diff --git a/go.mod b/go.mod index d1fbc4ae97..d6cd1b2758 100644 --- a/go.mod +++ b/go.mod @@ -34,6 +34,7 @@ require ( github.com/google/uuid v1.6.0 github.com/gophercloud/gophercloud v1.14.0 github.com/hashicorp/go-retryablehttp v0.7.7 + github.com/jackc/pgconn v1.14.3 github.com/jackc/pgtype v1.14.3 github.com/jackc/pgx/v4 v4.18.3 github.com/julienschmidt/httprouter v1.3.0 @@ -165,7 +166,6 @@ require ( github.com/hashicorp/go-version v1.7.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/chunkreader/v2 v2.0.1 // indirect - github.com/jackc/pgconn v1.14.3 // indirect github.com/jackc/pgio v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgproto3/v2 v2.3.3 // indirect diff --git a/internal/cloudapi/v2/errors.go b/internal/cloudapi/v2/errors.go index 216c6d2e75..6d8796499b 100644 --- a/internal/cloudapi/v2/errors.go +++ b/internal/cloudapi/v2/errors.go @@ -78,6 +78,8 @@ const ( ErrorTenantNotInContext ServiceErrorCode = 1020 ErrorGettingComposeList ServiceErrorCode = 1021 ErrorArtifactNotFound ServiceErrorCode = 1022 + ErrorDeletingJob ServiceErrorCode = 1023 + ErrorDeletingArtifacts ServiceErrorCode = 1024 // Errors contained within this file ErrorUnspecified ServiceErrorCode = 10000 @@ -163,6 +165,8 @@ func getServiceErrors() serviceErrors { serviceError{ErrorGettingJobType, http.StatusInternalServerError, "Unable to get job type of existing job"}, serviceError{ErrorTenantNotInContext, http.StatusInternalServerError, "Unable to retrieve tenant from request context"}, serviceError{ErrorGettingComposeList, http.StatusInternalServerError, "Unable to get list of composes"}, + serviceError{ErrorDeletingJob, http.StatusInternalServerError, "Unable to delete job"}, + serviceError{ErrorDeletingArtifacts, http.StatusInternalServerError, "Unable to delete job artifacts"}, serviceError{ErrorUnspecified, http.StatusInternalServerError, "Unspecified internal error "}, serviceError{ErrorNotHTTPError, http.StatusInternalServerError, "Error is not an instance of HTTPError"}, diff --git a/internal/cloudapi/v2/handler.go b/internal/cloudapi/v2/handler.go index 7da0bf6d08..2144800bae 100644 --- a/internal/cloudapi/v2/handler.go +++ b/internal/cloudapi/v2/handler.go @@ -307,13 +307,13 @@ func (h *apiHandlers) targetResultToUploadStatus(jobId uuid.UUID, t *target.Targ // GetComposeList returns a list of the root job UUIDs func (h *apiHandlers) GetComposeList(ctx echo.Context) error { - jobs, err := h.server.workers.AllRootJobIDs() + jobs, err := h.server.workers.AllRootJobIDs(ctx.Request().Context()) if err != nil { return HTTPErrorWithInternal(ErrorGettingComposeList, err) } // Gather up the details of each job - var stats []ComposeStatus + stats := []ComposeStatus{} for _, jid := range jobs { s, err := h.getJobIDComposeStatus(jid) if err != nil { @@ -329,6 +329,34 @@ func (h *apiHandlers) GetComposeList(ctx echo.Context) error { return ctx.JSON(http.StatusOK, stats) } +// DeleteCompose deletes a compose by UUID +func (h *apiHandlers) DeleteCompose(ctx echo.Context, jobId uuid.UUID) error { + return h.server.EnsureJobChannel(h.deleteComposeImpl)(ctx, jobId) +} + +func (h *apiHandlers) deleteComposeImpl(ctx echo.Context, jobId uuid.UUID) error { + _, err := h.server.workers.JobType(jobId) + if err != nil { + return HTTPError(ErrorComposeNotFound) + } + + err = h.server.workers.DeleteJob(ctx.Request().Context(), jobId) + if err != nil { + return HTTPErrorWithInternal(ErrorDeletingJob, err) + } + + err = h.server.workers.CleanupArtifacts() + if err != nil { + return HTTPErrorWithInternal(ErrorDeletingArtifacts, err) + } + + return ctx.JSON(http.StatusOK, ComposeDeleteStatus{ + Href: fmt.Sprintf("/api/image-builder-composer/v2/composes/delete/%v", jobId), + Id: jobId.String(), + Kind: "ComposeDeleteStatus", + }) +} + func (h *apiHandlers) GetComposeStatus(ctx echo.Context, jobId uuid.UUID) error { return h.server.EnsureJobChannel(h.getComposeStatusImpl)(ctx, jobId) } diff --git a/internal/cloudapi/v2/openapi.v2.gen.go b/internal/cloudapi/v2/openapi.v2.gen.go index 548912269b..92022b793e 100644 --- a/internal/cloudapi/v2/openapi.v2.gen.go +++ b/internal/cloudapi/v2/openapi.v2.gen.go @@ -500,6 +500,9 @@ type CloneStatus_Options struct { union json.RawMessage } +// ComposeDeleteStatus defines model for ComposeDeleteStatus. +type ComposeDeleteStatus = ObjectReference + // ComposeId defines model for ComposeId. type ComposeId struct { Href string `json:"href"` @@ -2675,6 +2678,9 @@ type ServerInterface interface { // The list of composes // (GET /composes/) GetComposeList(ctx echo.Context) error + // Delete a compose + // (DELETE /composes/{id}) + DeleteCompose(ctx echo.Context, id openapi_types.UUID) error // The status of a compose // (GET /composes/{id}) GetComposeStatus(ctx echo.Context, id openapi_types.UUID) error @@ -2761,6 +2767,24 @@ func (w *ServerInterfaceWrapper) GetComposeList(ctx echo.Context) error { return err } +// DeleteCompose converts echo context to params. +func (w *ServerInterfaceWrapper) DeleteCompose(ctx echo.Context) error { + var err error + // ------------- Path parameter "id" ------------- + var id openapi_types.UUID + + err = runtime.BindStyledParameterWithOptions("simple", "id", ctx.Param("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter id: %s", err)) + } + + ctx.Set(BearerScopes, []string{}) + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.DeleteCompose(ctx, id) + return err +} + // GetComposeStatus converts echo context to params. func (w *ServerInterfaceWrapper) GetComposeStatus(ctx echo.Context) error { var err error @@ -2999,6 +3023,7 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.GET(baseURL+"/clones/:id", wrapper.GetCloneStatus) router.POST(baseURL+"/compose", wrapper.PostCompose) router.GET(baseURL+"/composes/", wrapper.GetComposeList) + router.DELETE(baseURL+"/composes/:id", wrapper.DeleteCompose) router.GET(baseURL+"/composes/:id", wrapper.GetComposeStatus) router.POST(baseURL+"/composes/:id/clone", wrapper.PostCloneCompose) router.GET(baseURL+"/composes/:id/download", wrapper.GetComposeDownload) @@ -3018,217 +3043,218 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "/vr/AwAA///qR5lurvYAAA==", + "H4sIAAAAAAAC/+x9eXPbOLL4V0HpN1WZvOiyDh+p2npPli/5tuUj9irlhUhIgkUCDABKluflu/8KBylS", + "AnXEyezMPv+xO7GIo9HobnQ3uht/5BzqB5QgInju8x+5ADLoI4GY+auP5H9dxB2GA4EpyX3OXcI+Api4", + "6CWXz6EX6AceSjUfQS9Euc+5jdz37/kcln2+hYhNcvkcgb78olrmc9wZIB/KLmISyN+5YJj0VTeOXy1z", + "n4d+FzFAewAL5HOACUDQGQAzYBKaaIAYmnI5Ex7VdhE836OPaujGfXu/WWl6lKCmRB9XE0HXxRJM6F0y", + "GiAmsASkBz2O8rkg8dMfOYb6aj1zE+VzfAAZehpjMXiCjkNDszFmZbnP/8xtVKq1+ubW9k55o5L7ms8p", + "TFjHMj9AxuBErZ2hbyFmyJXDGBi+xs1o9xk5QvbT67sNPArdC4V6/sMLjAHPobAwRlwUNnL5P3PZ+Rwn", + "MOADKp70bidh8ieF6Os8VHaE2WFdhsa2gCLUXJJCFPRxGiLo40LZ2a6Wt3aqW1v1+k7drXVtGFsTxTOL", + "kfPml9BAu/oWEgjCrocdzcI9GHoibpdm6VYPcCSAoEB9Br+LAQKmC1DM+zEPIPAo6ecB7fZC7kCBXHB7", + "fdohmAOGRMgIcougJThALwFmUA4NfNwfCNBFgFNKEANiAAnoUQaoGCAGQrW2DhGQ9ZHgxQ7pkCksgoVI", + "TssHlAnE5GwgMRmAxO0QnJ4QcyBh59BHAHI1lfw7OR2Yzjbdoi6lHoLk7Zu62nZmkWLIPLsoTk4hG1nH", + "fw0Zegu5DCYBYk+jpz4iSOMzRTq5O7n8NOU0B5RypHB8dwZavjyXjuQwd2A6Sh64uNdDDBEBegiKkCEO", + "KAEKYADl/0YQe7DroQ5xUYCIi0lftpDjzg2nNw6R0JfYUEDdVRIYmfInlvDEImfmGJMkQntqCk0YyAWq", + "g6Ri4IdcEW5I8LdQnrWqYR+PEAEMcRoyB4E+o2FQVDQrJ5HUR30sJGv0GPVVF7lziAtJyAwSl/qAEgS6", + "kCNXrhCC29vWHsC8Q8wKkWsWmJSQCjCbCPKok9ip5AJPzZdokQGjIywXGYH/pMDPg/EAMb2FahbJb6Hn", + "qsVHeIFEdutjLhBT8B3RsWRRD3MBoOeBCAz+uUMGQgT8c6nkUocXfewwymlPFB3qlxAphLzkeLgE5d6X", + "jOz+7xFG43+onwqOhwseFIiL/wdfI+H+JCd6iif5oFAuIY5+kqgnVAAeIAf3MHLzAAv5o4vc0EltSAYe", + "ZpEu+R2Fkj/skj/ZdzF1pcllBXTPgnJDQweSazPMoZrRdn6H3RiEJ+zOA9XakyAlm/0AMDVUd7e7FacA", + "u5VaoVbbqBZ2yk69sLlRqZY30XZ5B1Vs0AlEIBEL4JJA6EarQWVIsIeJq/Zac6iWKZeUCeitQosRHQo8", + "QgUXM+QIyialXkhc6CMioMfnvhYGdFwQtCCnLmiQZ5BUd7ZQr97dLGw41V6h5sJyAW5WKoVyt7xZrlR3", + "3C13a+lRMsXY/N7OUeCSAyHrwElLyFVEzgyQiQFsIOx6IQoYJmLNo8ihREBMjBE0c+ZE3zR1cEkFyO9K", + "8U2k2jBAkiigByATPehIrTJWVH9jqJf7nPt/panNVTJWRSke16bAOiEX1MevMD5YFw0VL7uZ7vZ95vy0", + "aM4u5oLR+VXfSJVMfsPdULGuoCDkKFZxHG0FFUGrBzzUEwD5gZioTwPKRYfogcEYe57iJD7P2z3kUgYL", + "1R0bAyMiD2j3yaduaOy7ldB6ptrbcKool9usW2co2V5/lwvtyhOYC+h5yF11O80oWlxaZk+sIz19gwDo", + "YaM9BnoUnpd6p6QOV/3chc5wDJnLFd6hgF3sYTFR+FwHOhtgETfO7UAESybG3oorGzQjxLhVv2gAjvwR", + "YsC0AEQ5BlIEtVXcKm6VlwqR5eKjOcd+6wgT6CAmlvN/oymbpabSHKnlPrZhfm/6USLfYQiKWF2MxRBe", + "Rw5FQ05s2+FiPlw+AB/Ktj2XLmt6sHehWmIrJxzIn3/WsuK9lKPalqaAmHCBfIsyKxVN2gPTNsCXimFA", + "MREJEH8IGDOpFSSbfNpXkhActC7bwKcuspqRPczQGHreGpCYDpFkzMbCVDCut+pMWShPCLuZ1KSkh/vK", + "YouOEtXQZm31CY6OtUVQtKJ2yhOoJJXitScXjbCzxFRLdgC6Qx44IZPGpTcBlHgTebT1Qi8+GZHbRwWO", + "/cBTlkEhko4MyCXMHIElF41K3IXWBUYdl64wbvg9nxsiRtBSMjjRrYxF56Fl7U91q+/5HA0Q4Q4MVia0", + "iwCRdrNxqY8UJtRmYNJ/UrScsvhhKGjBG/lzdn8becgRYCB1cK2YDI2uHukX8cjILYIP0UAf9HepuDA4", + "BiHxEOcdIpTCDxlSxjFlwKcMpTgcS1sFOwPgQI6kvh+Pc3p3VgQf1NjQG8MJ75CQIy5/zwMk7fXxACnB", + "ZaYgFKAXwWBy/CL4wOD4A1A9JWQx+LxDbINkwJn2TTA4zuVzGn8xKr9azcmAcpx1xlwnvkqmHzMskPxH", + "CQmnNAn9oupfdEtpCW28GedUIIliKOQ3HiFBKBUQQAG6IfZcILCPiqsrMDE5xdBZzys24P6yoa6P2mdz", + "py4Llve7nO/GEZMyYSn47aid7MMHQzTJFrecD8AQTfiqqGm3j06QFRsSx6+ULOXum6jd93wu5Frg2GGT", + "X99y/t1ym73zfZEups5vizqoTSR1RC/TGTSdpbU0FwpoN/Yk5JH8V6NDDgIPypHRi7BJ6ozzU51/syNB", + "0Meu5GVoHDTmfJueCYyqqwlK0EUv9/mf85p5/AsmAvWVDvxS6NPC9NfNWu77V2102K7zEPMxl0o0B3rQ", + "+PBSUGICqCOgOtJ8KFLAlTdrNRsKAigGNkNBDEBsJHvpdSpx4k/M73Mj2gnxYkz0bWAap2GEU9nrF6J0", + "xpJQq/66jHqnWmaaBH1MoivLRcwTNVP7GYn+tP+kNIJsqdmT6JyP514C/FSpXGgAzerxupsLHKPOaXk5", + "d19EjZlklzXqM/hdWsWUCcAg6SP+UTmHA0YFdainRJHUSJK7/c9cpfJZOEEun9sum39gHwbqn+tdI64o", + "3aMFJ6W8lKerey2iER5Vr/UEZKxgzRGYlHFcMAR963KfOSVPAmKPql+WgBhNc9y+OL+JO0nWpx52JlZX", + "62UoJHfGbnKg24LWXiSo5WEMpIzmecCloIACQDLRijdxpHoUXwQAQTtE0m1/IHis+UlNx4cCO9DzJpLi", + "CFIeeCN25Eo8LIeKJjczO5Rw6hkdxEi6z7kwVO7OefnGqJQ2ZpXzlLMuFhMYnJUp05kWMmdCEZrb+C7k", + "KGRemv6m4iJyUzsuKTLkDqB2UTv68Cu5mIsSGyBvu7RdetnefNqsleSIlJcoL6WwxbDV+T7DR8aXl8Bc", + "ynL1UKYPqh/0nQFyhvau/aCvFKXkKpcCk7GDPhLQw2Rox5SPGaOMF7XLMmBUbkeRsn4p6vffUin+R+TS", + "rHTCcrmyCZkz+IfG4Apo05N4mIt5IGIY5Oeig4igXM3/3wx5CHL0j+2CZvXEzFD+/2ZN/6Lg24UcXbRX", + "gUW5K58GVPTwi90TxeWmcqBaQobFRJ7HAiX0CXV9HlFp1gV4tv+RYSqHTXyMTmdjwzwtJg/OvRFiuDex", + "fZ69WFjCbbdGG1nDD7jM9d63SUytM2I38rdLOYigG2k8ka2ct2Aky7/d0PemtAemwCd8OtB19a201JwE", + "Tar0UxJUzTdW4fUBtfl1bswEHziQDUB8uWUb0modSatIB5hI4yil3XE+KCC3Uq9v7IBGo9FoVs9fYXPD", + "e9xrbZzf7Nflb61zdniyz84e8Kezs9txeASvG8f+9SltvV73Kt/2Ku5e/bW8e/NS2nyxwTR/ZyWXs2FX", + "hTkfU2a7eTRX46YB4AIydZKJAfht87c8+K3+W17qsb9Vur/FXocuAlxQef5B3iGQAEQcNgnkGReNVAQX", + "YoDYGCecFV0EhLKJXK0iT02YDon7JXkyGZqFtNI3e8XfxwSoj4Y8rXq7jawl+/wIVa/suResx9thd0S9", + "UG/UjKqdUpzTsJ3F32IziEcj2fFj911KGicJ32E8SN74klzUw8R4NuNIgt+lDfExij6RhgrIntqGjpRV", + "kImbuyzErG2DBJCJJz2JDQOxJ0u5O8HhbWtPofXw8ibh5SqCA8rA3kU78Vtenxg9jKTlAEl0bSiJRsVo", + "DRD4vQIG6AW4uI/Fx5m51F2kispAIq/v9yUEdj1RDhhHxci2UyQCQwQBnOgALjl2BqNEW7y6pj9DqTav", + "jcFt5Nbryh4WP94MMSRgsVGB9bppzfg+5D/FV1sJc6tQKOzuH7bOQXP/+qZ10Go2bvYLhUKnQ85arWZ5", + "r9lsdHG/MW7tNvqt21axWOx0SKFQ2D/fm+nyhuDWKXDW1Scid3epq86XqTdg0X5ZIn+VayX5yzXiASUm", + "JtjzVhj1QkF2jVSYmoOUB2ImVMJNHzcblSqq1Te3Cmh7p1vYqLjVAqzVNwu1yuZmvV6rlcvl8nJDZhWt", + "J17dNIrjxxe1qH0qVkRPq/G5hzwkUFYQyUANaaGPDM1+iIm7PMxRYUs1zesZrGSk4Wu5/0E7rZd0aqyO", + "1RalWltWErHuigEwauZo/5fwtx5y8Rpon//UjVHBRuq6wmpjGhDmXIWI9aCD/vhuE+5D+oyXXs7RZ6zW", + "Yo9+MgAtRMUZJLiHuPip+PCTg74dGbPuyXj0xStDAka++5+1MCotZ/TkUN/Hwhow+PsAcqmlac1O7oAA", + "pnn+ByJntEWGieOFKvb4fP/uurFm9EyMCNs1mI4CXpEDr01ri5sxgfjr6ZgLdQZChc4HmW7tTDRbPteN", + "4/S+fp/VMrrJGL6VLpXWD5izxMklYt3S4pgNkFfYzmXGe6+IaxWsHmN6pvPqQnN2mB+VL3Psl0JAYtvb", + "uxdnP1eqRsuctx3kXMClTuirOzhpNqhcL33Vr7ku9t7q+MMk06w04DSu3txX709nCHmojIOBMrQF8BDk", + "AogxVQPxvLqGjwbR19iIjDCjRI6vbiYSLToEOiKEHjB+1TimRM27KrerDZfTZ5sKP35i/gwNz3Zm8njc", + "5UuLj/9kV7QmR2QpEZohVoRH8sV0oNX6pBB5pxIP5wwzPVB6gavsyz5jlFludZCAWPn2Z3XelHsUcqvf", + "0ab2msZzAOj1JExRHjoO4nItPYi9kKFcPmfyZ+SCEi65uOGc1JzGPc+tbEHqzFz4cRSUHSdaZOas6MB1", + "W5yLIePpVVE0aBTinb5EVhdRbFI0P6k7EzXrZwH7Vt+ox5+mHuj5SANGPXBz2gaqDe5hJ7onjSdVSWnL", + "fNdmgXaDxSzpLYlaC7Yl3g/jaXTSwfIz0W6UK6FpRRXsW0Q47K85g07lsVpDy3CTkIVr+Phx35z9s7cj", + "8vdI4keq7VwC2HQxkf/P0Jjd3WTy9WZu26/2zu2ZZTO4+RbCSRHTkj8xaU4lsx+fF2BtNhMwHy3ZSm1K", + "rVrhYvIvci+p7o6e+kHffn+kP0cXTfY2b7raNBcd73eXv/zu8qddO3LuPb31UvHfmV2QznT6WYlKT4sj", + "SvdV/GuyTSrZJREfgglIW3JFcDNAHHVIqncyq0ge1i4KOPVGyGSOCobRCMXjF0Ejxq83yav4Xz79PL1l", + "gyOTfIr9gLJEEMm/5kJf/zW9wuwQI7ynQnc1vM5KS2v+RSoZ5M9N6Pj5KVg/kCKyYvjUKjkeKw+1PENj", + "4Qity/Y6KRlR7Nccr2Zd6P+l8jKSSZzv6Rp/23SNdJbG1H2ZuOYLKBd9pq8XV1dZ3lM+/hIpH+Yy+99y", + "UCu2W/m07pCINS/aAAuOvJ6qEDPRgxGqCmXEJTxm/HEqgIKyDoFkYuqwSEQnPfcqfNhBnH9UMEcTP3Ek", + "ougDM+bccjAHuE8oi3KZVxK3/wEZK4lyAEv7Jdu+IQdl9cN/9ZySvfODSy/sY6KPs3nzdIE1Zx3PEPKq", + "VyMzhaCYM8ACOSJkM6FlsdU1x8Y/djnyI5cbs4LizaQ+YxtNVzIDXz6NmK8pPE/jLGZCU/7MG7cm9X1D", + "3AsjUiKYbDbgVEnPTm2KLYwfyW9ChIcMPQWQRZX+Fhfl2lftQZS3B3RHkDBAAHrBSS9RMhB7hQSo6Wp0", + "FlSc/GSSobD7l8mCmoK6MBVqq17/sVSoZPTrXD6Ui9kPpkPNYDhOhTKZUb8KwavmRO0Z0/NnBCDi2HWy", + "IgObLovi7WZ8z5MgNisScYa6alhCA+sHSoLRFSL0EoBn4CeWgnE4jvU4yf49I5N3onWvOCpfF2f5LAXt", + "Z6VU6039ubm4M1G2UEmCpGznhh8wS6mcqVjLKXqswyVPjsRwyVkyhosv137WzadjpMx8iZ3kfZ3sARMl", + "syzn7moXd46uBGGazwxsv1xUS/43BHppVL/luvpg72LdHMTW3oXxAABKuhSyZdmILn7ye/0njW4V4Pzk", + "Q+dJHrAZ+4pD8hSE3achmjwNIB8sb4UJR45R9xa3lNJ5Gmw77+KGJJQnf6iAlTo9Yk+Z5RzniF+5qNZD", + "aFuXQYmrkACOhCoMl6lBL9M3dPqQipSfGTuXX0n9/htkhv9CLWzJHfF7Vvp7VrqNYRYkoz/ZK3DLX5Nr", + "M9yKCehOBOLJJVU2alu17epmbTsNaWhA/ckZ7E+ZKezTlUo9zp1fbo8vyGFJrHLqBDb6HnoRktReenLh", + "ox4UVnebB7vIs0vuNyb9W2j8PR8n7XxO+peVeE5sn/776xqUZicv25XNe5WENaskfF+A2nZi1B/CagSW", + "CubSJdQokwaD/KdF++MJxcValicx3nSUBD4F8ggS6+EupS4tm1U3Tk/aE3LjiAjWTJ3KxPtjVLdiDaTv", + "YuICGKcZEyTGlA2BjkvTScbglRKljTEkoXIEEAz2ethRAXwdIgaUo7hHShJwJAQm/VjzkiPZ9Db7zWUy", + "I1P2zAM8V7E0mlaJJhgE3kSVmkiWpZ9OmhFfuIBFo+EjdUa5uTPjljthuVx1dB/1b/TPkv7Nh3yof/n6", + "v/qXs0ZT//C/OOBIfNa/qn/r35dHQdlo4bB5+ZZ4wW7oDJHIvqmFRCuxUsVr3zTO9xrXe6AtKIN9BBwP", + "cg521RDF2brk5o+CmWHNGuxxgu5MMGkc7SGFpnrqwQVN6gehQGCf9DGJYrY75CYuEq0GminbPsZiYMyM", + "w+YlMKFWUfov5ur4TN9v6bhz/VLANPJkenTGF25RPfcO+WBi31kBBrigtzwMsat3/EOkPJvppCYqUlCv", + "U+99+jrBPCrlEvX3RAXteE3RMZ8MpUngV3K9wad68SFGJZR/Y1eNHiVJF0EbIRBHB3o0dIt9SvsmBpdr", + "0lFVt0tx1XZTKD9dpV1pFqEncMFAHudhOx7liIvILjD8R343xdQj8tSEGXf7KNHsSNlF0grNLJJRuMYb", + "KHYxYvCi1g2i5koZk6OkKdlGvoo8ix2iEh4MkSism5iwRKGe2JYx0xh97k5BoO0vDiBDnzsEgAL4IO2b", + "z38gH2IPu98/fAYNAtRfALouQ5xri5ahgCGurOh4LkcOAWaWpdVRg708+AA97KD/ScRdfyiamc352ND9", + "1oRBT22GyJrbnxTUPXIBBsH/wCDgARXFvukU9UmCpAzodbFh1h+9DSDhmkGBK00CKw5c6kNMPv+h/ysn", + "VOwJ2iEWCOhfwe8Bwz5kk4/zk3uenjCqAmJOWihM31mMTFnvg1SpPszAZOe6xaQZvaeghYMqiAHJpEMi", + "/HZmdFdFcHNUkYuV0YgeVt28nHGXfJ5Hs/LuKwQnf/wlrzDF5+7Pq5+vzmY5/tNsmjHkDiIuJKLQZRC7", + "hWq5Wt+oLrXBE8Pll5XjP4w8UGsoD4ur4hixpH1UU9/e7zTQw3+0VsZZ/iTLzIA/XkK8lQhzW0ODjrot", + "sQVVUoqr7YVVguj2o/Y6HJGLLqVi1c4HcQerkjg3x9pPLpgQjGX3HKrdIlwfJFe2BgjWdIpLRkeY63g1", + "cHt9ulJWhBW6ZKbmr48M0awYOUCWJszdTAId26Nzn5eGB7ZvZKtfEReSfPjPeG3Kc5cPxgWpFpmPXY9F", + "cD9AJHrAq5x8wkV2wPJg9THBfuh3iK6+44LuJNFu6kuKcVyr7NR2NrcqO5tZPkytrj/RYKVc47QlNe1u", + "3gWz69bKU6bySXU/ZasoxTXw0OzLYiaFVSA/KjHUIRBwFEAmhaNp7SJpcWllVx2wWHBAxySaogjOzPgd", + "Mn1Ay8whrYgxktYxn4IRfYvqHUl5OlSuAIY6hIeBPvHXCJXTuLpR4y49SFNckmKAGSr9GnGjSqOdjx3C", + "AfIwWWo1mmWapCkQdTPW3cDYWXGspB6lKw0+YxOqbOT4sYyi9bCOYAlCFr0yafHp6o9xeILppMMZ/6XA", + "Y5SKfyVghDwOAdCOjfn0ZTdEUuZNmUM1MYOqX6YDqrjISIHUhkJ2qjPYC+McS6LeYwO01yGc+kk2VP5m", + "aUb6UEWLxmQWzZkitA4xSCgmnLvxyiNysPrmeZf6K6SLRxeGH2R7RVcfjOlTnIsnWFj1Iu6/gNXNylIA", + "FEEzHbnevtz7IoXalLMSa+eB+7JCkaiu1pJjkPIz5G8hwSn7ZGilKAqlWDlROo4IWDtR3KRYx1J0tQHS", + "lYZmOq9xjs2Os1A+RYneafStlVOd1ySt/6mB1v+OiuKaxOs5Gk+c8Ymp4FhOA8e8MIAFNgix+SvxTw6D", + "+M9XDYx+KA/BYCv1Jf1Hop/KqIiLo5i/ohQy88M0WSKf66u7h74TD9CXKlNs0Oj4pGQHTEVBqnuw66WH", + "lh/iifUf6Y+zozA4ns5DhTUPJJfPeXiUhkApFdAr6PB76kioRzyQ4mv6rwIdwVw+N+ZexhZJJj4x9Wtn", + "bifnsp9+wIvbSiakpMfnoUsLhKoykO56KaghgUIg4q4eJX0Sp7isowIHkgcs0R3qdw4g65uiHuZgkQSh", + "kp8Z0Dk1qj6PVKGkMEv54gjlvvhHjzIHrV7dMWktmgniGpjTofWXgou6YX+1LPQTU7DlB/Lxp9Me6NTd", + "pkdDt7ALeYYvVyXbpntWypVyeae8VSxb/ZMqsMieVjykz9iSUyx/HoTdVbKxIR/OeiVqFZv9nngUbApH", + "dfnruAb86VT5qGRmNOIUK18z9iYqKjbriJHMa0pwEFU0ai6ejWgxpFtmDZ91piq5vwp2bDQVxfilh8yo", + "g5ePX3G3ZDsby2z+i6ACerZPM1gwFfWi59/1q+u6cz4z5C+vXpP11ruDWjRG5lvcJgzsKQoUWvKEdqp5", + "JtxoTeeP7rTE9TNEExXFOC+Z2sjo4FET4MEJDdMRUqG1FosHST+0501Etw464XDuqb+8CQdishVBoIsc", + "6iMOjJc5r959Rd9CZQxI3R0yBDhyKHGhKW+RcOci8nTbLt7eHBS233qPfUr72IFeVr3bdeJ+4gtCT4+Z", + "LMz7VwkHWlrK2AJ75on3ppgYk/X900rLRLVT1LDT8IfZizVCXfRsJe7pyxcz/KJ+zx6xUlm18LKZwYaN", + "i2brjeIrHuGXvN5uHHqf/7C8hIuIsLpGG+pNfOXuUFEUKq4LTx0LoIeEM8CkH7kBiqAl9WlkLrz+FTLv", + "X8qARSJyKOU7RPtPUiUdlLfLZFsp2zcj9EKHx1qCdXW0GsIqARSaUo3gd7PJn0G5slmudSsu3EQ79VrX", + "rda6293tCtyu1lEdbm25le5mudeDH/M6gLPLIHEGBQ8PEWBx3ajpeGyAvGlRGmnifJyhqvkWdh2tN5+g", + "t0I3k0S6OLh4DwnEfOVJGQ+QQY2+VU690u1DAvuIgd8dSFwPBZh8BNhFRGAxAepNB01fKkgGKut17rEH", + "0KSEhz5iwJHEpWpbzRbugBw4HpYnRLrNAJEOiWkppgOpy0eElfGWxOoR8LP5HH+lKsPZD/q8v9jzN3yx", + "x74NVps/Qw1dsphscPLTURdBtgAqrsowoLWdBD/Sz8an0VPRP1utMG56SXNGpy2CA+wh0Pdot2vip2JH", + "a75DUL8IPqhqGnxQ+K8PM9Jd+KH12ZDM56wvzIV1/Jz1AriiJ9a7HiRDXVlUV2xLVEGIhkm9tw7usec6", + "6t1wpX5HyzGrqRU3NopzS6kWq/DH77/Nfu3FOW+Wq00rKSiDV2A/K4du8Vs6KKAZ43rYQSZlfFW9N+Uq", + "sbz54EsLx/rNfv6kyGAlxXLePaGz4heh/EeCLex8knoQfz5tBRKozLiCoNTjbyaV9Qt6Z6Xjz8ku3Pfd", + "+nKkm3b21EP7ZKvTtSoiyEN/gQiIeD5qCkIuBVDj9PDi81GjfaSundIvHg1gpb75uV6pb21vu6jqurVa", + "bWfLqWy5tY2tSn1zu7q52a2Uq9tluNnd3Cpv9cpwY2erXNuqopor/7EJaz1rxlomJ/0Yt+C+vvJbIP/f", + "wjDmEmsZ3+TjTVZbGmWDr/wKyGw+0bKnLZJv7ixrq5uZolxWRgm9QNtrb4rKhhzZ8+F2zRdldk0zqs1d", + "8FSpX1gmZLIw/VweZ8oRaq7QBUMossrUm2MZrucnEzGpFKSF/uTZ8iLRaldBaJaFrUp/rmRmxy1t06ny", + "PRkVZ1zSewpUTRq+QmWdM0jiGjbcDDlTn+fJWG+rjRaN8T0L7NkEp6wgrqXlixZNdLlsHk07T9Fjj4vv", + "iuNrNftkqxFsyhlb7JBGVA1eleXSutoHU832Qx58mBY4VX+ZwqofwHQdKta6Q7poalwp1U4V+NIj+lpN", + "SwfOUubqeOyAIQe5yvGAdUUzHXIDucq8kQZ1l46sqTGJsrt/XrXdtavrrpYw3g/6pmC2idw3uzGVRLHL", + "IMNLMK28OxNlenkIhmgyTXLEfTKN51FGaNrJkTqTC4X4HazLw0twebt72mqCk/0HsHt60TxRnzukQ/yr", + "1vnuYcNpO3R3v7F32tt+OBqi1+NN6HpnD+MteHjY8o6hJ7aPnysvpd3KyadBq9cKXw5FcPe8hTrk9Lq/", + "d7u1+Qxv6sHdXt0/ODuuBkNE0HXJufG/fbsank+u+OBLhV59Ge+/3ra7G83zs2avedgfftm+qnTI6+OQ", + "tZwmOyhfVcbspOvB0B3cfsJ3kDT2uL+x/bD/jXfrjdvqlitu2Vn16sG97+9cf/qCL3t329cdcrL7fFOu", + "ju52L9yzNn+o7pzCJtlsBRsXo2C7tU9LLbR/97DxzW9eXDbgSbl7fFQNe/1aM0RD/umm3SHjq/sb1Dx9", + "CR9PNy/OvtCLy5Px6Oyq99Ltb3zZ2x6Fj+UT8Vxyzo8qLzAsv/i8Ee4cHQdoOLq4vH7xOmTyTTxPHnuM", + "3mF0MAnGj/3R1VgQcrZd6rf3w9Lx3Q17KNcr/v7tzVbT6W7Vhs7Rwc1B72zokeFhqUPKvdta4xrWy7Wj", + "6stzeSi6qDo6cS6/0MuL8GT3jh+1R+Xy7eFDY3KJwsmn7S3ntvSwPzjbGlbbdyfPHbKJWo/9CT67KI+9", + "jYfDvesTJ/TGQ77T+BR6w/4GvenWePXVfxxdlrcO6c3Lfa3yDE/q9+1P54NHhDpke7P8hd4Nus7GSdD+", + "9Nx7pM+c7YvH7cvu7eOnh9HB9nXA3PsGez7qHg8rx8H1SePlZvDCrxp8d3C40SHl0/Clcg/Pdsv9Sqt+", + "6Zy5xyXn2zMtbzsOe979EuKXe4brONw5+xJsf7sp9dqv5z53W32yXfr2eNIhePsq9Hrh1lb4bXBfGotK", + "VxAs+tf82/Pg5Sx8fritPXZrg6E42B6c3Ja+fNmqVb4NTusn48Z146qx2yFi7+Dw8f565Pj7/ZO9s42T", + "dmP70b8bdqvHg9Obs43TL7sTeL8xcIjXiH53jo5H0L97dpv1UYc4vvMJXx1f7O6e7TYbjdoB3t9HR5s+", + "GxwcbYV3/Or07KxSfqg7jwPy8rB90PAVDzUPx9sHzfGw1SG749bhwRU9bjZ4c3f3odkY7zeP+vvNg1qj", + "0ewPr6a9P50/NEpbuw9B35u0G48PR4PnycmgQ0qfepuvl727UfeoUt7/Vh22ti4Ods/L5PTLp93bDT8c", + "tT99uwnb1ftTtlv1q4ehJ4KT6/3jk1Ph1/f3OmSDHb5+adCbjUmw89DaPm3suWfN5sXkufHM6f3t9tbD", + "bdj8VOqSZ3aDriun1xfN3uSyubV5v7Ndxxd3HeLX25+6/GpvvNWsnDLPbZzVzvZCOnncaGNxCB9rJ1en", + "d+LTzT7cqGH+0D5sPr/SrcuH7bvq8cWwXu6Q/rf7/nblvNT1K/uv7a2b7er9/l53wxs911re6KXf+naC", + "+hsbr18eXnz20H48Pm72Rq+9T955ezN86R91yPNL6bg88R4rp7h7yDYPG43Jxc7tPWs8tsfts/K+83yz", + "Pd5vkpdhey+cfPPvx3ej890v4X7rbvsCVR865AzfbvSOz7e5u7UX8IOX+tmnLy45I1ftT0fs+ebyZK/q", + "3zOv4ZL9m4H7cLf9/DgM7gd7E14t7eygiw4ZDMvslEzKz+fjIQx7JXy7feFsfhmdDZ9Pr8+O+/XbnbuT", + "yXF4fy9ex1/I89l5/f76YPfbSY0/Uv/srEN6ontztPGpPule35ca1dFuF75c31fE1u3r+bPziobtx30M", + "T893TktHznGzdb1xdbC9uV3Zcxve/sGO2yHDSv8KP7SvGhAel4+PG69Ho+vh9fHpaf+k8nD1gI/O7yYV", + "UT2eHPQ4g3593G7eX/QGl6g1Od29eTzukBELzr3LLurxm5361k2vsnveCvuvj6xZv3vZa58MH/vXg427", + "w1G7dUWak9fh1WRz/7by7TLA9/UdKaMGl60vj+yEOifVk9P2Tgm/Hl/dXHvi+azxjw75x2XvZku9pqjf", + "U1xw9GRU5aUMPXHu2Q/p98ryy1/FXnjl8rNeyU4WKbV6NuV4kSNBVzJVF1cJrQhyqdBwoEyuRAKeKtvQ", + "Ib9HgZ8frcVS51Kwoqc86JoFgX/uXVX6Ogpk3EatWFCo3T460YrhGna1VZVsuG4ckxHda5j3r2EoBpTh", + "V+Qqe2a+As1Kz1k32vdYDC+OarfbW7V9l+/ekonoVrvj0XW/f+Rded2HL94W2SiPdjLeErEWsrnVj4DH", + "5o9OK+V8oBbSoyztCXR9vPyVYjWTftXbZh23keRH42bjf17CzI8URc0uM9qQ5K2sNMODBPo6aoar9Unc", + "FUFbe+A5+C8wjl3zKiRfNc+DbihUIqRkXuPM4jP5E8sZ7BdXbo2RsLxw6+zerl++VV8kqKfONV4x0UJa", + "Mr96+2nv/GDNOq7R5cSbCriuXJ/kJ9QZAd1J4o10y8M50VMDrv0AJS3dZeOnFCBZCg3pqdBsvjYwPuTD", + "VWGRbZdCokuyrIsV69GQdJfNOwpXKOKtR0j6wvRZ6CAm3DU6y+aLvGkZbsJ5nmPUDeO84IXvgcyUzf5B", + "j+PcMNnQzy50/rolFPTJPJYFZ24FFx/xs7tgH1pT+tMk9JPeWYt+rJauq+qvAULqMnNmekfgkS7kaTSL", + "VJo3Rw5DoiA/JdRVFchPmZXQu5CjJ6vzb973t4IKHEVzpIbLKkEYNX7ScT5PAaMvk0WX4aoUiKkvphqb", + "7ET9OEIi6yVZN1JQ0DITzYSG2JdAWR+ShK85GQ1fK1crNXuIlbNca40X0vNgPyozwwaOLnykgU9AHlWG", + "gR6n5mkJI2N5vKIZvTsL0+kSoMmHGKfEVpTiJrHdq8ToIz/woMjKjZzfuHiVuoYDiAbQG6gvNrMRonul", + "l54Nk4r8eTNMGUS1HJIZbSFFVflZPk7tUIIpEwxl0zJuEq8nrBFRHnVbElNORKChWhD/TUQAokYp+69c", + "JJSJQQH6iGEHFgNKvSIRgbS/c/ncxqLPaxmMyRcksgN6olb5SCNQWsLtTTNlsdy2S/tQ7jZZLTtn/sKV", + "TFa4Lm7ct/ebldnM76V92tX1uszV6Vo6x2vI0HpdMl4QXdbNksCxrMtcyPSyDln34t+/2uVy5BPRzz/P", + "p8WrelSYAz6goecChlR8Ylc9xXPRU7bZ/CbpKgMq20GotGbL3heBGtdHkJhQaOh5wNIQaMrjHQIZ0seC", + "9nnMzQvjtuYMGWGqgsL0naEEuENY6CH9MA9DPcpQHowRGMBRXAFNUTNQWddydV0E4BhG9YexAJiTD6JD", + "Aso57uoofB+/qEhcHwpnoC8vzX4AQfvKUyOlZcw7WXfrieoJq4VgJNEVZ+CuzFIr9pitwLMGQ63Yw/7q", + "7Mq8sWL7jAiHNbg26vF15SztZL84TXuVgiSm6oOuSJL1eLYJ9InI5usMga2ZVs1CQrJyp1NFKObodu0F", + "vbFeiD3eaWbIr5lHV3YOeJFX4+TrKNU7mS9NHVw0IkbXvpMIDL2gaCrGmLfo7Cg0zsV16j7Fjx1a7Hj1", + "cWOVJ33nbKaVvKnn7PBkn5094E9nZ7fj8AheN47961Paer3uVb7tVdy9+mt59+altPmyKH8qmW6H2Ibd", + "g2gstTllphUFAOsGgAvIVNC8GIDfNn/Lg9/qv6lslN8q3d/i9+m6CMjtUdkUHQIJQMRhk0Aoj5keqQgu", + "pFQe48Szdl0EhHlQW1Uqn1ax75C4X9qYyrYxVw34TAa6zfGVyVN70nlqqzsu0/l+FopYP8PObuvoGZLv", + "Fv1uT2joI4KYQi3uAepjIZD7MTN/6b1290wsaCSo7C8ZztDaLNXYyC5BAJbC9oq/VH5RSLDg6UxAcIh3", + "rfSunvHAYtKW1KOpdRdBpqVeV/3rIGKc4/sbaf6pltJo1+3iUQdCBLnv35W3pEdt6b26PKWg5kJOlYnV", + "uQG6fA4v5lJx7pp+c40AOgMEKiq1XzkG4ovb8XhchOqzui01fXnptNXcP2/vFyrFcnEgfE+bX0Ih46K9", + "q6Y3hVwYUHVYAQxwIvD2c64SvToqP3zOVYvl4kZOv8Sg0FRyPEoQL/2B3e9K7tsqBR8aEtWnv6oZDMyR", + "LSlKZQghEb1zr+8BVJafqghk1Hj92FviApEyRdLTAkeq2J8keaUsIFdX1Ilf0mm5GpSmhLgdKSIBZNBH", + "QhnN/5wT4ntxmbIIeEFBX9UqxkRJUTGI4pU/63SwKT1r546WSOnzZKNSRbX65lYBbe90CxsVt1qAtfpm", + "oVbZ3KzXa7VyuVxenhQlDSNmLmHUZlTK5UTSp6m74ZnAutKzeYhoCtBCxTaBJUXOacwkcSJJpPYTpzbl", + "hOYnbRFtPhnKANjVU2/8+qkboXpkZIjUHTXWgOjZq79+9lsyvWaWFBggJmkDxLStIan9GZAMCR2TmS2o", + "/xm7f0vQS6BTC1WJKkAd9Ra0mxLhiosj4f3Pr5JH4hQfdQonhZASXjE9qXFK0R/qrQZuy0bWRUohIGgc", + "dc2DgMql4ygHkpuC6Oqaa4QYjIS7kvfGWYGgM5i+cpZwXfB5wXVJuTCy2ggZxMUudSc/j+P16NGV/ff0", + "4SyF2fc5ebPxs2dvubatNx9VVT6lSCP33yZ0WISfd8nzLnlWljxGaNgkDS8tVZyipziiHsoJOIlKK8b6", + "U94IFpXhqsx5qc7DkfxCGeip0Au7TqQHPtVvkf06pSIxjQXPs8t857F3HlvzdJ8noRSnRWaKiyTD2OKT", + "5O9T40Od1tIyk2aAkKThogARFxEBnmnXck7rEaYn9Qr2RTSXektfwfWfb13oJWtkZVsZEWY0Wt7NjXeB", + "9LcSSLPSRGWrvclBsoZPJELZEmdIso7veuLq/5pDJIWpBcLqXUq9S6m/tVPEaqNIzUk7e5OeEYuPQjZZ", + "S/1JCKu/kBT5Bf6VBGbUwH+2hyUxfxy7byEp9UICGk+fcuqqhwT0UzoZfheBXkRJP8aagmcWtStLr9rP", + "msDGm99TlrlES+oRwwUM4NIxkRZ2psW+Zxooqo7fJdeM1cME80HiFF9wIEfjrG9BTDv+7Q5k6ggkCtNC", + "n1PA4nm6mEBb6RI7GcfFBqPyqTpWLMb/+xn9fkb/PSyJpFiJpYqOf5xS87y88ky58x+xOubEFVhoc2Ax", + "NTXyxm/CqWLBVJYX7NJQmBIfPPTEQn+kBP/dKFnuUJV4ypCBkgTs8k+9vEWojhVxQg8y8yIO+F0MaNgf", + "mGjV4/bF+cfif9zBf6gefu3zFdjIhwT3EBfLeSluuQI7XSMRMsJVDaKonwJG3Qsa9YsYVlH6qHkbLG7s", + "UMVY8asZZvuit9GgAMkQEfPUlc7oh6Rk/i5EwxXrC1jxLEbBOz8u5ccpsrIUk+R2r6qY/M15Lc0eKzBd", + "opLkYp6LS4tbtWz9LDV6kSdm8iBiiv2QC7RLn0fhbobX4nAkFeS2iDMiON8ZYzljRLh6V9jfFfb/ZIV9", + "TjYtl3e8S/1sBSNSFiDQmUnpxwX5Er2hQ2aaQxa3Ue8QTp9CzLwi2L04W/PwlzDpdCYt5kA0xv+RqwK1", + "2gxJpz7+Xzv+p4ueZQUXBZx6I1TqeiEKmHkOJ9vNvGfa78bNf43TNppnrai48i+YPttfG7WZFlVR1bX+", + "7KMy2sH3ALn5A/Pvc2Ft9lCVxWU6HzTmSBNMk6wSlDyv5g6OvUTDXx1ZNjeXjVESbUCqrNLfTLEwAUnK", + "dxc9AgFc6+omwI3KI8m9U7Mu3DQFu9mtmaPetuRpk5J6FDArJTXRTudl/crzd7oGm7iIY8QMMt7l1L9H", + "sdcc8PdT62FMQJIP42T6iJqmbLY8KQqS+J2liKE1ZNMHm7oToPRXO6OufsOOTPM3qd7VP1mRztxK9QEk", + "f3vn4ncuXoeL0TwFSc6NUx2zT8gL0+SNdD+T2Dq/UAOKkgXSVpdDGDv97+gJWbgciXpdWbOULB6Zbf+l", + "S1H+IuPPXsv0TzYBM4puWjZLtwQRJDoXOrIJ3WmVzD/VLOQRUO9G4d/UKGzHFW8NESE3dY9CSUIlStXL", + "1QDFNcnmtJMziAn43VSuxJR8BPET7+kEdxjgonqsb4B7urQhDHBJeToL6g4TsYLxJ7HSqKKskNlXgmEf", + "k/6iCbiAffTGaRRuiQAu9aGqpaynWTbO1+//PwAA//8adl1EyPsAAA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/internal/cloudapi/v2/openapi.v2.yml b/internal/cloudapi/v2/openapi.v2.yml index 376ba03baa..cc685f71af 100644 --- a/internal/cloudapi/v2/openapi.v2.yml +++ b/internal/cloudapi/v2/openapi.v2.yml @@ -133,6 +133,60 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + delete: + operationId: deleteCompose + summary: Delete a compose + security: + - Bearer: [] + parameters: + - in: path + name: id + schema: + type: string + format: uuid + example: '123e4567-e89b-12d3-a456-426655440000' + required: true + description: ID of compose to delete + description: |- + Delete a compose and all of its independent jobs. + responses: + '200': + description: compose delete status + content: + application/json: + schema: + $ref: '#/components/schemas/ComposeDeleteStatus' + '400': + description: Invalid compose id + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: Auth token is invalid + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: Unauthorized to perform operation + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Unknown compose id + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Unexpected error occurred + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /composes/{id}/metadata: get: @@ -805,6 +859,10 @@ components: - failure - pending example: success + + ComposeDeleteStatus: + $ref: '#/components/schemas/ObjectReference' + ComposeLogs: allOf: - $ref: '#/components/schemas/ObjectReference' diff --git a/internal/cloudapi/v2/v2_test.go b/internal/cloudapi/v2/v2_test.go index b3a25fea6a..dc970b9b39 100644 --- a/internal/cloudapi/v2/v2_test.go +++ b/internal/cloudapi/v2/v2_test.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "os" + "path/filepath" "sync" "testing" "time" @@ -241,11 +242,19 @@ func mockSearch(t *testing.T, workerServer *worker.Server, wg *sync.WaitGroup, f } func newV2Server(t *testing.T, dir string, enableJWT bool, fail bool) (*v2.Server, *worker.Server, jobqueue.JobQueue, context.CancelFunc) { - q, err := fsjobqueue.New(dir) + jobsDir := filepath.Join(dir, "jobs") + err := os.Mkdir(jobsDir, 0755) require.NoError(t, err) + q, err := fsjobqueue.New(jobsDir) + require.NoError(t, err) + + artifactsDir := filepath.Join(dir, "artifacts") + err = os.Mkdir(artifactsDir, 0755) + require.NoError(t, err) + workerServer := worker.NewServer(nil, q, worker.Config{ - ArtifactsDir: t.TempDir(), + ArtifactsDir: artifactsDir, BasePath: "/api/worker/v1", JWTEnabled: enableJWT, TenantProviderFields: []string{"rh-org-id", "account_id"}, @@ -1878,6 +1887,10 @@ func TestComposesRoute(t *testing.T) { srv, _, _, cancel := newV2Server(t, t.TempDir(), false, false) defer cancel() + // List empty root composes + test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "GET", "/api/image-builder-composer/v2/composes/", ``, + http.StatusOK, `[]`) + // Make a compose so it has something to list reply := test.TestRouteWithReply(t, srv.Handler("/api/image-builder-composer/v2"), false, "POST", "/api/image-builder-composer/v2/compose", fmt.Sprintf(` { @@ -2127,3 +2140,65 @@ func TestComposeRequestMetadata(t *testing.T) { "request": %s }`, jobId, request)) } + +func TestComposesDeleteRoute(t *testing.T) { + srv, wrksrv, _, cancel := newV2Server(t, t.TempDir(), false, false) + defer cancel() + + // Make a compose so it has something to list and delete + reply := test.TestRouteWithReply(t, srv.Handler("/api/image-builder-composer/v2"), false, "POST", "/api/image-builder-composer/v2/compose", fmt.Sprintf(` + { + "distribution": "%s", + "image_request":{ + "architecture": "%s", + "image_type": "%s", + "repositories": [{ + "baseurl": "somerepo.org", + "rhsm": false + }], + "upload_options": { + "region": "eu-central-1", + "snapshot_name": "name", + "share_with_accounts": ["123456789012","234567890123"] + } + } + }`, test_distro.TestDistro1Name, test_distro.TestArch3Name, string(v2.ImageTypesAws)), http.StatusCreated, ` + { + "href": "/api/image-builder-composer/v2/compose", + "kind": "ComposeId" + }`, "id") + + // Extract the compose ID to use to test the list response + var composeReply v2.ComposeId + err := json.Unmarshal(reply, &composeReply) + require.NoError(t, err) + jobID := composeReply.Id + + _, token, jobType, _, _, err := wrksrv.RequestJob(context.Background(), test_distro.TestArch3Name, []string{worker.JobTypeOSBuild}, []string{""}, uuid.Nil) + require.NoError(t, err) + require.Equal(t, worker.JobTypeOSBuild, jobType) + res, err := json.Marshal(&worker.OSBuildJobResult{ + Success: true, + OSBuildOutput: &osbuild.Result{Success: true}, + }) + require.NoError(t, err) + err = wrksrv.FinishJob(token, res) + require.NoError(t, err) + + // List root composes + test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "GET", "/api/image-builder-composer/v2/composes/", ``, + http.StatusOK, fmt.Sprintf(`[{"href":"/api/image-builder-composer/v2/composes/%[1]s", "id":"%[1]s", "image_status":{"status":"success"}, "kind":"ComposeStatus", "status":"success"}]`, + jobID.String())) + + // Delete the compose + test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "DELETE", fmt.Sprintf("/api/image-builder-composer/v2/composes/%s", jobID.String()), ``, + http.StatusOK, fmt.Sprintf(` + { + "id": "%s", + "kind": "ComposeDeleteStatus" + }`, jobID.String()), "href") + + // List root composes (should now be none) + test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "GET", "/api/image-builder-composer/v2/composes/", ``, + http.StatusOK, `[]`) +} diff --git a/internal/jobqueue/fsjobqueue/fsjobqueue.go b/internal/jobqueue/fsjobqueue/fsjobqueue.go index ed23ce109c..f10e82e746 100644 --- a/internal/jobqueue/fsjobqueue/fsjobqueue.go +++ b/internal/jobqueue/fsjobqueue/fsjobqueue.go @@ -705,7 +705,7 @@ func jobMatchesCriteria(j *job, acceptedJobTypes []string, acceptedChannels []st // AllRootJobIDs Return a list of all the top level(root) job uuids // This only includes jobs without any Dependents set -func (q *fsJobQueue) AllRootJobIDs() ([]uuid.UUID, error) { +func (q *fsJobQueue) AllRootJobIDs(_ context.Context) ([]uuid.UUID, error) { ids, err := q.db.List() if err != nil { return nil, err @@ -726,3 +726,48 @@ func (q *fsJobQueue) AllRootJobIDs() ([]uuid.UUID, error) { return jobIDs, nil } + +// DeleteJob will delete a job and all of its dependencies +// If a dependency has multiple depenents it will only delete the parent job from +// the dependants list and then re-save the job instead of removing it. +// +// This assumes that the jobs have been created correctly, and that they have +// no dependency loops. Shared Dependants are ok, but a job cannot have a dependancy +// on any of its parents (this should never happen). +func (q *fsJobQueue) DeleteJob(_ context.Context, id uuid.UUID) error { + // Start it off with an empty parent + return q.deleteJob(uuid.UUID{}, id) +} + +// deleteJob will delete jobs as far down the list as possible +// missing dependencies are ignored, it deletes as much as it can. +// A missing parent (the first call) will be returned as an error +func (q *fsJobQueue) deleteJob(parent, id uuid.UUID) error { + var j job + _, err := q.db.Read(id.String(), &j) + if err != nil { + return err + } + + // Delete the parent uuid from the Dependents list + var deps []uuid.UUID + for _, d := range j.Dependents { + if d == parent { + continue + } + deps = append(deps, d) + } + j.Dependents = deps + + // This job can only be deleted when the Dependents list is empty + // Otherwise it needs to be saved with the new Dependents list + if len(j.Dependents) > 0 { + return q.db.Write(id.String(), j) + } + // Recursively delete the dependencies of this job + for _, dj := range j.Dependencies { + _ = q.deleteJob(id, dj) + } + + return q.db.Delete(id.String()) +} diff --git a/internal/jobqueue/fsjobqueue/fsjobqueue_test.go b/internal/jobqueue/fsjobqueue/fsjobqueue_test.go index bb330a7fbd..ee0f68e0c9 100644 --- a/internal/jobqueue/fsjobqueue/fsjobqueue_test.go +++ b/internal/jobqueue/fsjobqueue/fsjobqueue_test.go @@ -1,12 +1,14 @@ package fsjobqueue_test import ( + "context" "os" "path" "sort" "testing" "github.com/google/uuid" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/osbuild/osbuild-composer/internal/jobqueue/fsjobqueue" @@ -81,9 +83,88 @@ func TestAllRootJobIDs(t *testing.T) { rootJobs = append(rootJobs, jidRoot3) sortUUIDs(rootJobs) - roots, err := q.AllRootJobIDs() + roots, err := q.AllRootJobIDs(context.TODO()) require.Nil(t, err) require.Greater(t, len(roots), 0) sortUUIDs(roots) require.Equal(t, rootJobs, roots) } + +func TestDeleteJob(t *testing.T) { + dir := t.TempDir() + q, err := fsjobqueue.New(dir) + require.Nil(t, err) + require.NotNil(t, q) + + // root with no dependencies + jidRoot1, err := q.Enqueue("oneRoot", nil, nil, "OneRootJob") + require.Nil(t, err) + + err = q.DeleteJob(context.TODO(), jidRoot1) + require.Nil(t, err) + jobs, err := q.AllRootJobIDs(context.TODO()) + require.Nil(t, err) + require.Equal(t, 0, len(jobs)) + + // root with 2 dependencies + jid1, err := q.Enqueue("twoDeps", nil, nil, "TwoDepJobs") + require.Nil(t, err) + jid2, err := q.Enqueue("twoDeps", nil, nil, "TwoDepJobs") + require.Nil(t, err) + jidRoot2, err := q.Enqueue("twoDeps", nil, []uuid.UUID{jid1, jid2}, "TwoDepJobs") + require.Nil(t, err) + + // root with 2 dependencies, one shared with the previous root + jid3, err := q.Enqueue("sharedDeps", nil, nil, "SharedDepJobs") + require.Nil(t, err) + jidRoot3, err := q.Enqueue("sharedDeps", nil, []uuid.UUID{jid1, jid3}, "SharedDepJobs") + require.Nil(t, err) + + // This should only remove jidRoot2 and jid2, leaving jidRoot3, jid1, jid3 + err = q.DeleteJob(context.TODO(), jidRoot2) + require.Nil(t, err) + jobs, err = q.AllRootJobIDs(context.TODO()) + require.Nil(t, err) + require.Equal(t, 1, len(jobs)) + assert.Equal(t, []uuid.UUID{jidRoot3}, jobs) + + // This should remove the rest + err = q.DeleteJob(context.TODO(), jidRoot3) + require.Nil(t, err) + jobs, err = q.AllRootJobIDs(context.TODO()) + require.Nil(t, err) + require.Equal(t, 0, len(jobs)) + + // Make sure all the jobs are deleted + allJobs := []uuid.UUID{jidRoot1, jidRoot2, jidRoot3, jid1, jid2, jid3} + for _, jobId := range allJobs { + jobType, _, _, _, err := q.Job(jobId) + assert.Error(t, err, jobType) + } + + // root with 2 jobs depending on another (simulates Koji jobs) + kojiOSTree, err := q.Enqueue("ostree", nil, nil, "KojiJob") + require.Nil(t, err) + kojiDepsolve, err := q.Enqueue("depsolve", nil, nil, "KojiJob") + require.Nil(t, err) + kojiManifest, err := q.Enqueue("manifest", nil, []uuid.UUID{kojiOSTree, kojiDepsolve}, "KojiJob") + require.Nil(t, err) + kojiInit, err := q.Enqueue("init", nil, nil, "KojiJob") + require.Nil(t, err) + kojiRoot, err := q.Enqueue("final", nil, []uuid.UUID{kojiInit, kojiManifest, kojiDepsolve}, "KojiJob") + require.Nil(t, err) + + // Delete the koji job + err = q.DeleteJob(context.TODO(), kojiRoot) + require.Nil(t, err) + jobs, err = q.AllRootJobIDs(context.TODO()) + require.Nil(t, err) + require.Equal(t, 0, len(jobs)) + + // Make sure all the jobs are deleted + kojiJobs := []uuid.UUID{kojiRoot, kojiInit, kojiOSTree, kojiDepsolve, kojiManifest} + for _, jobId := range kojiJobs { + jobType, _, _, _, err := q.Job(jobId) + assert.Error(t, err, jobType) + } +} diff --git a/internal/jobqueue/jobqueuetest/jobqueuetest.go b/internal/jobqueue/jobqueuetest/jobqueuetest.go index 930a076f55..42dd3bde8a 100644 --- a/internal/jobqueue/jobqueuetest/jobqueuetest.go +++ b/internal/jobqueue/jobqueuetest/jobqueuetest.go @@ -7,15 +7,18 @@ import ( "context" "encoding/json" "fmt" - "github.com/osbuild/osbuild-composer/internal/worker" - "github.com/osbuild/osbuild-composer/internal/worker/clienterrors" "os" + "sort" "sync" "testing" "time" + "github.com/osbuild/osbuild-composer/internal/worker" + "github.com/osbuild/osbuild-composer/internal/worker/clienterrors" + "github.com/google/uuid" "github.com/osbuild/osbuild-composer/pkg/jobqueue" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -58,6 +61,8 @@ func TestJobQueue(t *testing.T, makeJobQueue MakeJobQueue) { t.Run("100-dequeuers", wrap(test100dequeuers)) t.Run("workers", wrap(testWorkers)) t.Run("fail", wrap(testFail)) + t.Run("all-root-jobs", wrap(testAllRootJobs)) + t.Run("delete-jobs", wrap(testDeleteJobs)) } func pushTestJob(t *testing.T, q jobqueue.JobQueue, jobType string, args interface{}, dependencies []uuid.UUID, channel string) uuid.UUID { @@ -814,5 +819,122 @@ func testFail(t *testing.T, q jobqueue.JobQueue) { require.Less(t, startTime, tmr) require.Greater(t, endTime, tmr) } +} +// sortUUIDs is a helper to sort a list of UUIDs +func sortUUIDs(entries []uuid.UUID) { + sort.Slice(entries, func(i, j int) bool { + return entries[i].String() < entries[j].String() + }) +} + +// Test listing all root jobs +func testAllRootJobs(t *testing.T, q jobqueue.JobQueue) { + var rootJobs []uuid.UUID + + // root with no dependencies + jidRoot1 := pushTestJob(t, q, "oneRoot", nil, nil, "OneRootJob") + rootJobs = append(rootJobs, jidRoot1) + + // root with 2 dependencies + jid1 := pushTestJob(t, q, "twoDeps", nil, nil, "TwoDepJobs") + jid2 := pushTestJob(t, q, "twoDeps", nil, nil, "TwoDepJobs") + jidRoot2 := pushTestJob(t, q, "twoDeps", nil, []uuid.UUID{jid1, jid2}, "TwoDepJobs") + rootJobs = append(rootJobs, jidRoot2) + + // root with 2 dependencies, one shared with the previous root + jid3 := pushTestJob(t, q, "sharedDeps", nil, nil, "SharedDepJobs") + jidRoot3 := pushTestJob(t, q, "sharedDeps", nil, []uuid.UUID{jid1, jid3}, "SharedDepJobs") + rootJobs = append(rootJobs, jidRoot3) + + sortUUIDs(rootJobs) + roots, err := q.AllRootJobIDs(context.Background()) + require.Nil(t, err) + require.Greater(t, len(roots), 0) + sortUUIDs(roots) + require.Equal(t, rootJobs, roots) +} + +// Test Deleting jobs +func testDeleteJobs(t *testing.T, q jobqueue.JobQueue) { + // root with no dependencies + t.Run("no dependencies", func(t *testing.T) { + jidRoot1 := pushTestJob(t, q, "oneRoot", nil, nil, "OneRootJob") + err := q.DeleteJob(context.Background(), jidRoot1) + require.Nil(t, err) + jobs, err := q.AllRootJobIDs(context.Background()) + require.Nil(t, err) + require.Equal(t, 0, len(jobs)) + }) + + // root with 2 dependencies + t.Run("two dependencies", func(t *testing.T) { + jid1 := pushTestJob(t, q, "twoDeps", nil, nil, "TwoDepJobs") + jid2 := pushTestJob(t, q, "twoDeps", nil, nil, "TwoDepJobs") + jidRoot2 := pushTestJob(t, q, "twoDeps", nil, []uuid.UUID{jid1, jid2}, "TwoDepJobs") + + // root with 2 dependencies, one shared with the previous root + jid3 := pushTestJob(t, q, "sharedDeps", nil, nil, "SharedDepJobs") + jidRoot3 := pushTestJob(t, q, "sharedDeps", nil, []uuid.UUID{jid1, jid3}, "SharedDepJobs") + + // This should only remove jidRoot2 and jid2, leaving jidRoot3, jid1, jid3 + err := q.DeleteJob(context.Background(), jidRoot2) + require.Nil(t, err) + jobs, err := q.AllRootJobIDs(context.Background()) + require.Nil(t, err) + require.Equal(t, 1, len(jobs)) + assert.Equal(t, []uuid.UUID{jidRoot3}, jobs) + + // This should remove the rest + err = q.DeleteJob(context.Background(), jidRoot3) + require.Nil(t, err) + jobs, err = q.AllRootJobIDs(context.Background()) + require.Nil(t, err) + require.Equal(t, 0, len(jobs)) + + // Make sure all the jobs are deleted + allJobs := []uuid.UUID{jidRoot2, jidRoot3, jid1, jid2, jid3} + for _, jobId := range allJobs { + jobType, _, _, _, err := q.Job(jobId) + assert.Error(t, err, jobType) + } + }) + + // Koji job with 2 images + t.Run("koji job simulation", func(t *testing.T) { + kojiInit := pushTestJob(t, q, "init", nil, nil, "KojiJob") + + finalJobs := []uuid.UUID{kojiInit} + imageJobs := []uuid.UUID{} + // Make 2 images, each one has: + // depsolve job + // ostree job + // manifest job + // osbuild job + for i := 0; i < 2; i++ { + kojiDepsolve := pushTestJob(t, q, "depsolve", nil, nil, "KojiJob") + kojiOSTree := pushTestJob(t, q, "ostree", nil, nil, "KojiJob") + kojiManifest := pushTestJob(t, q, "manifest", nil, []uuid.UUID{kojiOSTree, kojiDepsolve}, "KojiJob") + finalJobs = append(finalJobs, pushTestJob(t, q, "osbuild", nil, []uuid.UUID{kojiInit, kojiManifest, kojiDepsolve}, "KojiJob")) + + // Track the jobs inside the osbuild job for testing + imageJobs = append(imageJobs, kojiDepsolve, kojiOSTree, kojiManifest) + } + kojiRoot := pushTestJob(t, q, "final", nil, finalJobs, "KojiJob") + + // Delete the koji job + err := q.DeleteJob(context.Background(), kojiRoot) + require.Nil(t, err) + jobs, err := q.AllRootJobIDs(context.Background()) + require.Nil(t, err) + require.Equal(t, 0, len(jobs)) + + // Make sure all the jobs are deleted + kojiJobs := append(finalJobs, imageJobs...) + kojiJobs = append(kojiJobs, kojiRoot) + for _, jobId := range kojiJobs { + jobType, _, _, _, err := q.Job(jobId) + assert.Error(t, err, jobType) + } + }) } diff --git a/internal/jsondb/db.go b/internal/jsondb/db.go index 09118ed215..ecb80cd50f 100644 --- a/internal/jsondb/db.go +++ b/internal/jsondb/db.go @@ -79,6 +79,14 @@ func (db *JSONDatabase) List() ([]string, error) { return names, nil } +// Delete will delete the file from the database +func (db *JSONDatabase) Delete(name string) error { + if len(name) == 0 { + return fmt.Errorf("missing jsondb document name") + } + return os.Remove(filepath.Join(db.dir, name+".json")) +} + // Writes `document` to `name`, overwriting a previous document if it exists. // `document` must be serializable to JSON. func (db *JSONDatabase) Write(name string, document interface{}) error { diff --git a/internal/jsondb/db_test.go b/internal/jsondb/db_test.go index 8355857870..5090aa0c2a 100644 --- a/internal/jsondb/db_test.go +++ b/internal/jsondb/db_test.go @@ -121,3 +121,38 @@ func TestMultiple(t *testing.T) { require.Equalf(t, doc, d, "error retrieving document '%s'", name) } } + +func TestDelete(t *testing.T) { + dir := t.TempDir() + + perm := os.FileMode(0600) + documents := map[string]document{ + "one": document{"octopus", true}, + "two": document{"zebra", false}, + "three": document{"clownfish", true}, + } + + db := jsondb.New(dir, perm) + + for name, doc := range documents { + err := db.Write(name, doc) + require.NoError(t, err) + } + + err := db.Delete("two") + require.Nil(t, err) + + names, err := db.List() + require.NoError(t, err) + require.ElementsMatch(t, []string{"one", "three"}, names) +} + +func TestDeleteError(t *testing.T) { + dir := t.TempDir() + + perm := os.FileMode(0600) + db := jsondb.New(dir, perm) + + err := db.Delete("missing") + require.Error(t, err) +} diff --git a/internal/worker/server.go b/internal/worker/server.go index 134c470f68..ce86d64848 100644 --- a/internal/worker/server.go +++ b/internal/worker/server.go @@ -349,8 +349,53 @@ func (s *Server) JobDependencyChainErrors(id uuid.UUID) (*clienterrors.Error, er } // AllRootJobIDs returns a list of top level job UUIDs that the worker knows about -func (s *Server) AllRootJobIDs() ([]uuid.UUID, error) { - return s.jobs.AllRootJobIDs() +func (s *Server) AllRootJobIDs(ctx context.Context) ([]uuid.UUID, error) { + return s.jobs.AllRootJobIDs(ctx) +} + +// CleanupArtifacts removes worker artifact directories that do not have matching jobs +// The UUID used for the artifact directory is the same as for the job that created it +func (s *Server) CleanupArtifacts() error { + artifacts, err := os.ReadDir(s.config.ArtifactsDir) + if err != nil { + return err + } + + for _, d := range artifacts { + if !d.IsDir() { + continue + } + id, err := uuid.Parse(d.Name()) + if err != nil { + continue + } + + // Is there a job with this UUID? + if _, _, _, _, err := s.jobs.Job(id); err != nil { + // No associated job, it is safe to remove the unused artifact directory + // and everything under it, and the ComposeRequest (if it exists) + _ = os.Remove(path.Join(s.config.ArtifactsDir, "ComposeRequest", id.String()+".json")) + err = os.RemoveAll(path.Join(s.config.ArtifactsDir, id.String())) + if err != nil { + return err + } + } + } + + return nil +} + +// DeleteJob deletes a job and all of its dependencies +func (s *Server) DeleteJob(ctx context.Context, id uuid.UUID) error { + jobInfo, err := s.jobInfo(id, nil) + if err != nil { + return err + } + if jobInfo.JobStatus.Finished.IsZero() { + return fmt.Errorf("Cannot delete job before job is finished: %s", id) + } + + return s.jobs.DeleteJob(ctx, id) } func (s *Server) OSBuildJobInfo(id uuid.UUID, result *OSBuildJobResult) (*JobInfo, error) { diff --git a/internal/worker/server_test.go b/internal/worker/server_test.go index 3747420675..5d62d38d79 100644 --- a/internal/worker/server_test.go +++ b/internal/worker/server_test.go @@ -3,6 +3,7 @@ package worker_test import ( "context" "encoding/json" + "errors" "fmt" "net/http" "os" @@ -32,7 +33,14 @@ import ( ) func newTestServer(t *testing.T, tempdir string, config worker.Config, acceptArtifacts bool) *worker.Server { - q, err := fsjobqueue.New(tempdir) + // NOTE: jobs and artifacts directories need to be next to each other. artifacts inside the fsjobqueue + // directory makes it crash when trying to list all the jobs because it isn't a UUID. + jobsDir := path.Join(tempdir, "jobs") + err := os.Mkdir(jobsDir, 0755) + if err != nil && !os.IsExist(err) { + t.Fatalf("cannot create jobs directory %s: %v", jobsDir, err) + } + q, err := fsjobqueue.New(jobsDir) if err != nil { t.Fatalf("error creating fsjobqueue: %v", err) } @@ -41,7 +49,7 @@ func newTestServer(t *testing.T, tempdir string, config worker.Config, acceptArt artifactsDir := path.Join(tempdir, "artifacts") err := os.Mkdir(artifactsDir, 0755) if err != nil && !os.IsExist(err) { - t.Fatalf("cannot create state directory %s: %v", artifactsDir, err) + t.Fatalf("cannot create artifacts directory %s: %v", artifactsDir, err) } config.ArtifactsDir = artifactsDir } @@ -1607,3 +1615,74 @@ func TestJobHeartbeats(t *testing.T) { require.Equal(t, float64(0), promtest.ToFloat64(prometheus.PendingJobs)) require.Equal(t, float64(0), promtest.ToFloat64(prometheus.RunningJobs)) } + +func makeFakeArtifact(tempdir string, id uuid.UUID, filename string) error { + d := path.Join(tempdir, "artifacts", id.String()) + err := os.Mkdir(d, 0755) + if err != nil { + return err + } + if len(filename) > 0 { + p := path.Join(d, filename) + fp, err := os.Create(p) + if err != nil { + return err + } + return fp.Close() + } + + return nil +} + +func artifactUUIDExists(tempdir string, id uuid.UUID) bool { + dir := path.Join(tempdir, "artifacts", id.String()) + _, err := os.Stat(dir) + return !errors.Is(err, os.ErrNotExist) +} + +func TestCleanupArtifacts(t *testing.T) { + distroStruct := newTestDistro(t) + arch, err := distroStruct.GetArch(test_distro.TestArchName) + if err != nil { + t.Fatalf("error getting arch from distro: %v", err) + } + imageType, err := arch.GetImageType(test_distro.TestImageTypeName) + if err != nil { + t.Fatalf("error getting image type from arch: %v", err) + } + manifest, _, err := imageType.Manifest(nil, distro.ImageOptions{Size: imageType.Size(0)}, nil, nil) + if err != nil { + t.Fatalf("error creating osbuild manifest: %v", err) + } + tempdir := t.TempDir() + server := newTestServer(t, tempdir, defaultConfig, true) + mf, err := manifest.Serialize(nil, nil, nil, nil) + if err != nil { + t.Fatalf("error creating osbuild manifest: %v", err) + } + + jobID, err := server.EnqueueOSBuild(arch.Name(), &worker.OSBuildJob{Manifest: mf}, "") + require.NoError(t, err) + + // Make a fake artifact for the existing jobid + err = makeFakeArtifact(tempdir, jobID, "not-a-real.img") + require.Nil(t, err) + + // Make an artifact directory for a job that is already gone + lostJobID := uuid.New() + err = makeFakeArtifact(tempdir, lostJobID, "not-a-real.img") + require.Nil(t, err) + + // Make an artifact directory with no files + emptyJobID := uuid.New() + err = makeFakeArtifact(tempdir, emptyJobID, "") + require.Nil(t, err) + + // This should remove lostJobID and not jobID + err = server.CleanupArtifacts() + require.Nil(t, err) + + assert.True(t, artifactUUIDExists(tempdir, jobID)) + assert.False(t, artifactUUIDExists(tempdir, lostJobID)) + assert.False(t, artifactUUIDExists(tempdir, emptyJobID)) +} diff --git a/pkg/jobqueue/dbjobqueue/dbjobqueue.go b/pkg/jobqueue/dbjobqueue/dbjobqueue.go index 34074dee77..b4ec0bdb57 100644 --- a/pkg/jobqueue/dbjobqueue/dbjobqueue.go +++ b/pkg/jobqueue/dbjobqueue/dbjobqueue.go @@ -15,6 +15,7 @@ import ( "time" "github.com/google/uuid" + "github.com/jackc/pgconn" "github.com/jackc/pgtype" "github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4/pgxpool" @@ -61,6 +62,10 @@ const ( SET started_at = NULL, token = NULL, retries = retries + 1 WHERE id = $1 AND started_at IS NOT NULL AND finished_at IS NULL` + sqlDelete = ` + DELETE FROM jobs + WHERE id = $1` + sqlInsertDependency = `INSERT INTO job_dependencies VALUES ($1, $2)` sqlQueryDependencies = ` SELECT dependency_id @@ -70,7 +75,12 @@ const ( SELECT job_id FROM job_dependencies WHERE dependency_id = $1` + sqlDeleteDependencies = ` + DELETE FROM job_dependencies + WHERE job_id = $1 AND dependency_id = $2` + sqlQueryListJobs = ` + SELECT id from jobs` sqlQueryJob = ` SELECT type, args, channel, started_at, finished_at, retries, canceled FROM jobs @@ -138,6 +148,14 @@ const ( WHERE worker_id = $1` ) +// connection unifies pgxpool.Conn and pgx.Tx interfaces +// Some methods don't care whether they run queries on a raw connection, +// or in a transaction. This interface thus abstracts this concept. +type connection interface { + Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error) + Exec(ctx context.Context, sql string, arguments ...interface{}) (pgconn.CommandTag, error) +} + type DBJobQueue struct { logger jobqueue.SimpleLogger pool *pgxpool.Pool @@ -849,13 +867,6 @@ func (q *DBJobQueue) DeleteWorker(workerID uuid.UUID) error { return nil } -// connection unifies pgxpool.Conn and pgx.Tx interfaces -// Some methods don't care whether they run queries on a raw connection, -// or in a transaction. This interface thus abstracts this concept. -type connection interface { - Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error) -} - func (q *DBJobQueue) jobDependencies(ctx context.Context, conn connection, id uuid.UUID) ([]uuid.UUID, error) { rows, err := conn.Query(ctx, sqlQueryDependencies, id) if err != nil { @@ -904,9 +915,153 @@ func (q *DBJobQueue) jobDependents(ctx context.Context, conn connection, id uuid return dependents, nil } +// listJobs returns a list of all of the job UUIDs +func (q *DBJobQueue) listJobs(ctx context.Context, conn connection) (jobs []uuid.UUID, err error) { + rows, err := conn.Query(ctx, sqlQueryListJobs) + if err != nil { + return + } + defer rows.Close() + + for rows.Next() { + var t uuid.UUID + err = rows.Scan(&t) + if err != nil { + // Log the error and try to continue with the next row + q.logger.Error(err, "Unable to read job uuid from jobs") + continue + } + jobs = append(jobs, t) + } + if rows.Err() != nil { + q.logger.Error(rows.Err(), "Error reading job uuids from jobs") + } + + return +} + // AllRootJobIDs returns a list of top level job UUIDs that the worker knows about -func (q *DBJobQueue) AllRootJobIDs() ([]uuid.UUID, error) { - // TODO write this +func (q *DBJobQueue) AllRootJobIDs(ctx context.Context) (rootJobs []uuid.UUID, err error) { + conn, err := q.pool.Acquire(ctx) + if err != nil { + return + } + defer conn.Release() + + var jobs []uuid.UUID + jobs, err = q.listJobs(ctx, conn) + if err != nil { + return + } + + for _, id := range jobs { + var dependents []uuid.UUID + dependents, err = q.jobDependents(ctx, conn, id) + if err != nil { + return + } + + if len(dependents) == 0 { + rootJobs = append(rootJobs, id) + } + } + + return +} - return nil, nil +// DeleteJob deletes a job and all of its dependencies from the database +// If a dependency has multiple dependents it will only remove the parent job from +// the dependents list for that job instead of removing it. +// +// This assumes that the jobs have been created correctly, and that they have +// no dependency loops. Shared Dependents are ok, but a job cannot have a dependency +// on any of its parents (this should never happen). +func (q *DBJobQueue) DeleteJob(ctx context.Context, id uuid.UUID) error { + conn, err := q.pool.Acquire(ctx) + if err != nil { + return err + } + defer conn.Release() + + tx, err := conn.Begin(ctx) + if err != nil { + return fmt.Errorf("error starting database transaction: %w", err) + } + defer func() { + err := tx.Rollback(ctx) + if err != nil && !errors.Is(err, pgx.ErrTxClosed) { + q.logger.Error(err, "Error rolling back delete transaction") + } + }() + + // Start it off with an empty parent + err = q.deleteJobs(ctx, tx, uuid.UUID{}, id) + if err != nil { + return fmt.Errorf("Error deleting job %s: %w", id.String(), err) + } + err = tx.Commit(ctx) + if err != nil { + return fmt.Errorf("unable to commit database transaction: %v", err) + } + + q.logger.Info("Deleted job", "job_id", id.String()) + + return nil +} + +// deleteJobs will delete jobs as far down the list as possible +// missing dependencies are ignored, it deletes as much as it can. +// This function is recursive, the first call to it should be with +// the parent set to uuid.UUID{} +func (q *DBJobQueue) deleteJobs(ctx context.Context, conn connection, parent, id uuid.UUID) error { + // Delete parent:id dependencies if they exist + if len(parent.String()) > 0 { + err := q.deleteJobDependencies(ctx, conn, parent, id) + if err != nil { + return err + } + } + + // Get the list of dependents for this id + dependents, err := q.jobDependents(ctx, conn, id) + if err != nil { + return err + } + + // If this is > 0 then we are done, cannot delete further + if len(dependents) > 0 { + return nil + } + + // Nothing depends on this job, recursively remove the dependencies + deps, err := q.jobDependencies(ctx, conn, id) + if err != nil { + return err + } + for _, d := range deps { + _ = q.deleteJobs(ctx, conn, id, d) // Recursively delete dependencies + } + + return q.deleteJob(ctx, conn, id) // Actual delete from the database +} + +// deleteJob removes the job from the database +// the CASCADE constraint will also delete any entries from the job_dependencies table +func (q *DBJobQueue) deleteJob(ctx context.Context, conn connection, jobID uuid.UUID) error { + _, err := conn.Exec(ctx, sqlDelete, jobID) + if err != nil { + q.logger.Error(err, "Error deleting job") + return err + } + return nil +} + +// deleteJobDependencies removes job dependencies +func (q *DBJobQueue) deleteJobDependencies(ctx context.Context, conn connection, jobID, dependencyID uuid.UUID) error { + _, err := conn.Exec(ctx, sqlDeleteDependencies, jobID, dependencyID) + if err != nil { + q.logger.Error(err, "Error deleting dependency") + return err + } + return nil } diff --git a/pkg/jobqueue/jobqueue.go b/pkg/jobqueue/jobqueue.go index 28f30df8e5..323b9bf059 100644 --- a/pkg/jobqueue/jobqueue.go +++ b/pkg/jobqueue/jobqueue.go @@ -97,7 +97,10 @@ type JobQueue interface { DeleteWorker(workerID uuid.UUID) error // AllRootJobIDs returns a list of top level job UUIDs that the worker knows about - AllRootJobIDs() ([]uuid.UUID, error) + AllRootJobIDs(context.Context) ([]uuid.UUID, error) + + // DeleteJob deletes a job and all of its dependencies + DeleteJob(context.Context, uuid.UUID) error } // SimpleLogger provides a structured logging methods for the jobqueue library.