diff --git a/.gitignore b/.gitignore index 1122ff039..94a1b559b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,5 +9,7 @@ __pycache__ /osbuild-dnf-json-tests /osbuild-tests /osbuild-weldr-tests +/osbuild-composer-cloud +/osbuild-composer-cloud-tests /rpmbuild diff --git a/Makefile b/Makefile index eae820a85..890d2291b 100644 --- a/Makefile +++ b/Makefile @@ -109,6 +109,7 @@ man: $(MANPAGES_TROFF) .PHONY: build build: go build -o osbuild-composer ./cmd/osbuild-composer/ + go build -o osbuild-composer-cloud ./cmd/osbuild-composer-cloud/ go build -o osbuild-worker ./cmd/osbuild-worker/ go build -o osbuild-pipeline ./cmd/osbuild-pipeline/ go build -o osbuild-upload-azure ./cmd/osbuild-upload-azure/ @@ -117,12 +118,14 @@ build: go test -c -tags=integration -o osbuild-weldr-tests ./internal/client/ go test -c -tags=integration -o osbuild-dnf-json-tests ./cmd/osbuild-dnf-json-tests/main_test.go go test -c -tags=integration -o osbuild-image-tests ./cmd/osbuild-image-tests/ + go test -c -tags=integration -o osbuild-composer-cloud-tests ./cmd/osbuild-composer-cloud-tests/main_test.go .PHONY: install install: - mkdir -p /usr/libexec/osbuild-composer cp osbuild-composer /usr/libexec/osbuild-composer/ cp osbuild-worker /usr/libexec/osbuild-composer/ + cp osbuild-composer-cloud /usr/libexec/osbuild-composer/ cp dnf-json /usr/libexec/osbuild-composer/ - mkdir -p /usr/share/osbuild-composer/repositories cp repositories/* /usr/share/osbuild-composer/repositories diff --git a/cmd/osbuild-composer-cloud-tests/main_test.go b/cmd/osbuild-composer-cloud-tests/main_test.go new file mode 100644 index 000000000..55923719f --- /dev/null +++ b/cmd/osbuild-composer-cloud-tests/main_test.go @@ -0,0 +1,80 @@ +// +build integration + +package main + +import ( + "context" + "net/http" + "testing" + + "github.com/osbuild/osbuild-composer/internal/cloudapi" + "github.com/stretchr/testify/require" + "github.com/google/uuid" +) + +func TestCloud(t *testing.T) { + client, err := cloudapi.NewClientWithResponses("http://127.0.0.1:8703/") + if err != nil { + panic(err) + } + + response, err := client.ComposeWithResponse(context.Background(), cloudapi.ComposeJSONRequestBody{ + Distribution: "rhel-8", + ImageRequests: []cloudapi.ImageRequest{ + { + Architecture: "x86_64", + ImageType: "qcow2", + Repositories: []cloudapi.Repository{ + { + Baseurl: "https://cdn.redhat.com/content/dist/rhel8/8/x86_64/baseos/os", + }, + { + Baseurl: "https://cdn.redhat.com/content/dist/rhel8/8/x86_64/appstream/os", + }, + }, + UploadRequests: []cloudapi.UploadRequest{ + { + Options: cloudapi.AWSUploadRequestOptions{ + Ec2: cloudapi.AWSUploadRequestOptionsEc2{ + AccessKeyId: "access-key-id", + SecretAccessKey: "my-secret-key", + }, + Region: "eu-central-1", + S3: cloudapi.AWSUploadRequestOptionsS3{ + AccessKeyId: "access-key-id", + SecretAccessKey: "my-secret-key", + Bucket: "bucket", + }, + }, + Type: "aws", + }, + }, + }, + }, + Customizations: &cloudapi.Customizations{ + Subscription: &cloudapi.Subscription { + ActivationKey: "somekey", + BaseUrl: "http://cdn.stage.redhat.com/", + ServerUrl: "subscription.rhsm.stage.redhat.com", + Organization: 00000, + Insights: true, + }, + }, + }) + + require.NoError(t, err) + require.Equalf(t, http.StatusCreated, response.StatusCode(), "Error: got non-201 status. Full response: %v", string(response.Body)) + require.NotNil(t, response.JSON201) + + response2, err := client.ComposeStatusWithResponse(context.Background(), response.JSON201.Id) + require.NoError(t, err) + require.Equalf(t, response2.StatusCode(), http.StatusOK, "Error: got non-200 status. Full response: %v", response2.Body) + + response2, err = client.ComposeStatusWithResponse(context.Background(), "invalid-id") + require.NoError(t, err) + require.Equalf(t, response2.StatusCode(), http.StatusBadRequest, "Error: got non-400 status. Full response: %v", response2.Body) + + response2, err = client.ComposeStatusWithResponse(context.Background(), uuid.New().String()) + require.NoError(t, err) + require.Equalf(t, response2.StatusCode(), http.StatusNotFound, "Error: got non-404 status. Full response: %s", response2.Body) +} diff --git a/cmd/osbuild-composer-cloud/main.go b/cmd/osbuild-composer-cloud/main.go new file mode 100644 index 000000000..ad0beb2a7 --- /dev/null +++ b/cmd/osbuild-composer-cloud/main.go @@ -0,0 +1,135 @@ +package main + +import ( + "crypto/tls" + "crypto/x509" + "flag" + "fmt" + "io/ioutil" + "log" + "net" + "os" + "path" + + "github.com/osbuild/osbuild-composer/internal/cloudapi" + "github.com/osbuild/osbuild-composer/internal/distro" + "github.com/osbuild/osbuild-composer/internal/distro/fedora31" + "github.com/osbuild/osbuild-composer/internal/distro/fedora32" + "github.com/osbuild/osbuild-composer/internal/distro/rhel8" + "github.com/osbuild/osbuild-composer/internal/jobqueue/fsjobqueue" + "github.com/osbuild/osbuild-composer/internal/rpmmd" + "github.com/osbuild/osbuild-composer/internal/worker" + + "github.com/coreos/go-systemd/activation" +) + +type connectionConfig struct { + CACertFile string + ServerKeyFile string + ServerCertFile string +} + +func createTLSConfig(c *connectionConfig) (*tls.Config, error) { + caCertPEM, err := ioutil.ReadFile(c.CACertFile) + if err != nil { + panic(fmt.Sprintf("Failed to read root certificate %v", c.CACertFile)) + } + + roots := x509.NewCertPool() + ok := roots.AppendCertsFromPEM(caCertPEM) + if !ok { + panic(fmt.Sprintf("Failed to parse root certificate %v", c.CACertFile)) + } + + cert, err := tls.LoadX509KeyPair(c.ServerCertFile, c.ServerKeyFile) + if err != nil { + return nil, err + } + return &tls.Config{ + Certificates: []tls.Certificate{cert}, + ClientAuth: tls.RequireAndVerifyClientCert, + ClientCAs: roots, + }, nil +} + +func main() { + var verbose bool + flag.BoolVar(&verbose, "v", false, "Print access log") + flag.Parse() + + tlsConfig, err := createTLSConfig(&connectionConfig{ + CACertFile: "/etc/osbuild-composer/ca-crt.pem", + ServerKeyFile: "/etc/osbuild-composer/composer-key.pem", + ServerCertFile: "/etc/osbuild-composer/composer-crt.pem", + }) + + if err != nil { + log.Fatalf("TLS configuration cannot be created: %v", err.Error()) + } + + stateDir, ok := os.LookupEnv("STATE_DIRECTORY") + if !ok { + log.Fatal("STATE_DIRECTORY is not set. Is the service file missing StateDirectory=?") + } + + cacheDirectory, ok := os.LookupEnv("CACHE_DIRECTORY") + if !ok { + log.Fatal("CACHE_DIRECTORY is not set. Is the service file missing CacheDirectory=?") + } + + listeners, err := activation.ListenersWithNames() + if err != nil { + log.Fatalf("Could not get listening sockets: " + err.Error()) + } + + var cloudListener net.Listener + var jobListener net.Listener + if composerListeners, exists := listeners["osbuild-composer-cloud.socket"]; exists { + if len(composerListeners) != 2 { + log.Fatalf("Unexpected number of listening sockets (%d), expected 2", len(composerListeners)) + } + + cloudListener = composerListeners[0] + jobListener = tls.NewListener(composerListeners[1], tlsConfig) + } else { + log.Fatalf("osbuild-composer-cloud.socket doesn't exist") + } + + var logger *log.Logger + if verbose { + logger = log.New(os.Stdout, "", 0) + } + + queueDir := path.Join(stateDir, "jobs") + err = os.Mkdir(queueDir, 0700) + if err != nil && !os.IsExist(err) { + log.Fatalf("cannot create queue directory: %v", err) + } + + jobs, err := fsjobqueue.New(queueDir, []string{"osbuild"}) + if err != nil { + log.Fatalf("cannot create jobqueue: %v", err) + } + + rpm := rpmmd.NewRPMMD(path.Join(cacheDirectory, "rpmmd"), "/usr/libexec/osbuild-composer/dnf-json") + + distros, err := distro.NewRegistry(fedora31.New(), fedora32.New(), rhel8.New()) + if err != nil { + log.Fatalf("Error loading distros: %v", err) + } + + workerServer := worker.NewServer(logger, jobs, "") + cloudServer := cloudapi.NewServer(workerServer, rpm, distros) + + go func() { + err := workerServer.Serve(jobListener) + if err != nil { + panic(err) + } + }() + + err = cloudServer.Serve(cloudListener) + if err != nil { + panic(err) + } +} diff --git a/distribution/osbuild-composer-cloud.service b/distribution/osbuild-composer-cloud.service new file mode 100644 index 000000000..cf5e0d98a --- /dev/null +++ b/distribution/osbuild-composer-cloud.service @@ -0,0 +1,19 @@ +[Unit] +Description=OSBuild Composer cloud +After=multi-user.target +Requires=osbuild-composer-cloud.socket + +[Service] +Type=simple +ExecStart=/usr/libexec/osbuild-composer/osbuild-composer-cloud +CacheDirectory=osbuild-composer-cloud +StateDirectory=osbuild-composer-cloud +WorkingDirectory=/usr/libexec/osbuild-composer/ +Restart=on-failure + +# systemd >= 240 sets this, but osbuild-composer runs on earlier versions +Environment="CACHE_DIRECTORY=/var/cache/osbuild-composer-cloud" +Environment="STATE_DIRECTORY=/var/lib/osbuild-composer-cloud" + +[Install] +WantedBy=multi-user.target diff --git a/distribution/osbuild-composer-cloud.socket b/distribution/osbuild-composer-cloud.socket new file mode 100644 index 000000000..e73dfc6d6 --- /dev/null +++ b/distribution/osbuild-composer-cloud.socket @@ -0,0 +1,10 @@ +[Unit] +Description=OSBuild Composer cloud API sockets + +[Socket] +Service=osbuild-composer-cloud.service +ListenStream=8703 +ListenStream=8704 + +[Install] +WantedBy=sockets.target diff --git a/go.sum b/go.sum index 3951b9096..1dbf11c1b 100644 --- a/go.sum +++ b/go.sum @@ -58,6 +58,7 @@ github.com/getkin/kin-openapi v0.13.0 h1:03fqBEEgivp4MVK2ElB140B56hjO9ZFvFTHBsvF github.com/getkin/kin-openapi v0.13.0/go.mod h1:WGRs2ZMM1Q8LR1QBEwUxC6RJEfaBcD0s+pcEVXFuAjw= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-chi/chi v4.0.2+incompatible h1:maB6vn6FqCxrpz4FqWdh4+lwpyZIQS7YEAUcHlgXVRs= github.com/go-chi/chi v4.0.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= diff --git a/internal/cloudapi/openapi.gen.go b/internal/cloudapi/openapi.gen.go new file mode 100644 index 000000000..1d41c056b --- /dev/null +++ b/internal/cloudapi/openapi.gen.go @@ -0,0 +1,529 @@ +// Package cloudapi provides primitives to interact the openapi HTTP API. +// +// Code generated by github.com/deepmap/oapi-codegen DO NOT EDIT. +package cloudapi + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "github.com/deepmap/oapi-codegen/pkg/runtime" + "github.com/go-chi/chi" + "io" + "io/ioutil" + "net/http" + "net/url" + "strings" +) + +// AWSUploadRequestOptions defines model for AWSUploadRequestOptions. +type AWSUploadRequestOptions struct { + Ec2 AWSUploadRequestOptionsEc2 `json:"ec2"` + Region string `json:"region"` + S3 AWSUploadRequestOptionsS3 `json:"s3"` +} + +// AWSUploadRequestOptionsEc2 defines model for AWSUploadRequestOptionsEc2. +type AWSUploadRequestOptionsEc2 struct { + AccessKeyId string `json:"access_key_id"` + SecretAccessKey string `json:"secret_access_key"` + SnapshotName *string `json:"snapshot_name,omitempty"` +} + +// AWSUploadRequestOptionsS3 defines model for AWSUploadRequestOptionsS3. +type AWSUploadRequestOptionsS3 struct { + AccessKeyId string `json:"access_key_id"` + Bucket string `json:"bucket"` + SecretAccessKey string `json:"secret_access_key"` +} + +// AWSUploadStatus defines model for AWSUploadStatus. +type AWSUploadStatus struct { + AmiId *string `json:"ami_id,omitempty"` +} + +// ComposeRequest defines model for ComposeRequest. +type ComposeRequest struct { + Customizations *Customizations `json:"customizations,omitempty"` + Distribution string `json:"distribution"` + ImageRequests []ImageRequest `json:"image_requests"` +} + +// ComposeResult defines model for ComposeResult. +type ComposeResult struct { + Id string `json:"id"` +} + +// ComposeStatus defines model for ComposeStatus. +type ComposeStatus struct { + ImageStatuses *[]ImageStatus `json:"image_statuses,omitempty"` + Status string `json:"status"` +} + +// Customizations defines model for Customizations. +type Customizations struct { + Subscription *Subscription `json:"subscription,omitempty"` +} + +// ImageRequest defines model for ImageRequest. +type ImageRequest struct { + Architecture string `json:"architecture"` + ImageType string `json:"image_type"` + Repositories []Repository `json:"repositories"` + UploadRequests []UploadRequest `json:"upload_requests"` +} + +// ImageStatus defines model for ImageStatus. +type ImageStatus struct { + Status string `json:"status"` + UploadStatuses *[]UploadStatus `json:"upload_statuses,omitempty"` +} + +// Repository defines model for Repository. +type Repository struct { + Baseurl string `json:"baseurl"` +} + +// Subscription defines model for Subscription. +type Subscription struct { + ActivationKey string `json:"activation-key"` + BaseUrl string `json:"base-url"` + Insights bool `json:"insights"` + Organization int `json:"organization"` + ServerUrl string `json:"server-url"` +} + +// UploadRequest defines model for UploadRequest. +type UploadRequest struct { + Options interface{} `json:"options"` + Type string `json:"type"` +} + +// UploadStatus defines model for UploadStatus. +type UploadStatus interface{} + +// ComposeJSONBody defines parameters for Compose. +type ComposeJSONBody ComposeRequest + +// ComposeRequestBody defines body for Compose for application/json ContentType. +type ComposeJSONRequestBody ComposeJSONBody + +// RequestEditorFn is the function signature for the RequestEditor callback function +type RequestEditorFn func(ctx context.Context, req *http.Request) error + +// Doer performs HTTP requests. +// +// The standard http.Client implements this interface. +type HttpRequestDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client which conforms to the OpenAPI3 specification for this service. +type Client struct { + // The endpoint of the server conforming to this interface, with scheme, + // https://api.deepmap.com for example. + Server string + + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A callback for modifying requests which are generated before sending over + // the network. + RequestEditor RequestEditorFn +} + +// ClientOption allows setting custom parameters during construction +type ClientOption func(*Client) error + +// Creates a new Client, with reasonable defaults +func NewClient(server string, opts ...ClientOption) (*Client, error) { + // create a client with sane default values + client := Client{ + Server: server, + } + // mutate client and add all optional params + for _, o := range opts { + if err := o(&client); err != nil { + return nil, err + } + } + // ensure the server URL always has a trailing slash + if !strings.HasSuffix(client.Server, "/") { + client.Server += "/" + } + // create httpClient, if not already present + if client.Client == nil { + client.Client = http.DefaultClient + } + return &client, nil +} + +// WithHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithHTTPClient(doer HttpRequestDoer) ClientOption { + return func(c *Client) error { + c.Client = doer + return nil + } +} + +// WithRequestEditorFn allows setting up a callback function, which will be +// called right before sending the request. This can be used to mutate the request. +func WithRequestEditorFn(fn RequestEditorFn) ClientOption { + return func(c *Client) error { + c.RequestEditor = fn + return nil + } +} + +// The interface specification for the client above. +type ClientInterface interface { + // Compose request with any body + ComposeWithBody(ctx context.Context, contentType string, body io.Reader) (*http.Response, error) + + Compose(ctx context.Context, body ComposeJSONRequestBody) (*http.Response, error) + + // ComposeStatus request + ComposeStatus(ctx context.Context, id string) (*http.Response, error) +} + +func (c *Client) ComposeWithBody(ctx context.Context, contentType string, body io.Reader) (*http.Response, error) { + req, err := NewComposeRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if c.RequestEditor != nil { + err = c.RequestEditor(ctx, req) + if err != nil { + return nil, err + } + } + return c.Client.Do(req) +} + +func (c *Client) Compose(ctx context.Context, body ComposeJSONRequestBody) (*http.Response, error) { + req, err := NewComposeRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if c.RequestEditor != nil { + err = c.RequestEditor(ctx, req) + if err != nil { + return nil, err + } + } + return c.Client.Do(req) +} + +func (c *Client) ComposeStatus(ctx context.Context, id string) (*http.Response, error) { + req, err := NewComposeStatusRequest(c.Server, id) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if c.RequestEditor != nil { + err = c.RequestEditor(ctx, req) + if err != nil { + return nil, err + } + } + return c.Client.Do(req) +} + +// NewComposeRequest calls the generic Compose builder with application/json body +func NewComposeRequest(server string, body ComposeJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewComposeRequestWithBody(server, "application/json", bodyReader) +} + +// NewComposeRequestWithBody generates requests for Compose with any type of body +func NewComposeRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + queryUrl, err := url.Parse(server) + if err != nil { + return nil, err + } + + basePath := fmt.Sprintf("/compose") + if basePath[0] == '/' { + basePath = basePath[1:] + } + + queryUrl, err = queryUrl.Parse(basePath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryUrl.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + return req, nil +} + +// NewComposeStatusRequest generates requests for ComposeStatus +func NewComposeStatusRequest(server string, id string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParam("simple", false, "id", id) + if err != nil { + return nil, err + } + + queryUrl, err := url.Parse(server) + if err != nil { + return nil, err + } + + basePath := fmt.Sprintf("/compose/%s", pathParam0) + if basePath[0] == '/' { + basePath = basePath[1:] + } + + queryUrl, err = queryUrl.Parse(basePath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryUrl.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// ClientWithResponses builds on ClientInterface to offer response payloads +type ClientWithResponses struct { + ClientInterface +} + +// NewClientWithResponses creates a new ClientWithResponses, which wraps +// Client with return type handling +func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { + client, err := NewClient(server, opts...) + if err != nil { + return nil, err + } + return &ClientWithResponses{client}, nil +} + +// WithBaseURL overrides the baseURL. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) error { + newBaseURL, err := url.Parse(baseURL) + if err != nil { + return err + } + c.Server = newBaseURL.String() + return nil + } +} + +// ClientWithResponsesInterface is the interface specification for the client with responses above. +type ClientWithResponsesInterface interface { + // Compose request with any body + ComposeWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader) (*ComposeResponse, error) + + ComposeWithResponse(ctx context.Context, body ComposeJSONRequestBody) (*ComposeResponse, error) + + // ComposeStatus request + ComposeStatusWithResponse(ctx context.Context, id string) (*ComposeStatusResponse, error) +} + +type ComposeResponse struct { + Body []byte + HTTPResponse *http.Response + JSON201 *ComposeResult +} + +// Status returns HTTPResponse.Status +func (r ComposeResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ComposeResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type ComposeStatusResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *ComposeStatus +} + +// Status returns HTTPResponse.Status +func (r ComposeStatusResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ComposeStatusResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ComposeWithBodyWithResponse request with arbitrary body returning *ComposeResponse +func (c *ClientWithResponses) ComposeWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader) (*ComposeResponse, error) { + rsp, err := c.ComposeWithBody(ctx, contentType, body) + if err != nil { + return nil, err + } + return ParseComposeResponse(rsp) +} + +func (c *ClientWithResponses) ComposeWithResponse(ctx context.Context, body ComposeJSONRequestBody) (*ComposeResponse, error) { + rsp, err := c.Compose(ctx, body) + if err != nil { + return nil, err + } + return ParseComposeResponse(rsp) +} + +// ComposeStatusWithResponse request returning *ComposeStatusResponse +func (c *ClientWithResponses) ComposeStatusWithResponse(ctx context.Context, id string) (*ComposeStatusResponse, error) { + rsp, err := c.ComposeStatus(ctx, id) + if err != nil { + return nil, err + } + return ParseComposeStatusResponse(rsp) +} + +// ParseComposeResponse parses an HTTP response from a ComposeWithResponse call +func ParseComposeResponse(rsp *http.Response) (*ComposeResponse, error) { + bodyBytes, err := ioutil.ReadAll(rsp.Body) + defer rsp.Body.Close() + if err != nil { + return nil, err + } + + response := &ComposeResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: + var dest ComposeResult + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON201 = &dest + + } + + return response, nil +} + +// ParseComposeStatusResponse parses an HTTP response from a ComposeStatusWithResponse call +func ParseComposeStatusResponse(rsp *http.Response) (*ComposeStatusResponse, error) { + bodyBytes, err := ioutil.ReadAll(rsp.Body) + defer rsp.Body.Close() + if err != nil { + return nil, err + } + + response := &ComposeStatusResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest ComposeStatus + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + } + + return response, nil +} + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // Create compose + // (POST /compose) + Compose(w http.ResponseWriter, r *http.Request) + // The status of a compose + // (GET /compose/{id}) + ComposeStatus(w http.ResponseWriter, r *http.Request, id string) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface +} + +// Compose operation middleware +func (siw *ServerInterfaceWrapper) Compose(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + siw.Handler.Compose(w, r.WithContext(ctx)) +} + +// ComposeStatus operation middleware +func (siw *ServerInterfaceWrapper) ComposeStatus(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var err error + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameter("simple", false, "id", chi.URLParam(r, "id"), &id) + if err != nil { + http.Error(w, fmt.Sprintf("Invalid format for parameter id: %s", err), http.StatusBadRequest) + return + } + + siw.Handler.ComposeStatus(w, r.WithContext(ctx), id) +} + +// Handler creates http.Handler with routing matching OpenAPI spec. +func Handler(si ServerInterface) http.Handler { + return HandlerFromMux(si, chi.NewRouter()) +} + +// HandlerFromMux creates http.Handler with routing matching OpenAPI spec based on the provided mux. +func HandlerFromMux(si ServerInterface, r chi.Router) http.Handler { + wrapper := ServerInterfaceWrapper{ + Handler: si, + } + + r.Group(func(r chi.Router) { + r.Post("/compose", wrapper.Compose) + }) + r.Group(func(r chi.Router) { + r.Get("/compose/{id}", wrapper.ComposeStatus) + }) + + return r +} diff --git a/internal/cloudapi/openapi.yml b/internal/cloudapi/openapi.yml new file mode 100644 index 000000000..da2734d8d --- /dev/null +++ b/internal/cloudapi/openapi.yml @@ -0,0 +1,241 @@ +--- +openapi: 3.0.1 +info: + version: '1' + title: OSBuild Composer cloud api + description: Service to build and install images. + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html + +paths: + /compose/{id}: + get: + summary: The status of a compose + parameters: + - in: path + name: id + schema: + type: string + format: uuid + example: '123e4567-e89b-12d3-a456-426655440000' + required: true + description: ID of compose status to get + description: Get the status of a running or completed compose. This includes whether or not it succeeded, and also meta information about the result. + operationId: compose_status + responses: + '200': + description: compose status + content: + application/json: + schema: + $ref: '#/components/schemas/ComposeStatus' + '400': + description: Invalid compose id + content: + text/plain: + schema: + type: string + '404': + description: Unknown compose id + content: + text/plain: + schema: + type: string + /compose: + post: + summary: Create compose + description: Create a new compose, potentially consisting of several images and upload each to their destinations. + operationId: compose + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ComposeRequest' + responses: + '201': + description: Compose has started + content: + application/json: + schema: + $ref: '#/components/schemas/ComposeResult' + +components: + schemas: + ComposeStatus: + required: + - status + properties: + status: + type: string + enum: ['success', 'failure', 'pending'] + example: 'success' + image_statuses: + type: array + items: + $ref: '#/components/schemas/ImageStatus' + ImageStatus: + required: + - status + properties: + status: + type: string + enum: ['success', 'failure', 'pending', 'building', 'uploading', 'registering'] + example: 'success' + upload_statuses: + type: array + items: + $ref: '#/components/schemas/UploadStatus' + UploadStatus: + oneOf: + - $ref: '#/components/schemas/AWSUploadStatus' + AWSUploadStatus: + type: object + properties: + ami_id: + type: string + example: 'ami-0c830793775595d4b' + ComposeRequest: + type: object + required: + - distribution + - image_requests + properties: + distribution: + type: string + example: 'rhel-8' + image_requests: + type: array + items: + $ref: '#/components/schemas/ImageRequest' + customizations: + $ref: '#/components/schemas/Customizations' + ImageRequest: + required: + - architecture + - image_type + - repositories + - upload_requests + properties: + architecture: + type: string + example: 'x86_64' + image_type: + type: string + example: 'ami' + repositories: + type: array + items: + $ref: '#/components/schemas/Repository' + upload_requests: + type: array + items: + $ref: '#/components/schemas/UploadRequest' + Repository: + type: object + required: + - baseurl + properties: + baseurl: + type: string + format: url + example: 'https://cdn.redhat.com/content/dist/rhel8/8/x86_64/baseos/os/' + UploadRequest: + type: object + required: + - type + - options + properties: + type: + type: string + enum: ['aws'] + options: + oneOf: + - $ref: '#/components/schemas/AWSUploadRequestOptions' + AWSUploadRequestOptions: + type: object + required: + - region + - s3 + - ec2 + properties: + region: + type: string + example: 'eu-west-1' + s3: + $ref: '#/components/schemas/AWSUploadRequestOptionsS3' + ec2: + $ref: '#/components/schemas/AWSUploadRequestOptionsEc2' + AWSUploadRequestOptionsS3: + type: object + required: + - access_key_id + - secret_access_key + - bucket + properties: + access_key_id: + type: string + example: 'AKIAIOSFODNN7EXAMPLE' + secret_access_key: + type: string + format: password + example: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY' + bucket: + type: string + example: 'my-bucket' + AWSUploadRequestOptionsEc2: + type: object + required: + - access_key_id + - secret_access_key + properties: + access_key_id: + type: string + example: 'AKIAIOSFODNN7EXAMPLE' + secret_access_key: + type: string + format: password + example: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY' + snapshot_name: + type: string + example: 'my-snapshot' + Customizations: + type: object + properties: + subscription: + $ref: '#/components/schemas/Subscription' + Subscription: + type: object + required: + - organization + - activation-key + - server-url + - base-url + - insights + properties: + organization: + type: integer + example: 2040324 + activation-key: + type: string + format: password + example: 'my-secret-key' + server-url: + type: string + example: 'subscription.rhsm.redhat.com' + base-url: + type: string + format: url + example: http://cdn.redhat.com/ + insights: + type: boolean + example: true + ComposeResult: + required: + - id + properties: + id: + type: string + format: uuid + example: '123e4567-e89b-12d3-a456-426655440000' diff --git a/internal/cloudapi/server.go b/internal/cloudapi/server.go new file mode 100644 index 000000000..a51c253a0 --- /dev/null +++ b/internal/cloudapi/server.go @@ -0,0 +1,226 @@ +//go:generate go run github.com/deepmap/oapi-codegen/cmd/oapi-codegen --package=cloudapi --generate types,chi-server,client -o openapi.gen.go openapi.yml + +package cloudapi + +import ( + "encoding/json" + "fmt" + "net" + "net/http" + + "github.com/google/uuid" + + "github.com/osbuild/osbuild-composer/internal/blueprint" + "github.com/osbuild/osbuild-composer/internal/distro" + "github.com/osbuild/osbuild-composer/internal/rpmmd" + "github.com/osbuild/osbuild-composer/internal/target" + "github.com/osbuild/osbuild-composer/internal/worker" +) + +// Server represents the state of the cloud Server +type Server struct { + workers *worker.Server + rpmMetadata rpmmd.RPMMD + distros *distro.Registry +} + +// NewServer creates a new cloud server +func NewServer(workers *worker.Server, rpmMetadata rpmmd.RPMMD, distros *distro.Registry) *Server { + server := &Server{ + workers: workers, + rpmMetadata: rpmMetadata, + distros: distros, + } + return server +} + +// Serve serves the cloud API over the provided listener socket +func (server *Server) Serve(listener net.Listener) error { + s := http.Server{Handler: Handler(server)} + + err := s.Serve(listener) + if err != nil && err != http.ErrServerClosed { + return err + } + + return nil +} + +// Compose handles a new /compose POST request +func (server *Server) Compose(w http.ResponseWriter, r *http.Request) { + contentType := r.Header["Content-Type"] + if len(contentType) != 1 || contentType[0] != "application/json" { + http.Error(w, "Only 'application/json' content type is supported", http.StatusUnsupportedMediaType) + return + } + + var request ComposeRequest + err := json.NewDecoder(r.Body).Decode(&request) + if err != nil { + http.Error(w, "Could not parse JSON body", http.StatusBadRequest) + return + } + + distribution := server.distros.GetDistro(request.Distribution) + if distribution == nil { + http.Error(w, fmt.Sprintf("Unsupported distribution: %s", request.Distribution), http.StatusBadRequest) + return + } + + type imageRequest struct { + manifest distro.Manifest + } + imageRequests := make([]imageRequest, len(request.ImageRequests)) + var targets []*target.Target + + for i, ir := range request.ImageRequests { + arch, err := distribution.GetArch(ir.Architecture) + if err != nil { + http.Error(w, fmt.Sprintf("Unsupported architecture '%s' for distribution '%s'", ir.Architecture, request.Distribution), http.StatusBadRequest) + return + } + imageType, err := arch.GetImageType(ir.ImageType) + if err != nil { + http.Error(w, fmt.Sprintf("Unsupported image type '%s' for %s/%s", ir.ImageType, ir.Architecture, request.Distribution), http.StatusBadRequest) + return + } + repositories := make([]rpmmd.RepoConfig, len(ir.Repositories)) + for j, repo := range ir.Repositories { + repositories[j].BaseURL = repo.Baseurl + repositories[j].RHSM = true + } + + var bp = blueprint.Blueprint{} + err = bp.Initialize() + if err != nil { + http.Error(w, "Unable to initialize blueprint", http.StatusInternalServerError) + return + } + + packageSpecs, _ := imageType.Packages(bp) + packages, _, err := server.rpmMetadata.Depsolve(packageSpecs, nil, repositories, distribution.ModulePlatformID(), arch.Name()) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to depsolve base packages for %s/%s/%s: %s", ir.ImageType, ir.Architecture, request.Distribution, err), http.StatusInternalServerError) + return + } + buildPackageSpecs := imageType.BuildPackages() + buildPackages, _, err := server.rpmMetadata.Depsolve(buildPackageSpecs, nil, repositories, distribution.ModulePlatformID(), arch.Name()) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to depsolve build packages for %s/%s/%s: %s", ir.ImageType, ir.Architecture, request.Distribution, err), http.StatusInternalServerError) + return + } + + imageOptions := distro.ImageOptions{Size: imageType.Size(0)} + if request.Customizations != nil && request.Customizations.Subscription != nil { + imageOptions.Subscription = &distro.SubscriptionImageOptions{ + Organization: request.Customizations.Subscription.Organization, + ActivationKey: request.Customizations.Subscription.ActivationKey, + ServerUrl: request.Customizations.Subscription.ServerUrl, + BaseUrl: request.Customizations.Subscription.BaseUrl, + Insights: request.Customizations.Subscription.Insights, + } + } + + manifest, err := imageType.Manifest(nil, imageOptions, repositories, packages, buildPackages) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to get manifest for for %s/%s/%s: %s", ir.ImageType, ir.Architecture, request.Distribution, err), http.StatusBadRequest) + return + } + + imageRequests[i].manifest = manifest + + if len(ir.UploadRequests) != 1 { + http.Error(w, "Only compose requests with a single upload target are currently supported", http.StatusBadRequest) + return + } + uploadRequest := (ir.UploadRequests)[0] + /* oneOf is not supported by the openapi generator so marshal and unmarshal the uploadrequest based on the type */ + if uploadRequest.Type == "aws" { + var awsUploadOptions AWSUploadRequestOptions + jsonUploadOptions, err := json.Marshal(uploadRequest.Options) + if err != nil { + http.Error(w, "Unable to marshal aws upload request", http.StatusInternalServerError) + return + } + err = json.Unmarshal(jsonUploadOptions, &awsUploadOptions) + if err != nil { + http.Error(w, "Unable to unmarshal aws upload request", http.StatusInternalServerError) + return + } + + key := fmt.Sprintf("composer-cloudapi-%s", uuid.New().String()) + t := target.NewAWSTarget(&target.AWSTargetOptions{ + Filename: imageType.Filename(), + Region: awsUploadOptions.Region, + AccessKeyID: awsUploadOptions.S3.AccessKeyId, + SecretAccessKey: awsUploadOptions.S3.SecretAccessKey, + Bucket: awsUploadOptions.S3.Bucket, + Key: key, + }) + if awsUploadOptions.Ec2.SnapshotName != nil { + t.ImageName = *awsUploadOptions.Ec2.SnapshotName + } else { + t.ImageName = key + } + + targets = append(targets, t) + } else { + http.Error(w, "Unknown upload request type, only aws is supported", http.StatusBadRequest) + return + } + } + + var ir imageRequest + if len(imageRequests) == 1 { + // NOTE: the store currently does not support multi-image composes + ir = imageRequests[0] + } else { + http.Error(w, "Only single-image composes are currently supported", http.StatusBadRequest) + return + } + + id, err := server.workers.Enqueue(ir.manifest, targets) + if err != nil { + http.Error(w, "Failed to enqueue manifest", http.StatusInternalServerError) + return + } + + var response ComposeResult + response.Id = id.String() + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusCreated) + err = json.NewEncoder(w).Encode(response) + if err != nil { + panic("Failed to write response") + } +} + +// ComposeStatus handles a /compose/{id} GET request +func (server *Server) ComposeStatus(w http.ResponseWriter, r *http.Request, id string) { + composeId, err := uuid.Parse(id) + + if err != nil { + http.Error(w, fmt.Sprintf("Invalid format for parameter id: %s", err), http.StatusBadRequest) + return + } + + status, err := server.workers.JobStatus(composeId) + if err != nil { + http.Error(w, fmt.Sprintf("Job %s not found: %s", id, err), http.StatusNotFound) + return + } + + response := ComposeStatus{ + Status: status.State.ToString(), // TODO: map the status correctly + ImageStatuses: &[]ImageStatus{ + { + Status: status.State.ToString(), // TODO: map the status correctly + }, + }, + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + err = json.NewEncoder(w).Encode(response) + if err != nil { + panic("Failed to write response") + } +} diff --git a/internal/distro/distro.go b/internal/distro/distro.go index 9b8107c6e..d5ec11b13 100644 --- a/internal/distro/distro.go +++ b/internal/distro/distro.go @@ -95,6 +95,8 @@ type OSTreeImageOptions struct { } // The SubscriptionImageOptions specify subscription-specific image options +// ServerUrl denotes the host to register the system with +// BaseUrl specifies the repository URL for DNF type SubscriptionImageOptions struct { Organization int ActivationKey string diff --git a/osbuild-composer.spec b/osbuild-composer.spec index dd809163d..41f12ef8e 100644 --- a/osbuild-composer.spec +++ b/osbuild-composer.spec @@ -40,6 +40,7 @@ BuildRequires: golang(github.com/BurntSushi/toml) BuildRequires: golang(github.com/coreos/go-semver/semver) BuildRequires: golang(github.com/coreos/go-systemd/activation) BuildRequires: golang(github.com/deepmap/oapi-codegen/pkg/codegen) +BuildRequires: golang(github.com/go-chi/chi) BuildRequires: golang(github.com/google/uuid) BuildRequires: golang(github.com/julienschmidt/httprouter) BuildRequires: golang(github.com/kolo/xmlrpc) @@ -94,6 +95,7 @@ export GOFLAGS=-mod=vendor %gobuild -o _bin/osbuild-composer %{goipath}/cmd/osbuild-composer %gobuild -o _bin/osbuild-worker %{goipath}/cmd/osbuild-worker +%gobuild -o _bin/osbuild-composer-cloud %{goipath}/cmd/osbuild-composer-cloud %if %{with tests} || 0%{?rhel} @@ -115,6 +117,7 @@ go test -c -tags=integration -ldflags="${TEST_LDFLAGS}" -o _bin/osbuild-tests %{ go test -c -tags=integration -ldflags="${TEST_LDFLAGS}" -o _bin/osbuild-dnf-json-tests %{goipath}/cmd/osbuild-dnf-json-tests go test -c -tags=integration -ldflags="${TEST_LDFLAGS}" -o _bin/osbuild-weldr-tests %{goipath}/internal/client/ go test -c -tags=integration -ldflags="${TEST_LDFLAGS}" -o _bin/osbuild-image-tests %{goipath}/cmd/osbuild-image-tests +go test -c -tags=integration -ldflags="${TEST_LDFLAGS}" -o _bin/osbuild-composer-cloud-tests %{goipath}/cmd/osbuild-composer-cloud-tests %endif @@ -134,12 +137,18 @@ install -m 0644 -vp distribution/osbuild-remote-worker.socket %{buildroot}%{_u install -m 0644 -vp distribution/osbuild-remote-worker@.service %{buildroot}%{_unitdir}/ install -m 0644 -vp distribution/osbuild-worker@.service %{buildroot}%{_unitdir}/ install -m 0644 -vp distribution/osbuild-composer-koji.socket %{buildroot}%{_unitdir}/ +install -m 0755 -vd %{buildroot}%{_unitdir} +install -m 0644 -vp distribution/osbuild-composer.{service,socket} %{buildroot}%{_unitdir}/ +install -m 0644 -vp distribution/osbuild-*worker*.{service,socket} %{buildroot}%{_unitdir}/ install -m 0755 -vd %{buildroot}%{_sysusersdir} install -m 0644 -vp distribution/osbuild-composer.conf %{buildroot}%{_sysusersdir}/ install -m 0755 -vd %{buildroot}%{_localstatedir}/cache/osbuild-composer/dnf-cache +install -m 0755 -vp _bin/osbuild-composer-cloud %{buildroot}%{_libexecdir}/osbuild-composer/ +install -m 0644 -vp distribution/osbuild-composer-cloud.{service,socket} %{buildroot}%{_unitdir}/ + %if %{with tests} || 0%{?rhel} install -m 0755 -vd %{buildroot}%{_libexecdir}/tests/osbuild-composer @@ -148,6 +157,7 @@ install -m 0755 -vp _bin/osbuild-weldr-tests %{buildroot}%{_l install -m 0755 -vp _bin/osbuild-dnf-json-tests %{buildroot}%{_libexecdir}/tests/osbuild-composer/ install -m 0755 -vp _bin/osbuild-image-tests %{buildroot}%{_libexecdir}/tests/osbuild-composer/ install -m 0755 -vp tools/image-info %{buildroot}%{_libexecdir}/osbuild-composer/ +install -m 0755 -vp _bin/osbuild-composer-cloud-tests %{buildroot}%{_libexecdir}/tests/osbuild-composer/ install -m 0755 -vd %{buildroot}%{_datadir}/tests/osbuild-composer install -m 0644 -vp test/azure-deployment-template.json %{buildroot}%{_datadir}/tests/osbuild-composer/ @@ -224,6 +234,27 @@ systemctl stop "osbuild-worker@*.service" "osbuild-remote-worker@*.service" # restart all the worker services %systemd_postun_with_restart "osbuild-worker@*.service" "osbuild-remote-worker@*.service" +%package cloud +Summary: The osbuild-composer cloud api +Requires: systemd + +%description cloud +The cloud api for osbuild-composer + +%files cloud +%{_libexecdir}/osbuild-composer/osbuild-composer-cloud +%{_unitdir}/osbuild-composer-cloud.socket +%{_unitdir}/osbuild-composer-cloud.service + +%post cloud +%systemd_post osbuild-composer-cloud.socket osbuild-composer-cloud.service + +%preun cloud +%systemd_preun osbuild-composer-cloud.socket osbuild-composer-cloud.service + +%postun cloud +%systemd_postun_with_restart osbuild-composer-cloud.socket osbuild-composer-cloud.service + %if %{with tests} || 0%{?rhel} %package tests diff --git a/osbuild-image-tests b/osbuild-image-tests new file mode 100755 index 000000000..a323263c7 Binary files /dev/null and b/osbuild-image-tests differ diff --git a/schutzbot/deploy.sh b/schutzbot/deploy.sh index 9f0b5249a..78c2ce653 100755 --- a/schutzbot/deploy.sh +++ b/schutzbot/deploy.sh @@ -77,6 +77,10 @@ sudo make worker-key-pair sudo systemctl enable --now osbuild-composer.socket sudo systemctl enable --now osbuild-composer-koji.socket +if [[ $ID == rhel ]]; then + sudo systemctl enable --now osbuild-composer-cloud.socket +fi + # Verify that the API is running. sudo composer-cli status show sudo composer-cli sources list diff --git a/test/image-tests/aws.sh b/test/image-tests/aws.sh index 6299da926..3f4135a5d 100755 --- a/test/image-tests/aws.sh +++ b/test/image-tests/aws.sh @@ -279,6 +279,96 @@ $AWS_CMD ec2 terminate-instances --instance-id "${INSTANCE_ID}" $AWS_CMD ec2 deregister-image --image-id "${AMI_IMAGE_ID}" $AWS_CMD ec2 delete-snapshot --snapshot-id "${SNAPSHOT_ID}" +# Use the return code of the smoke test to determine if we passed or failed. +# On rhel continue with the cloudapi test +if [[ $RESULTS == 1 ]] && [[ $ID != rhel ]]; then + greenprint "๐Ÿ’š Success" + exit 0 +elif [[ $RESULTS != 1 ]]; then + greenprint "โŒ Failed" + exit 1 +fi + +CLOUD_REQUEST_FILE=${TEMPDIR}/image_request.json +REPOSITORY_RHEL=repositories/rhel-8.json +if [[ $VERSION_ID == 8.3 ]]; then + REPOSITORY_RHEL=repositories/rhel-8-beta.json +fi + +sudo systemctl stop osbuild-worker* +sudo systemctl start osbuild-remote-worker@localhost:8704 + +BASE_URL=$(jq -r '.x86_64[0].baseurl' "$REPOSITORY_RHEL") +APPSTREAM_URL=$(jq -r '.x86_64[1].baseurl' "$REPOSITORY_RHEL") +SNAPSHOT_NAME=$(cat /proc/sys/kernel/random/uuid) + +tee "$CLOUD_REQUEST_FILE" > /dev/null << EOF +{ + "distribution": "rhel-8", + "image_requests": [ + { + "architecture": "x86_64", + "image_type": "qcow2", + "repositories": [ + { "baseurl": "${BASE_URL}" }, + { "baseurl": "${APPSTREAM_URL}" } + ], + "upload_requests": [ + { + "type": "aws", + "options": { + "region": "${AWS_REGION}", + "s3": { + "access_key_id": "${AWS_ACCESS_KEY_ID}", + "secret_access_key": "${AWS_SECRET_ACCESS_KEY}", + "bucket": "${AWS_BUCKET}" + }, + "ec2": { + "access_key_id": "${AWS_ACCESS_KEY_ID}", + "secret_access_key": "${AWS_SECRET_ACCESS_KEY}", + "snapshot_name": "${SNAPSHOT_NAME}" + } + } + } + ] + } + ] +} +EOF + +COMPOSE_ID=$(curl -sS -H 'Content-Type: application/json' -X POST -d @"$CLOUD_REQUEST_FILE" http://localhost:8703/compose | jq -r '.id') +# Wait for the compose to finish. +greenprint "โฑ Waiting for cloud compose to finish: ${COMPOSE_ID}" + +for LOOP_COUNTER in {0..40}; do + COMPOSE_STATUS=$(curl -sS http://localhost:8703/compose/"$COMPOSE_ID" | jq -r '.status') + + echo "Cloud compose $COMPOSE_ID status: $COMPOSE_STATUS" + if [[ $COMPOSE_STATUS == FAILED ]]; then + echo "Something went wrong with the cloudapi compose. ๐Ÿ˜ข" + exit 1 + elif [[ $COMPOSE_STATUS != RUNNING ]] && [[ $COMPOSE_STATUS != WAITING ]]; then + break + fi + + sleep 30 +done + +# Find the image that we made in AWS. +greenprint "๐Ÿ” Search for created AMI" +$AWS_CMD ec2 describe-images \ + --owners self \ + --filters Name=name,Values="$SNAPSHOT_NAME" \ + | tee "$AMI_DATA" > /dev/null + +AMI_IMAGE_ID=$(jq -r '.Images[].ImageId' "$AMI_DATA") +SNAPSHOT_ID=$(jq -r '.Images[].BlockDeviceMappings[].Ebs.SnapshotId' "$AMI_DATA") + +# Delete the image without running it +greenprint "๐Ÿงผ Cleaning up composer cloud image" +$AWS_CMD ec2 deregister-image --image-id "$AMI_IMAGE_ID" +$AWS_CMD ec2 delete-snapshot --snapshot-id "$SNAPSHOT_ID" + # Use the return code of the smoke test to determine if we passed or failed. if [[ $RESULTS == 1 ]]; then greenprint "๐Ÿ’š Success"