From 2f328b0e9704657e0d20c18d267a3d0d57dbb429 Mon Sep 17 00:00:00 2001 From: sanne Date: Tue, 31 Aug 2021 11:47:27 +0200 Subject: [PATCH] workers: Backwards compatible api.openshift.com spec compliance The main changes are: - Kind, Href, Id fields for every object returned - Attach operationIds to each request, return it for errors - Errors are predefined and queryable --- distribution/Dockerfile-ubi | 1 + internal/kojiapi/server_test.go | 12 +- internal/worker/api/api.gen.go | 154 ++++++++- internal/worker/api/api.go | 2 +- internal/worker/api/errors.go | 186 ++++++++++ internal/worker/api/openapi.yml | 317 +++++++++++------- internal/worker/client.go | 28 +- internal/worker/json.go | 27 +- internal/worker/server.go | 122 +++++-- internal/worker/server_test.go | 17 +- osbuild-composer.spec | 1 + schutzbot/containerbuild.sh | 23 +- .../regression-old-worker-new-composer.sh | 125 +++++++ test/cases/regression.sh | 10 +- 14 files changed, 816 insertions(+), 209 deletions(-) create mode 100644 internal/worker/api/errors.go create mode 100644 test/cases/regression-old-worker-new-composer.sh diff --git a/distribution/Dockerfile-ubi b/distribution/Dockerfile-ubi index 5ff29a687..fc8ee4348 100644 --- a/distribution/Dockerfile-ubi +++ b/distribution/Dockerfile-ubi @@ -13,6 +13,7 @@ RUN mkdir -p "/etc/osbuild-composer/" RUN mkdir -p "/run/osbuild-composer/" RUN mkdir -p "/var/cache/osbuild-composer/" RUN mkdir -p "/var/lib/osbuild-composer/" +RUN mkdir -p "/usr/share/osbuild-composer/" RUN mkdir -p "/opt/migrate/" COPY --from=builder /opt/app-root/src/go/bin/osbuild-composer /usr/libexec/osbuild-composer/ COPY ./containers/osbuild-composer/entrypoint.py /opt/entrypoint.py diff --git a/internal/kojiapi/server_test.go b/internal/kojiapi/server_test.go index dd6d01d7b..df0e68383 100644 --- a/internal/kojiapi/server_test.go +++ b/internal/kojiapi/server_test.go @@ -249,7 +249,8 @@ func TestCompose(t *testing.T) { initJobResult, err := json.Marshal(&jobResult{Result: result}) require.NoError(t, err) - test.TestRoute(t, workerHandler, false, "PATCH", fmt.Sprintf("/api/worker/v1/jobs/%v", token), string(initJobResult), http.StatusOK, `{}`) + test.TestRoute(t, workerHandler, false, "PATCH", fmt.Sprintf("/api/worker/v1/jobs/%v", token), string(initJobResult), http.StatusOK, + fmt.Sprintf(`{"href":"/api/worker/v1/jobs/%v","id":"%v","kind":"UpdateJobResponse"}`, token, token)) wg.Done() }(t, c.initResult) @@ -300,7 +301,8 @@ func TestCompose(t *testing.T) { buildJobResult, err := json.Marshal(&jobResult{Result: c.buildResult}) require.NoError(t, err) - test.TestRoute(t, workerHandler, false, "PATCH", fmt.Sprintf("/api/worker/v1/jobs/%v", token), string(buildJobResult), http.StatusOK, `{}`) + test.TestRoute(t, workerHandler, false, "PATCH", fmt.Sprintf("/api/worker/v1/jobs/%v", token), string(buildJobResult), http.StatusOK, + fmt.Sprintf(`{"href":"/api/worker/v1/jobs/%v","id":"%v","kind":"UpdateJobResponse"}`, token, token)) _, token, jobType, rawJob, _, err = workerServer.RequestJob(context.Background(), test_distro.TestArchName, []string{"osbuild-koji"}) require.NoError(t, err) @@ -322,7 +324,8 @@ func TestCompose(t *testing.T) { "success": true } } - }`, test_distro.TestArchName, test_distro.TestDistroName), http.StatusOK, `{}`) + }`, test_distro.TestArchName, test_distro.TestDistroName), http.StatusOK, + fmt.Sprintf(`{"href":"/api/worker/v1/jobs/%v","id":"%v","kind":"UpdateJobResponse"}`, token, token)) finalizeID, token, jobType, rawJob, _, err := workerServer.RequestJob(context.Background(), test_distro.TestArchName, []string{"koji-finalize"}) require.NoError(t, err) @@ -342,7 +345,8 @@ func TestCompose(t *testing.T) { finalizeResult, err := json.Marshal(&jobResult{Result: c.finalizeResult}) require.NoError(t, err) - test.TestRoute(t, workerHandler, false, "PATCH", fmt.Sprintf("/api/worker/v1/jobs/%v", token), string(finalizeResult), http.StatusOK, `{}`) + test.TestRoute(t, workerHandler, false, "PATCH", fmt.Sprintf("/api/worker/v1/jobs/%v", token), string(finalizeResult), http.StatusOK, + fmt.Sprintf(`{"href":"/api/worker/v1/jobs/%v","id":"%v","kind":"UpdateJobResponse"}`, token, token)) test.TestRoute(t, handler, false, "GET", fmt.Sprintf("/api/composer-koji/v1/compose/%v", finalizeID), ``, http.StatusOK, c.composeStatus) diff --git a/internal/worker/api/api.gen.go b/internal/worker/api/api.gen.go index 3d925e49e..cf6d89e1d 100644 --- a/internal/worker/api/api.gen.go +++ b/internal/worker/api/api.gen.go @@ -4,29 +4,86 @@ package api import ( + "bytes" + "compress/gzip" + "encoding/base64" + "encoding/json" "fmt" "github.com/deepmap/oapi-codegen/pkg/runtime" + "github.com/getkin/kin-openapi/openapi3" "github.com/labstack/echo/v4" "net/http" + "strings" ) // Error defines model for Error. type Error struct { - Message string `json:"message"` + // Embedded struct due to allOf(#/components/schemas/ObjectReference) + ObjectReference + // Embedded fields due to inline allOf schema + Code string `json:"code"` + + // Backward compatibility with workers <= v33, equals reason + Message string `json:"message"` + OperationId string `json:"operation_id"` + Reason string `json:"reason"` } -// RequestJobJSONBody defines parameters for RequestJob. -type RequestJobJSONBody struct { +// GetJobResponse defines model for GetJobResponse. +type GetJobResponse struct { + // Embedded struct due to allOf(#/components/schemas/ObjectReference) + ObjectReference + // Embedded fields due to inline allOf schema + Canceled bool `json:"canceled"` +} + +// ObjectReference defines model for ObjectReference. +type ObjectReference struct { + Href string `json:"href"` + Id string `json:"id"` + Kind string `json:"kind"` +} + +// RequestJobRequest defines model for RequestJobRequest. +type RequestJobRequest struct { Arch string `json:"arch"` Types []string `json:"types"` } -// UpdateJobJSONBody defines parameters for UpdateJob. -type UpdateJobJSONBody struct { - Result interface{} `json:"result"` - Status string `json:"status"` +// RequestJobResponse defines model for RequestJobResponse. +type RequestJobResponse struct { + // Embedded struct due to allOf(#/components/schemas/ObjectReference) + ObjectReference + // Embedded fields due to inline allOf schema + Args *json.RawMessage `json:"args,omitempty"` + ArtifactLocation string `json:"artifact_location"` + DynamicArgs *[]json.RawMessage `json:"dynamic_args,omitempty"` + Location string `json:"location"` + Type string `json:"type"` } +// StatusResponse defines model for StatusResponse. +type StatusResponse struct { + // Embedded struct due to allOf(#/components/schemas/ObjectReference) + ObjectReference + // Embedded fields due to inline allOf schema + Status string `json:"status"` +} + +// UpdateJobRequest defines model for UpdateJobRequest. +type UpdateJobRequest struct { + Result json.RawMessage `json:"result"` +} + +// UpdateJobResponse defines model for UpdateJobResponse. +type UpdateJobResponse ObjectReference + +// RequestJobJSONBody defines parameters for RequestJob. +type RequestJobJSONBody RequestJobRequest + +// UpdateJobJSONBody defines parameters for UpdateJob. +type UpdateJobJSONBody UpdateJobRequest + // RequestJobRequestBody defines body for RequestJob for application/json ContentType. type RequestJobJSONRequestBody RequestJobJSONBody @@ -35,6 +92,9 @@ type UpdateJobJSONRequestBody UpdateJobJSONBody // ServerInterface represents all server handlers. type ServerInterface interface { + // Get error description + // (GET /errors/{id}) + GetError(ctx echo.Context, id string) error // Request a job // (POST /jobs) RequestJob(ctx echo.Context) error @@ -47,6 +107,9 @@ type ServerInterface interface { // Upload an artifact // (PUT /jobs/{token}/artifacts/{name}) UploadJobArtifact(ctx echo.Context, token string, name string) error + // Get the openapi spec in json format + // (GET /openapi) + GetOpenapi(ctx echo.Context) error // status // (GET /status) GetStatus(ctx echo.Context) error @@ -57,6 +120,24 @@ type ServerInterfaceWrapper struct { Handler ServerInterface } +// GetError converts echo context to params. +func (w *ServerInterfaceWrapper) GetError(ctx echo.Context) error { + var err error + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameter("simple", false, "id", ctx.Param("id"), &id) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter id: %s", err)) + } + + ctx.Set("Bearer.Scopes", []string{""}) + + // Invoke the callback with all the unmarshalled arguments + err = w.Handler.GetError(ctx, id) + return err +} + // RequestJob converts echo context to params. func (w *ServerInterfaceWrapper) RequestJob(ctx echo.Context) error { var err error @@ -122,6 +203,15 @@ func (w *ServerInterfaceWrapper) UploadJobArtifact(ctx echo.Context) error { return err } +// GetOpenapi converts echo context to params. +func (w *ServerInterfaceWrapper) GetOpenapi(ctx echo.Context) error { + var err error + + // Invoke the callback with all the unmarshalled arguments + err = w.Handler.GetOpenapi(ctx) + return err +} + // GetStatus converts echo context to params. func (w *ServerInterfaceWrapper) GetStatus(ctx echo.Context) error { var err error @@ -153,10 +243,60 @@ func RegisterHandlers(router EchoRouter, si ServerInterface) { Handler: si, } + router.GET("/errors/:id", wrapper.GetError) router.POST("/jobs", wrapper.RequestJob) router.GET("/jobs/:token", wrapper.GetJob) router.PATCH("/jobs/:token", wrapper.UpdateJob) router.PUT("/jobs/:token/artifacts/:name", wrapper.UploadJobArtifact) + router.GET("/openapi", wrapper.GetOpenapi) router.GET("/status", wrapper.GetStatus) } + +// Base64 encoded, gzipped, json marshaled Swagger object +var swaggerSpec = []string{ + + "H4sIAAAAAAAC/9xXTW/jNhD9KwTbo2I5TfcioIfNtlhkiyJF0kUXSINgTI0tJhKpDCknhqH/XvDDX5Ji", + "Z4H4sDlZCcmZN2/eDIdLLnRVa4XKGp4tuREFVuA//yDS5D6gLC+nPLtZ8p8JpzzjP6WbQ2k8kV5O7lHY", + "K5wioRLI22TJa9I1kpXoDQqdo/u1ixp5xo0lqWa8TXiFxsDMr+VoBMnaSq14xs9BPDwB5cz5AysnspR2", + "wZ6kLdiTpgckw/5rxuMz8Rubn50lDB8bKA0jBKMVT/quHB5w1u9kPoglHu0v+bXHRhLmPLsJway3dwxv", + "QrpdY9CeH97etgn/jPaLnlyhqbUy+KYcgxJY4nZsE61LBNWPYLV1GGPXV9Z1VXigAxS+wOyDVPlhXj17", + "fmsSPPTRJfwKHxs0gUP/1UcHJIpBGO4ffoe0WJkXt/CMAxEsegDD+SQ4OATu7RMMNPO/zyczfRJ93xut", + "Rlfw9FcUXevQWTkFYe9KLSBU00Cg+UJBJcXdyuiakgPWdwlK+F4n4R+H8u5XtywNhTAs1GsLtjHH4Np4", + "y4exx33D8L7WOVjcJ1VC05T2IO0dp/HUkAK3XG5I+S4qnDOpprrfkv8ppGHSMFDs498XbKpp3YmtZhRi", + "ZKByVoDKS2T3emJGrhVLWzqYl9fnjSxz9snBMEjshP3rDfCEz5FMcHMam7WCWvKMn43GozFPeA228Jyl", + "6G4nky5l3rq/Z2j7WD+jQ8KkMtb1OqanzBbI/FFmahRyKjFnkwXzXWfdwi/ycDjcgM4rQYUWyXhR7Tq5", + "+H3HLnfE8cwj5QlXULmgvf1N9iw1mMS71sHGZ6hqz87pWf/Wam/d2ZBJH/wv43G4T5VF5eOGui5lqJL0", + "Pt5fG/P7Uh9ibH3Gf/327Sh2PxzFbptwg6IhaRc+LecIhMSzm1tHmGmqCmgRVRBSvp04dzx12vT1qM2A", + "fGLBGgZOxCPmpb8WCZuUWjwY1igry7DF18UcZAmTEkc9RW0uhigGNPZc54s346Z/LQaaOuI5PYrD2Gm8", + "w10ePxGCxZz/aArrxuE1s9HV1arXudRv9JQurX5Atd2Veo1lJYEj1XRnvBwI5fJP/kPW+05RU6OUVLNA", + "f69LD3Rhn5i9jXig89ZgwyS5m8X1HXukWu6NDYOlPD6Gv3csmxAlg13tdEs3XY2eJl066fharhs7pIJS", + "Q/5FTz7GE/w1OvQ/3yPD5O3k/DqtamHRnhhLCNUu6V2TL4ny3QnHJdpNkyttBNmsR9SXm/1l3PIanqI5", + "P5wyqZjD7mbsCvxg/+EYg1+3yL8qfK5RWMzj2KSFaMjpq9+C3di7F7PjaPOMGpzSr6WbfVnYFV8NxJ4K", + "KQpGaBtShhmkuRSrTUOz+vVq5WgdsvPOfI/tMdIbZ2uar3pYQyXPeAq1TMNjL52f+vfy1oKI77mTrR23", + "7f8BAAD//yi1+mtgFAAA", +} + +// GetSwagger returns the Swagger specification corresponding to the generated code +// in this file. +func GetSwagger() (*openapi3.Swagger, error) { + zipped, err := base64.StdEncoding.DecodeString(strings.Join(swaggerSpec, "")) + if err != nil { + return nil, fmt.Errorf("error base64 decoding spec: %s", err) + } + zr, err := gzip.NewReader(bytes.NewReader(zipped)) + if err != nil { + return nil, fmt.Errorf("error decompressing spec: %s", err) + } + var buf bytes.Buffer + _, err = buf.ReadFrom(zr) + if err != nil { + return nil, fmt.Errorf("error decompressing spec: %s", err) + } + + swagger, err := openapi3.NewSwaggerLoader().LoadSwaggerFromData(buf.Bytes()) + if err != nil { + return nil, fmt.Errorf("error loading Swagger: %s", err) + } + return swagger, nil +} diff --git a/internal/worker/api/api.go b/internal/worker/api/api.go index d379f8f2a..4a47ea9e1 100644 --- a/internal/worker/api/api.go +++ b/internal/worker/api/api.go @@ -1,4 +1,4 @@ -//go:generate go run github.com/deepmap/oapi-codegen/cmd/oapi-codegen -package=api -generate types,server -o api.gen.go openapi.yml +//go:generate go run github.com/deepmap/oapi-codegen/cmd/oapi-codegen -package=api -generate types,server,spec -o api.gen.go openapi.yml package api diff --git a/internal/worker/api/errors.go b/internal/worker/api/errors.go new file mode 100644 index 000000000..3da988384 --- /dev/null +++ b/internal/worker/api/errors.go @@ -0,0 +1,186 @@ +package api + +import ( + "fmt" + "net/http" + + "github.com/labstack/echo/v4" +) + +const ( + ErrorCodePrefix = "COMPOSER-WORKER-" + ErrorHREF = "/api/composer-worker/v1/errors" + + ErrorUnsupportedMediaType ServiceErrorCode = 3 + ErrorJobNotFound ServiceErrorCode = 5 + ErrorJobNotRunning ServiceErrorCode = 6 + ErrorMalformedJobId ServiceErrorCode = 7 + ErrorMalformedJobToken ServiceErrorCode = 8 + ErrorInvalidErrorId ServiceErrorCode = 9 + ErrorBodyDecodingError ServiceErrorCode = 10 + ErrorResourceNotFound ServiceErrorCode = 11 + ErrorMethodNotAllowed ServiceErrorCode = 12 + ErrorNotAcceptable ServiceErrorCode = 13 + ErrorErrorNotFound ServiceErrorCode = 14 + // ErrorTokenNotFound ServiceErrorCode = 6 + + // internal errors + ErrorDiscardingArtifact ServiceErrorCode = 1000 + ErrorCreatingArtifact ServiceErrorCode = 1001 + ErrorWritingArtifact ServiceErrorCode = 1002 + ErrorResolvingJobId ServiceErrorCode = 1003 + ErrorFinishingJob ServiceErrorCode = 1004 + ErrorRetrievingJobStatus ServiceErrorCode = 1005 + ErrorRequestingJob ServiceErrorCode = 1006 + ErrorFailedLoadingOpenAPISpec ServiceErrorCode = 1007 + + // Errors contained within this file + ErrorUnspecified ServiceErrorCode = 10000 + ErrorNotHTTPError ServiceErrorCode = 10001 + ErrorServiceErrorNotFound ServiceErrorCode = 10002 + ErrorMalformedOperationID ServiceErrorCode = 10003 +) + +type ServiceErrorCode int + +type serviceError struct { + code ServiceErrorCode + httpStatus int + reason string +} + +type serviceErrors []serviceError + +func getServiceErrors() serviceErrors { + return serviceErrors{ + serviceError{ErrorUnsupportedMediaType, http.StatusUnsupportedMediaType, "Only 'application/json' content is supported"}, + serviceError{ErrorBodyDecodingError, http.StatusBadRequest, "Malformed json, unable to decode body"}, + + serviceError{ErrorJobNotFound, http.StatusNotFound, "Token not found"}, + serviceError{ErrorJobNotRunning, http.StatusBadRequest, "Job is not running"}, + serviceError{ErrorMalformedJobId, http.StatusBadRequest, "Given job id is not a uuidv4"}, + serviceError{ErrorMalformedJobToken, http.StatusBadRequest, "Given job id is not a uuidv4"}, + + serviceError{ErrorDiscardingArtifact, http.StatusInternalServerError, "Error discarding artifact"}, + serviceError{ErrorCreatingArtifact, http.StatusInternalServerError, "Error creating artifact"}, + serviceError{ErrorWritingArtifact, http.StatusInternalServerError, "Error writing artifact"}, + serviceError{ErrorResolvingJobId, http.StatusInternalServerError, "Error resolving id from job token"}, + serviceError{ErrorFinishingJob, http.StatusInternalServerError, "Error finishing job"}, + serviceError{ErrorRetrievingJobStatus, http.StatusInternalServerError, "Error requesting job"}, + serviceError{ErrorRequestingJob, http.StatusInternalServerError, "Error requesting job"}, + serviceError{ErrorInvalidErrorId, http.StatusBadRequest, "Invalid format for error id, it should be an integer as a string"}, + serviceError{ErrorFailedLoadingOpenAPISpec, http.StatusInternalServerError, "Unable to load openapi spec"}, + serviceError{ErrorResourceNotFound, http.StatusNotFound, "Requested resource doesn't exist"}, + serviceError{ErrorMethodNotAllowed, http.StatusMethodNotAllowed, "Requested method isn't supported for resource"}, + serviceError{ErrorNotAcceptable, http.StatusNotAcceptable, "Only 'application/json' content is supported"}, + serviceError{ErrorErrorNotFound, http.StatusNotFound, "Error with given id not found"}, + + serviceError{ErrorUnspecified, http.StatusInternalServerError, "Unspecified internal error "}, + serviceError{ErrorNotHTTPError, http.StatusInternalServerError, "Error is not an instance of HTTPError"}, + serviceError{ErrorServiceErrorNotFound, http.StatusInternalServerError, "Error does not exist"}, + serviceError{ErrorMalformedOperationID, http.StatusInternalServerError, "OperationID is empty or is not a string"}, + } +} + +func find(code ServiceErrorCode) *serviceError { + for _, e := range getServiceErrors() { + if e.code == code { + return &e + } + } + return &serviceError{ErrorServiceErrorNotFound, http.StatusInternalServerError, "Error does not exist"} +} + +// Make an echo compatible error out of a service error +func HTTPError(code ServiceErrorCode) error { + return HTTPErrorWithInternal(code, nil) +} + +// echo.HTTPError has a message interface{} field, which can be used to include the ServiceErrorCode +func HTTPErrorWithInternal(code ServiceErrorCode, internalErr error) error { + se := find(code) + he := echo.NewHTTPError(se.httpStatus, se.code) + if internalErr != nil { + he.Internal = internalErr + } + return he +} + +// Convert a ServiceErrorCode into an Error as defined in openapi.v2.yml +// serviceError is optional, prevents multiple find() calls +func APIError(code ServiceErrorCode, serviceError *serviceError, c echo.Context) *Error { + se := serviceError + if se == nil { + se = find(code) + } + + operationID, ok := c.Get("operationID").(string) + if !ok || operationID == "" { + c.Logger().Errorf("Couldn't find operationID handling error %v", code) + se = find(ErrorMalformedOperationID) + } + + return &Error{ + ObjectReference: ObjectReference{ + Href: fmt.Sprintf("%s/%d", ErrorHREF, se.code), + Id: fmt.Sprintf("%d", se.code), + Kind: "Error", + }, + Code: fmt.Sprintf("%s%d", ErrorCodePrefix, se.code), + OperationId: operationID, // set operation id from context + Reason: se.reason, + Message: se.reason, // backward compatibility + } +} + +func apiErrorFromEchoError(echoError *echo.HTTPError) ServiceErrorCode { + switch echoError.Code { + case http.StatusNotFound: + return ErrorResourceNotFound + case http.StatusMethodNotAllowed: + return ErrorMethodNotAllowed + case http.StatusNotAcceptable: + return ErrorNotAcceptable + default: + return ErrorUnspecified + } +} + +// Convert an echo error into an AOC compliant one so we send a correct json error response +func HTTPErrorHandler(echoError error, c echo.Context) { + doResponse := func(code ServiceErrorCode, c echo.Context) { + if !c.Response().Committed { + var err error + sec := find(code) + apiErr := APIError(code, sec, c) + + if sec.httpStatus == http.StatusInternalServerError { + c.Logger().Errorf("Internal server error. Code: %s, OperationId: %s", + apiErr.Code, apiErr.OperationId) + } + + if c.Request().Method == http.MethodHead { + err = c.NoContent(sec.httpStatus) + } else { + err = c.JSON(sec.httpStatus, apiErr) + } + if err != nil { + c.Logger().Error(err) + } + } + } + + he, ok := echoError.(*echo.HTTPError) + if !ok { + doResponse(ErrorNotHTTPError, c) + return + } + + sec, ok := he.Message.(ServiceErrorCode) + if !ok { + // No service code was set, so Echo threw this error + doResponse(apiErrorFromEchoError(he), c) + return + } + doResponse(sec, c) +} diff --git a/internal/worker/api/openapi.yml b/internal/worker/api/openapi.yml index 478abd603..b84e29458 100644 --- a/internal/worker/api/openapi.yml +++ b/internal/worker/api/openapi.yml @@ -5,105 +5,76 @@ info: description: This is an API for workers to request and handle jobs. servers: - url: /api/worker/v1 + - url: /api/composer-worker/v1 + paths: + /openapi: + get: + operationId: getOpenapi + summary: Get the openapi spec in json format + responses: + '200': + description: openapi spec in json format + '500': + description: Unexpected error occurred + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /status: get: + operationId: GetStatus summary: status - tags: [] + description: Simple status handler which returns service status responses: '200': description: OK - headers: {} content: application/json: schema: - type: object - properties: - status: - type: string - enum: - - OK - required: - - status - 4XX: + $ref: '#/components/schemas/StatusResponse' + '4XX': content: application/json: schema: $ref: '#/components/schemas/Error' - 5XX: - description: '' + '5XX': content: application/json: schema: $ref: '#/components/schemas/Error' - operationId: GetStatus - description: Simple status handler to check whether the service is up. + /jobs: post: + operationId: RequestJob summary: Request a job - tags: [] + description: Requests a job. This operation blocks until a job is available. + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/RequestJobRequest' responses: '201': description: Created content: application/json: schema: - type: object - additionalProperties: false - properties: - id: - type: string - format: uuid - location: - type: string - artifact_location: - type: string - type: - type: string - enum: - - osbuild - args: {} - dynamic_args: - type: array - items: {} - required: - - type - - location - - id - 4XX: + $ref: '#/components/schemas/RequestJobResponse' + '4XX': content: application/json: schema: $ref: '#/components/schemas/Error' - 5XX: + '5XX': description: '' content: application/json: schema: $ref: '#/components/schemas/Error' - operationId: RequestJob - requestBody: - content: - application/json: - schema: - type: object - additionalProperties: false - properties: - types: - type: array - items: - type: string - enum: - - osbuild - arch: - type: string - required: - - types - - arch - description: '' - description: Requests a job. This operation blocks until a job is available. - parameters: [] - '/jobs/{token}': + + /jobs/{token}: parameters: - schema: type: string @@ -111,98 +82,212 @@ paths: in: path required: true get: + operationId: GetJob summary: Get running job - tags: [] responses: '200': description: OK content: application/json: schema: - type: object - properties: - canceled: - type: boolean - required: - - canceled - 4XX: - description: '' + $ref: '#/components/schemas/GetJobResponse' + '4XX': content: application/json: schema: $ref: '#/components/schemas/Error' - 5XX: - description: '' + '5XX': content: application/json: schema: $ref: '#/components/schemas/Error' - operationId: GetJob - description: '' patch: - summary: Update a running job - tags: [] - responses: {} operationId: UpdateJob + summary: Update a running job requestBody: content: application/json: schema: - type: object - properties: - status: - type: string - enum: - - WAITING - - RUNNING - - FINISHED - - FAILED - result: {} - required: - - status - - result - '/jobs/{token}/artifacts/{name}': - parameters: - - schema: - type: string - name: name - in: path - required: true - - schema: - type: string - name: token - in: path - required: true - put: - summary: Upload an artifact - tags: [] + $ref: '#/components/schemas/UpdateJobRequest' responses: '200': description: OK - 4XX: + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateJobResponse' + '4XX': content: application/json: schema: $ref: '#/components/schemas/Error' - 5XX: - description: '' + '5XX': content: application/json: schema: $ref: '#/components/schemas/Error' + + /jobs/{token}/artifacts/{name}: + put: operationId: UploadJobArtifact + summary: Upload an artifact requestBody: content: application/octet-stream: schema: type: string + parameters: + - schema: + type: string + name: name + in: path + required: true + - schema: + type: string + name: token + in: path + required: true + responses: + '200': + description: OK + '4XX': + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '5XX': + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /errors/{id}: + get: + operationId: getError + summary: Get error description + description: Get an instance of the error specified by id + security: + - Bearer: [] + parameters: + - in: path + name: id + schema: + type: string + example: '13' + required: true + description: ID of the error + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '4XX': + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '5XX': + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + components: schemas: - Error: - title: Error + ObjectReference: type: object - properties: - message: - type: string required: - - message + - id + - kind + - href + properties: + id: + type: string + kind: + type: string + href: + type: string + + Error: + allOf: + - $ref: '#/components/schemas/ObjectReference' + - type: object + required: + - code + - reason + - operation_id + - message + properties: + code: + type: string + reason: + type: string + operation_id: + type: string + message: + description: Backward compatibility with workers <= v33, equals reason + type: string + + StatusResponse: + allOf: + - $ref: '#/components/schemas/ObjectReference' + - type: object + required: + - status + properties: + status: + type: string + + RequestJobRequest: + type: object + required: + - types + - arch + properties: + types: + type: array + items: + type: string + arch: + type: string + RequestJobResponse: + allOf: + - $ref: '#/components/schemas/ObjectReference' + - type: object + required: + - type + - location + - artifact_location + properties: + location: + type: string + artifact_location: + type: string + type: + type: string + args: + x-go-type: json.RawMessage + dynamic_args: + type: array + items: + x-go-type: json.RawMessage + + GetJobResponse: + allOf: + - $ref: '#/components/schemas/ObjectReference' + - type: object + required: + - canceled + properties: + canceled: + type: boolean + UpdateJobRequest: + type: object + required: + - result + properties: + result: + x-go-type: json.RawMessage + UpdateJobResponse: + $ref: '#/components/schemas/ObjectReference' diff --git a/internal/worker/client.go b/internal/worker/client.go index 439f6e7cb..9396809a1 100644 --- a/internal/worker/client.go +++ b/internal/worker/client.go @@ -185,7 +185,7 @@ func (c *Client) RequestJob(types []string, arch string) (Job, error) { return nil, errorFromResponse(response, "error requesting job") } - var jr requestJobResponse + var jr api.RequestJobResponse err = json.NewDecoder(response.Body).Decode(&jr) if err != nil { return nil, fmt.Errorf("error parsing response: %v", err) @@ -201,12 +201,26 @@ func (c *Client) RequestJob(types []string, arch string) (Job, error) { return nil, fmt.Errorf("error parsing artifact location url in response: %v", err) } + jobId, err := uuid.Parse(jr.Id) + if err != nil { + return nil, fmt.Errorf("error parsing job id in response: %v", err) + } + + args := json.RawMessage{} + if jr.Args != nil { + args = *jr.Args + } + dynamicArgs := []json.RawMessage{} + if jr.DynamicArgs != nil { + dynamicArgs = *jr.DynamicArgs + } + return &job{ client: c, - id: jr.Id, + id: jobId, jobType: jr.Type, - args: jr.Args, - dynamicArgs: jr.DynamicArgs, + args: args, + dynamicArgs: dynamicArgs, location: location.String(), artifactLocation: artifactLocation.String(), }, nil @@ -242,7 +256,7 @@ func (j *job) DynamicArgs(i int, args interface{}) error { func (j *job) Update(result interface{}) error { var buf bytes.Buffer - err := json.NewEncoder(&buf).Encode(api.UpdateJobJSONRequestBody{ + err := json.NewEncoder(&buf).Encode(updateJobRequest{ Result: result, }) if err != nil { @@ -285,7 +299,7 @@ func (j *job) Canceled() (bool, error) { return false, errorFromResponse(response, "error fetching job info") } - var jr getJobResponse + var jr api.GetJobResponse err = json.NewDecoder(response.Body).Decode(&jr) if err != nil { return false, fmt.Errorf("error parsing reponse: %v", err) @@ -337,5 +351,5 @@ func errorFromResponse(response *http.Response, message string) error { if err != nil { return fmt.Errorf("failed to parse error response: %v", err) } - return fmt.Errorf("%v: %v — %v", message, response.StatusCode, e.Message) + return fmt.Errorf("%v: %v — %s (%v)", message, response.StatusCode, e.Reason, e.Code) } diff --git a/internal/worker/json.go b/internal/worker/json.go index 896703443..0d074220c 100644 --- a/internal/worker/json.go +++ b/internal/worker/json.go @@ -1,9 +1,6 @@ package worker import ( - "encoding/json" - - "github.com/google/uuid" "github.com/osbuild/osbuild-composer/internal/distro" osbuild "github.com/osbuild/osbuild-composer/internal/osbuild1" "github.com/osbuild/osbuild-composer/internal/target" @@ -76,29 +73,9 @@ type KojiFinalizeJobResult struct { } // -// JSON-serializable types for the HTTP API +// JSON-serializable types for the client // -type statusResponse struct { - Status string `json:"status"` -} - -type requestJobResponse struct { - Id uuid.UUID `json:"id"` - Location string `json:"location"` - ArtifactLocation string `json:"artifact_location"` - Type string `json:"type"` - Args json.RawMessage `json:"args,omitempty"` - DynamicArgs []json.RawMessage `json:"dynamic_args,omitempty"` -} - -type getJobResponse struct { - Canceled bool `json:"canceled"` -} - type updateJobRequest struct { - Result json.RawMessage `json:"result"` -} - -type updateJobResponse struct { + Result interface{} `json:"result"` } diff --git a/internal/worker/server.go b/internal/worker/server.go index 84ca25284..9ab72c61d 100644 --- a/internal/worker/server.go +++ b/internal/worker/server.go @@ -11,11 +11,13 @@ import ( "net/http" "os" "path" + "strconv" "time" "github.com/google/uuid" "github.com/labstack/echo/v4" + "github.com/osbuild/osbuild-composer/internal/common" "github.com/osbuild/osbuild-composer/internal/jobqueue" "github.com/osbuild/osbuild-composer/internal/prometheus" "github.com/osbuild/osbuild-composer/internal/worker/api" @@ -53,11 +55,8 @@ func (s *Server) Handler() http.Handler { e.StdLogger = s.logger // log errors returned from handlers - e.HTTPErrorHandler = func(err error, c echo.Context) { - log.Println(c.Path(), c.QueryParams().Encode(), err.Error()) - e.DefaultHTTPErrorHandler(err, c) - } - + e.HTTPErrorHandler = api.HTTPErrorHandler + e.Pre(common.OperationIDMiddleware) handler := apiHandlers{ server: s, } @@ -135,8 +134,10 @@ func (s *Server) Job(id uuid.UUID, job interface{}) (string, json.RawMessage, [] return "", nil, nil, err } - if err := json.Unmarshal(rawArgs, job); err != nil { - return "", nil, nil, fmt.Errorf("error unmarshaling arguments for job '%s': %v", id, err) + if job != nil { + if err := json.Unmarshal(rawArgs, job); err != nil { + return "", nil, nil, fmt.Errorf("error unmarshaling arguments for job '%s': %v", id, err) + } } return jobType, rawArgs, deps, nil @@ -284,12 +285,39 @@ type apiHandlers struct { server *Server } +func (h *apiHandlers) GetOpenapi(ctx echo.Context) error { + spec, err := api.GetSwagger() + if err != nil { + return api.HTTPError(api.ErrorFailedLoadingOpenAPISpec) + } + return ctx.JSON(http.StatusOK, spec) +} + func (h *apiHandlers) GetStatus(ctx echo.Context) error { - return ctx.JSON(http.StatusOK, &statusResponse{ + return ctx.JSON(http.StatusOK, &api.StatusResponse{ + ObjectReference: api.ObjectReference{ + Href: fmt.Sprintf("%s/status", api.BasePath), + Id: "status", + Kind: "Status", + }, Status: "OK", }) } +func (h *apiHandlers) GetError(ctx echo.Context, id string) error { + errorId, err := strconv.Atoi(id) + if err != nil { + return api.HTTPError(api.ErrorInvalidErrorId) + } + + apiError := api.APIError(api.ServiceErrorCode(errorId), nil, ctx) + // If the service error wasn't found, it's a 404 in this instance + if apiError.Id == fmt.Sprintf("%d", api.ErrorServiceErrorNotFound) { + return api.HTTPError(api.ErrorErrorNotFound) + } + return ctx.JSON(http.StatusOK, apiError) +} + func (h *apiHandlers) RequestJob(ctx echo.Context) error { var body api.RequestJobJSONRequestBody err := ctx.Bind(&body) @@ -299,47 +327,73 @@ func (h *apiHandlers) RequestJob(ctx echo.Context) error { jobId, token, jobType, jobArgs, dynamicJobArgs, err := h.server.RequestJob(ctx.Request().Context(), body.Arch, body.Types) if err != nil { - return err + return api.HTTPErrorWithInternal(api.ErrorRequestingJob, err) } - return ctx.JSON(http.StatusCreated, requestJobResponse{ - Id: jobId, + var respArgs *json.RawMessage + if len(jobArgs) != 0 { + respArgs = &jobArgs + } + var respDynArgs *[]json.RawMessage + if len(dynamicJobArgs) != 0 { + respDynArgs = &dynamicJobArgs + } + + response := api.RequestJobResponse{ + ObjectReference: api.ObjectReference{ + Href: fmt.Sprintf("%s/jobs", api.BasePath), + Id: jobId.String(), + Kind: "RequestJob", + }, Location: fmt.Sprintf("%s/jobs/%v", api.BasePath, token), ArtifactLocation: fmt.Sprintf("%s/jobs/%v/artifacts/", api.BasePath, token), Type: jobType, - Args: jobArgs, - DynamicArgs: dynamicJobArgs, - }) + Args: respArgs, + DynamicArgs: respDynArgs, + } + return ctx.JSON(http.StatusCreated, response) } func (h *apiHandlers) GetJob(ctx echo.Context, tokenstr string) error { token, err := uuid.Parse(tokenstr) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "cannot parse job token") + return api.HTTPError(api.ErrorMalformedJobToken) } jobId, err := h.server.jobs.IdFromToken(token) if err != nil { switch err { case jobqueue.ErrNotExist: - return ErrInvalidToken + return api.HTTPError(api.ErrorJobNotFound) default: - return err + return api.HTTPError(api.ErrorResolvingJobId) } } if jobId == uuid.Nil { - return ctx.JSON(http.StatusOK, getJobResponse{}) + return ctx.JSON(http.StatusOK, api.GetJobResponse{ + ObjectReference: api.ObjectReference{ + Href: fmt.Sprintf("%s/jobs/%v", api.BasePath, token), + Id: token.String(), + Kind: "JobStatus", + }, + Canceled: false, + }) } h.server.jobs.RefreshHeartbeat(token) status, _, err := h.server.JobStatus(jobId, &json.RawMessage{}) if err != nil { - return err + return api.HTTPErrorWithInternal(api.ErrorRetrievingJobStatus, err) } - return ctx.JSON(http.StatusOK, getJobResponse{ + return ctx.JSON(http.StatusOK, api.GetJobResponse{ + ObjectReference: api.ObjectReference{ + Href: fmt.Sprintf("%s/jobs/%v", api.BasePath, token), + Id: token.String(), + Kind: "JobStatus", + }, Canceled: status.Canceled, }) } @@ -347,10 +401,10 @@ func (h *apiHandlers) GetJob(ctx echo.Context, tokenstr string) error { func (h *apiHandlers) UpdateJob(ctx echo.Context, idstr string) error { token, err := uuid.Parse(idstr) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "cannot parse job token") + return api.HTTPError(api.ErrorMalformedJobId) } - var body updateJobRequest + var body api.UpdateJobRequest err = ctx.Bind(&body) if err != nil { return err @@ -360,21 +414,25 @@ func (h *apiHandlers) UpdateJob(ctx echo.Context, idstr string) error { if err != nil { switch err { case ErrInvalidToken: - fallthrough + return api.HTTPError(api.ErrorJobNotFound) case ErrJobNotRunning: - return echo.NewHTTPError(http.StatusNotFound, "not found") + return api.HTTPError(api.ErrorJobNotRunning) default: - return err + return api.HTTPError(api.ErrorFinishingJob) } } - return ctx.JSON(http.StatusOK, updateJobResponse{}) + return ctx.JSON(http.StatusOK, api.UpdateJobResponse{ + Href: fmt.Sprintf("%s/jobs/%v", api.BasePath, token), + Id: token.String(), + Kind: "UpdateJobResponse", + }) } func (h *apiHandlers) UploadJobArtifact(ctx echo.Context, tokenstr string, name string) error { token, err := uuid.Parse(tokenstr) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "cannot parse job token") + return api.HTTPError(api.ErrorMalformedJobId) } request := ctx.Request() @@ -382,19 +440,19 @@ func (h *apiHandlers) UploadJobArtifact(ctx echo.Context, tokenstr string, name if h.server.artifactsDir == "" { _, err := io.Copy(ioutil.Discard, request.Body) if err != nil { - return fmt.Errorf("error discarding artifact: %v", err) + return api.HTTPError(api.ErrorDiscardingArtifact) } return ctx.NoContent(http.StatusOK) } f, err := os.Create(path.Join(h.server.artifactsDir, "tmp", token.String(), name)) if err != nil { - return fmt.Errorf("cannot create artifact file: %v", err) + return api.HTTPError(api.ErrorDiscardingArtifact) } _, err = io.Copy(f, request.Body) if err != nil { - return fmt.Errorf("error writing artifact file: %v", err) + return api.HTTPError(api.ErrorWritingArtifact) } return ctx.NoContent(http.StatusOK) @@ -410,12 +468,12 @@ func (b binder) Bind(i interface{}, ctx echo.Context) error { contentType := request.Header["Content-Type"] if len(contentType) != 1 || contentType[0] != "application/json" { - return echo.NewHTTPError(http.StatusUnsupportedMediaType, "request must be json-encoded") + return api.HTTPError(api.ErrorUnsupportedMediaType) } err := json.NewDecoder(request.Body).Decode(i) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "cannot parse request body: "+err.Error()) + return api.HTTPError(api.ErrorBodyDecodingError) } return nil diff --git a/internal/worker/server_test.go b/internal/worker/server_test.go index 219af9055..8a75828d0 100644 --- a/internal/worker/server_test.go +++ b/internal/worker/server_test.go @@ -37,7 +37,7 @@ func TestStatus(t *testing.T) { server := newTestServer(t, tempdir) handler := server.Handler() - test.TestRoute(t, handler, false, "GET", "/api/worker/v1/status", ``, http.StatusOK, `{"status":"OK"}`, "message") + test.TestRoute(t, handler, false, "GET", "/api/worker/v1/status", ``, http.StatusOK, `{"status":"OK", "href": "/api/worker/v1/status", "kind":"Status"}`, "message", "id") } func TestErrors(t *testing.T) { @@ -68,7 +68,7 @@ func TestErrors(t *testing.T) { for _, c := range cases { server := newTestServer(t, tempdir) handler := server.Handler() - test.TestRoute(t, handler, false, c.Method, c.Path, c.Body, c.ExpectedStatus, "{}", "message") + test.TestRoute(t, handler, false, c.Method, c.Path, c.Body, c.ExpectedStatus, `{"kind":"Error"}`, "message", "href", "operation_id", "reason", "id", "code") } } @@ -98,7 +98,7 @@ func TestCreate(t *testing.T) { test.TestRoute(t, handler, false, "POST", "/api/worker/v1/jobs", fmt.Sprintf(`{"types":["osbuild"],"arch":"%s"}`, test_distro.TestArchName), http.StatusCreated, - `{"type":"osbuild","args":{"manifest":{"pipeline":{},"sources":{}}}}`, "id", "location", "artifact_location") + `{"kind":"RequestJob","href":"/api/worker/v1/jobs","type":"osbuild","args":{"manifest":{"pipeline":{},"sources":{}}}}`, "id", "location", "artifact_location") } func TestCancel(t *testing.T) { @@ -133,13 +133,13 @@ func TestCancel(t *testing.T) { require.Nil(t, dynamicArgs) test.TestRoute(t, handler, false, "GET", fmt.Sprintf("/api/worker/v1/jobs/%s", token), `{}`, http.StatusOK, - `{"canceled":false}`) + fmt.Sprintf(`{"canceled":false,"href":"/api/worker/v1/jobs/%s","id":"%s","kind":"JobStatus"}`, token, token)) err = server.Cancel(jobId) require.NoError(t, err) test.TestRoute(t, handler, false, "GET", fmt.Sprintf("/api/worker/v1/jobs/%s", token), `{}`, http.StatusOK, - `{"canceled":true}`) + fmt.Sprintf(`{"canceled":true,"href":"/api/worker/v1/jobs/%s","id":"%s","kind":"JobStatus"}`, token, token)) } func TestUpdate(t *testing.T) { @@ -173,8 +173,11 @@ func TestUpdate(t *testing.T) { require.NotNil(t, args) require.Nil(t, dynamicArgs) - test.TestRoute(t, handler, false, "PATCH", fmt.Sprintf("/api/worker/v1/jobs/%s", token), `{}`, http.StatusOK, `{}`) - test.TestRoute(t, handler, false, "PATCH", fmt.Sprintf("/api/worker/v1/jobs/%s", token), `{}`, http.StatusNotFound, `*`) + test.TestRoute(t, handler, false, "PATCH", fmt.Sprintf("/api/worker/v1/jobs/%s", token), `{}`, http.StatusOK, + fmt.Sprintf(`{"href":"/api/worker/v1/jobs/%s","id":"%s","kind":"UpdateJobResponse"}`, token, token)) + test.TestRoute(t, handler, false, "PATCH", fmt.Sprintf("/api/worker/v1/jobs/%s", token), `{}`, http.StatusNotFound, + `{"href":"/api/composer-worker/v1/errors/5","code":"COMPOSER-WORKER-5","id":"5","kind":"Error","message":"Token not found","reason":"Token not found"}`, + "operation_id") } func TestArgs(t *testing.T) { diff --git a/osbuild-composer.spec b/osbuild-composer.spec index 6d4f270b8..02dd5cf32 100644 --- a/osbuild-composer.spec +++ b/osbuild-composer.spec @@ -110,6 +110,7 @@ Obsoletes: osbuild-composer-koji <= 23 # Remove when F33 is EOL sed -i "s/openapi3.Swagger/openapi3.T/;s/openapi3.NewSwaggerLoader().LoadSwaggerFromData/openapi3.NewLoader().LoadFromData/" internal/cloudapi/v1/openapi.v1.gen.go sed -i "s/openapi3.Swagger/openapi3.T/;s/openapi3.NewSwaggerLoader().LoadSwaggerFromData/openapi3.NewLoader().LoadFromData/" internal/cloudapi/v2/openapi.v2.gen.go +sed -i "s/openapi3.Swagger/openapi3.T/;s/openapi3.NewSwaggerLoader().LoadSwaggerFromData/openapi3.NewLoader().LoadFromData/" internal/worker/api/api.gen.go %endif %build diff --git a/schutzbot/containerbuild.sh b/schutzbot/containerbuild.sh index e3179be81..e2ad1acb9 100755 --- a/schutzbot/containerbuild.sh +++ b/schutzbot/containerbuild.sh @@ -1,21 +1,26 @@ #!/bin/bash set -euo pipefail - -echo "Query host" - -COMMIT=$(git rev-parse HEAD) - - echo "Prepare host system" sudo dnf -y install podman - echo "Build container" +IMAGE_NAME="quay.io/osbuild/osbuild-composer-ubi-pr" +IMAGE_TAG="${CI_COMMIT_SHA:-$(git rev-parse HEAD)}" + podman \ build \ - "--file=distribution/Dockerfile-ubi" \ - "--tag=osbuild-composer:${COMMIT}" \ + --file="distribution/Dockerfile-ubi" \ + --tag="${IMAGE_NAME}:${IMAGE_TAG}" \ + --label="quay.expires-after=1w" \ . + +# Push to reuse later in the pipeline (see regression tests) +BRANCH_NAME="${BRANCH_NAME:-${CI_COMMIT_BRANCH}}" +if [[ "$BRANCH_NAME" =~ ^PR-[0-9]+$ ]]; then + podman push \ + --creds "${QUAY_USERNAME}":"${QUAY_PASSWORD}" \ + "${IMAGE_NAME}:${IMAGE_TAG}" +fi diff --git a/test/cases/regression-old-worker-new-composer.sh b/test/cases/regression-old-worker-new-composer.sh new file mode 100644 index 000000000..cb79ad3af --- /dev/null +++ b/test/cases/regression-old-worker-new-composer.sh @@ -0,0 +1,125 @@ +#!/bin/bash + +# Verify that an older worker (v33) is still compatible with this composer +# version. +# +# Any tweaks to the worker api need to be backwards compatible. + +set -exuo pipefail + +WORKER_VERSION=8f21f0b873420a38a261d78a7df130f28b8e2867 +WORKER_RPM=osbuild-composer-worker-33-1.20210830git8f21f0b.el8.x86_64 + +# grab the repos from the test rpms +REPOS=$(mktemp -d) +sudo dnf -y install osbuild-composer-tests +sudo cp -a /usr/share/tests/osbuild-composer/repositories "$REPOS/repositories" +sudo cp -fv "$REPOS/repositories/rhel-8.json" "$REPOS/repositories/rhel-84.json" + +# Remove the "new" worker +sudo dnf remove -y osbuild-composer osbuild-composer-worker osbuild-composer-tests + +function setup_repo { + local project=$1 + local commit=$2 + local priority=${3:-10} + echo "Setting up dnf repository for ${project} ${commit}" + sudo tee "/etc/yum.repos.d/${project}.repo" << EOF +[${project}] +name=${project} ${commit} +baseurl=http://osbuild-composer-repos.s3-website.us-east-2.amazonaws.com/${project}/rhel-8-cdn/x86_64/${commit} +enabled=1 +gpgcheck=0 +priority=${priority} +EOF +} + +# Composer v33 +setup_repo osbuild-composer "$WORKER_VERSION" 20 +sudo dnf install -y osbuild-composer-worker podman composer-cli + +# verify the right worker is installed just to be sure +rpm -q "$WORKER_RPM" + +# run container +WELDR_DIR="$(mktemp -d)" +WELDR_SOCK="$WELDR_DIR/api.socket" + +sudo podman pull --creds "${QUAY_USERNAME}":"${QUAY_PASSWORD}" \ + "quay.io/osbuild/osbuild-composer-ubi-pr:${CI_COMMIT_SHA}" +sudo podman run \ + --name=composer \ + -d \ + -v /etc/osbuild-composer:/etc/osbuild-composer:Z \ + -v "$REPOS/repositories":/usr/share/osbuild-composer/repositories:Z \ + -v "$WELDR_DIR:/run/weldr/":Z \ + -p 8700:8700 \ + "quay.io/osbuild/osbuild-composer-ubi-pr:${CI_COMMIT_SHA}" \ + --weldr-api --remote-worker-api \ + --no-local-worker-api --no-composer-api + +# try starting a worker +set +e +sudo systemctl start osbuild-remote-worker@localhost:8700.service +while ! sudo systemctl --quiet is-active osbuild-remote-worker@localhost:8700.service; do + sudo systemctl status osbuild-remote-worker@localhost:8700.service + sleep 1 + sudo systemctl start osbuild-remote-worker@localhost:8700.service +done +set -e + +function log_on_exit() { + sudo podman logs composer +} + +trap log_on_exit EXIT + +BLUEPRINT_FILE=$(mktemp) +COMPOSE_START=$(mktemp) +COMPOSE_INFO=$(mktemp) +tee "$BLUEPRINT_FILE" > /dev/null << EOF2 +name = "simple" +version = "0.0.1" + +[customizations] +hostname = "simple" +EOF2 + +sudo composer-cli -s "$WELDR_SOCK" blueprints push "$BLUEPRINT_FILE" +sudo composer-cli -s "$WELDR_SOCK" blueprints depsolve simple +sudo composer-cli -s "$WELDR_SOCK" --json compose start simple qcow2 | tee "${COMPOSE_START}" +if rpm -q --quiet weldr-client; then + COMPOSE_ID=$(jq -r '.body.build_id' "$COMPOSE_START") +else + COMPOSE_ID=$(jq -r '.build_id' "$COMPOSE_START") +fi + +# Wait for the compose to finish. +echo "⏱ Waiting for compose to finish: ${COMPOSE_ID}" +while true; do + sudo composer-cli -s "$WELDR_SOCK" --json compose info "${COMPOSE_ID}" | tee "$COMPOSE_INFO" > /dev/null + if rpm -q --quiet weldr-client; then + COMPOSE_STATUS=$(jq -r '.body.queue_status' "$COMPOSE_INFO") + else + COMPOSE_STATUS=$(jq -r '.queue_status' "$COMPOSE_INFO") + fi + + # Is the compose finished? + if [[ $COMPOSE_STATUS != RUNNING ]] && [[ $COMPOSE_STATUS != WAITING ]]; then + break + fi + + # Wait 30 seconds and try again. + sleep 30 +done + +sudo journalctl -u osbuild-remote-worker@localhost:8700.service +# Verify that the remote worker finished a job +sudo journalctl -u osbuild-remote-worker@localhost:8700.service | + grep -qE "Job [0-9a-fA-F-]+ finished" + +# Did the compose finish with success? +if [[ $COMPOSE_STATUS != FINISHED ]]; then + echo "Something went wrong with the compose. 😢" + exit 1 +fi diff --git a/test/cases/regression.sh b/test/cases/regression.sh index c329f2aaf..14c74ec2d 100644 --- a/test/cases/regression.sh +++ b/test/cases/regression.sh @@ -30,7 +30,7 @@ run_test_case () { echo "🏃🏻 Running test: ${TEST_NAME}" test_divider - if sudo "${1}" -test.v | tee "${LOGS_DIRECTORY}"/"${TEST_NAME}".log; then + if sudo -E "${1}" -test.v | tee "${LOGS_DIRECTORY}"/"${TEST_NAME}".log; then PASSED_TESTS+=("$TEST_NAME") else FAILED_TESTS+=("$TEST_NAME") @@ -44,6 +44,14 @@ run_test_case () { # Provision the software under test. /usr/libexec/osbuild-composer-test/provision.sh +ARCH=$(uname -m) +# Only run this on x86 and rhel8 GA; since the container is based on the ubi +# container, and we use the weldr api +if [ "$ARCH" = "x86_64" ] && [ "$ID" = rhel ] && sudo subscription-manager status; then + # Always run this one last as it force-installs an older worker + TEST_CASES+=("regression-old-worker-new-composer.sh") +fi + # Run test cases common for all distros. for TEST_CASE in "${TEST_CASES[@]}"; do run_test_case ${TESTS_PATH}/"$TEST_CASE"