diff --git a/.vscode/settings.json b/.vscode/settings.json index 42900fa357..f1b96e2a42 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -52,7 +52,7 @@ }, }, "editor.tabCompletion": "on", - "go.lintTool": "golangci-lint", + "go.lintTool": "golangci-lint-v2", "go.lintFlags": [ "--path-mode=abs", "--fast-only", diff --git a/packages/api/internal/api/api.gen.go b/packages/api/internal/api/api.gen.go index b1ccf2943f..6cea337545 100644 --- a/packages/api/internal/api/api.gen.go +++ b/packages/api/internal/api/api.gen.go @@ -1625,6 +1625,9 @@ type GetV2SandboxesSandboxIDLogsParams struct { // PostAccessTokensJSONRequestBody defines body for PostAccessTokens for application/json ContentType. type PostAccessTokensJSONRequestBody = NewAccessToken +// PostAdminTeamsTeamIDApiKeysJSONRequestBody defines body for PostAdminTeamsTeamIDApiKeys for application/json ContentType. +type PostAdminTeamsTeamIDApiKeysJSONRequestBody = NewTeamAPIKey + // PostApiKeysJSONRequestBody defines body for PostApiKeys for application/json ContentType. type PostApiKeysJSONRequestBody = NewTeamAPIKey @@ -1885,6 +1888,14 @@ type ClientInterface interface { // DeleteAccessTokensAccessTokenID request DeleteAccessTokensAccessTokenID(ctx context.Context, accessTokenID AccessTokenID, reqEditors ...RequestEditorFn) (*http.Response, error) + // PostAdminTeamsTeamIDApiKeysWithBody request with any body + PostAdminTeamsTeamIDApiKeysWithBody(ctx context.Context, teamID openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + PostAdminTeamsTeamIDApiKeys(ctx context.Context, teamID openapi_types.UUID, body PostAdminTeamsTeamIDApiKeysJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // DeleteAdminTeamsTeamIDApiKeysApiKeyID request + DeleteAdminTeamsTeamIDApiKeysApiKeyID(ctx context.Context, teamID openapi_types.UUID, apiKeyID ApiKeyID, reqEditors ...RequestEditorFn) (*http.Response, error) + // PostAdminTeamsTeamIDBuildsCancel request PostAdminTeamsTeamIDBuildsCancel(ctx context.Context, teamID openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -2118,6 +2129,42 @@ func (c *Client) DeleteAccessTokensAccessTokenID(ctx context.Context, accessToke return c.Client.Do(req) } +func (c *Client) PostAdminTeamsTeamIDApiKeysWithBody(ctx context.Context, teamID openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPostAdminTeamsTeamIDApiKeysRequestWithBody(c.Server, teamID, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) PostAdminTeamsTeamIDApiKeys(ctx context.Context, teamID openapi_types.UUID, body PostAdminTeamsTeamIDApiKeysJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPostAdminTeamsTeamIDApiKeysRequest(c.Server, teamID, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) DeleteAdminTeamsTeamIDApiKeysApiKeyID(ctx context.Context, teamID openapi_types.UUID, apiKeyID ApiKeyID, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDeleteAdminTeamsTeamIDApiKeysApiKeyIDRequest(c.Server, teamID, apiKeyID) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) PostAdminTeamsTeamIDBuildsCancel(ctx context.Context, teamID openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewPostAdminTeamsTeamIDBuildsCancelRequest(c.Server, teamID) if err != nil { @@ -3056,6 +3103,94 @@ func NewDeleteAccessTokensAccessTokenIDRequest(server string, accessTokenID Acce return req, nil } +// NewPostAdminTeamsTeamIDApiKeysRequest calls the generic PostAdminTeamsTeamIDApiKeys builder with application/json body +func NewPostAdminTeamsTeamIDApiKeysRequest(server string, teamID openapi_types.UUID, body PostAdminTeamsTeamIDApiKeysJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewPostAdminTeamsTeamIDApiKeysRequestWithBody(server, teamID, "application/json", bodyReader) +} + +// NewPostAdminTeamsTeamIDApiKeysRequestWithBody generates requests for PostAdminTeamsTeamIDApiKeys with any type of body +func NewPostAdminTeamsTeamIDApiKeysRequestWithBody(server string, teamID openapi_types.UUID, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "teamID", teamID, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/admin/teams/%s/api-keys", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewDeleteAdminTeamsTeamIDApiKeysApiKeyIDRequest generates requests for DeleteAdminTeamsTeamIDApiKeysApiKeyID +func NewDeleteAdminTeamsTeamIDApiKeysApiKeyIDRequest(server string, teamID openapi_types.UUID, apiKeyID ApiKeyID) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "teamID", teamID, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "apiKeyID", apiKeyID, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/admin/teams/%s/api-keys/%s", pathParam0, pathParam1) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewPostAdminTeamsTeamIDBuildsCancelRequest generates requests for PostAdminTeamsTeamIDBuildsCancel func NewPostAdminTeamsTeamIDBuildsCancelRequest(server string, teamID openapi_types.UUID) (*http.Request, error) { var err error @@ -5697,6 +5832,14 @@ type ClientWithResponsesInterface interface { // DeleteAccessTokensAccessTokenIDWithResponse request DeleteAccessTokensAccessTokenIDWithResponse(ctx context.Context, accessTokenID AccessTokenID, reqEditors ...RequestEditorFn) (*DeleteAccessTokensAccessTokenIDResponse, error) + // PostAdminTeamsTeamIDApiKeysWithBodyWithResponse request with any body + PostAdminTeamsTeamIDApiKeysWithBodyWithResponse(ctx context.Context, teamID openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostAdminTeamsTeamIDApiKeysResponse, error) + + PostAdminTeamsTeamIDApiKeysWithResponse(ctx context.Context, teamID openapi_types.UUID, body PostAdminTeamsTeamIDApiKeysJSONRequestBody, reqEditors ...RequestEditorFn) (*PostAdminTeamsTeamIDApiKeysResponse, error) + + // DeleteAdminTeamsTeamIDApiKeysApiKeyIDWithResponse request + DeleteAdminTeamsTeamIDApiKeysApiKeyIDWithResponse(ctx context.Context, teamID openapi_types.UUID, apiKeyID ApiKeyID, reqEditors ...RequestEditorFn) (*DeleteAdminTeamsTeamIDApiKeysApiKeyIDResponse, error) + // PostAdminTeamsTeamIDBuildsCancelWithResponse request PostAdminTeamsTeamIDBuildsCancelWithResponse(ctx context.Context, teamID openapi_types.UUID, reqEditors ...RequestEditorFn) (*PostAdminTeamsTeamIDBuildsCancelResponse, error) @@ -5958,6 +6101,74 @@ func (r DeleteAccessTokensAccessTokenIDResponse) ContentType() string { return "" } +type PostAdminTeamsTeamIDApiKeysResponse struct { + Body []byte + HTTPResponse *http.Response + JSON201 *CreatedTeamAPIKey + JSON400 *N400 + JSON401 *N401 + JSON403 *N403 + JSON404 *N404 + JSON500 *N500 +} + +// Status returns HTTPResponse.Status +func (r PostAdminTeamsTeamIDApiKeysResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r PostAdminTeamsTeamIDApiKeysResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r PostAdminTeamsTeamIDApiKeysResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type DeleteAdminTeamsTeamIDApiKeysApiKeyIDResponse struct { + Body []byte + HTTPResponse *http.Response + JSON400 *N400 + JSON401 *N401 + JSON404 *N404 + JSON500 *N500 +} + +// Status returns HTTPResponse.Status +func (r DeleteAdminTeamsTeamIDApiKeysApiKeyIDResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DeleteAdminTeamsTeamIDApiKeysApiKeyIDResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r DeleteAdminTeamsTeamIDApiKeysApiKeyIDResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + type PostAdminTeamsTeamIDBuildsCancelResponse struct { Body []byte HTTPResponse *http.Response @@ -7501,6 +7712,7 @@ type PostV3TemplatesResponse struct { JSON202 *TemplateRequestResponseV3 JSON400 *N400 JSON401 *N401 + JSON403 *N403 JSON500 *N500 } @@ -7684,6 +7896,32 @@ func (c *ClientWithResponses) DeleteAccessTokensAccessTokenIDWithResponse(ctx co return ParseDeleteAccessTokensAccessTokenIDResponse(rsp) } +// PostAdminTeamsTeamIDApiKeysWithBodyWithResponse request with arbitrary body returning *PostAdminTeamsTeamIDApiKeysResponse +func (c *ClientWithResponses) PostAdminTeamsTeamIDApiKeysWithBodyWithResponse(ctx context.Context, teamID openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostAdminTeamsTeamIDApiKeysResponse, error) { + rsp, err := c.PostAdminTeamsTeamIDApiKeysWithBody(ctx, teamID, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePostAdminTeamsTeamIDApiKeysResponse(rsp) +} + +func (c *ClientWithResponses) PostAdminTeamsTeamIDApiKeysWithResponse(ctx context.Context, teamID openapi_types.UUID, body PostAdminTeamsTeamIDApiKeysJSONRequestBody, reqEditors ...RequestEditorFn) (*PostAdminTeamsTeamIDApiKeysResponse, error) { + rsp, err := c.PostAdminTeamsTeamIDApiKeys(ctx, teamID, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePostAdminTeamsTeamIDApiKeysResponse(rsp) +} + +// DeleteAdminTeamsTeamIDApiKeysApiKeyIDWithResponse request returning *DeleteAdminTeamsTeamIDApiKeysApiKeyIDResponse +func (c *ClientWithResponses) DeleteAdminTeamsTeamIDApiKeysApiKeyIDWithResponse(ctx context.Context, teamID openapi_types.UUID, apiKeyID ApiKeyID, reqEditors ...RequestEditorFn) (*DeleteAdminTeamsTeamIDApiKeysApiKeyIDResponse, error) { + rsp, err := c.DeleteAdminTeamsTeamIDApiKeysApiKeyID(ctx, teamID, apiKeyID, reqEditors...) + if err != nil { + return nil, err + } + return ParseDeleteAdminTeamsTeamIDApiKeysApiKeyIDResponse(rsp) +} + // PostAdminTeamsTeamIDBuildsCancelWithResponse request returning *PostAdminTeamsTeamIDBuildsCancelResponse func (c *ClientWithResponses) PostAdminTeamsTeamIDBuildsCancelWithResponse(ctx context.Context, teamID openapi_types.UUID, reqEditors ...RequestEditorFn) (*PostAdminTeamsTeamIDBuildsCancelResponse, error) { rsp, err := c.PostAdminTeamsTeamIDBuildsCancel(ctx, teamID, reqEditors...) @@ -8392,6 +8630,114 @@ func ParseDeleteAccessTokensAccessTokenIDResponse(rsp *http.Response) (*DeleteAc return response, nil } +// ParsePostAdminTeamsTeamIDApiKeysResponse parses an HTTP response from a PostAdminTeamsTeamIDApiKeysWithResponse call +func ParsePostAdminTeamsTeamIDApiKeysResponse(rsp *http.Response) (*PostAdminTeamsTeamIDApiKeysResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &PostAdminTeamsTeamIDApiKeysResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: + var dest CreatedTeamAPIKey + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON201 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest N400 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest N401 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest N403 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest N404 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest N500 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseDeleteAdminTeamsTeamIDApiKeysApiKeyIDResponse parses an HTTP response from a DeleteAdminTeamsTeamIDApiKeysApiKeyIDWithResponse call +func ParseDeleteAdminTeamsTeamIDApiKeysApiKeyIDResponse(rsp *http.Response) (*DeleteAdminTeamsTeamIDApiKeysApiKeyIDResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DeleteAdminTeamsTeamIDApiKeysApiKeyIDResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest N400 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest N401 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest N404 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest N500 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ParsePostAdminTeamsTeamIDBuildsCancelResponse parses an HTTP response from a PostAdminTeamsTeamIDBuildsCancelWithResponse call func ParsePostAdminTeamsTeamIDBuildsCancelResponse(rsp *http.Response) (*PostAdminTeamsTeamIDBuildsCancelResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -10538,6 +10884,13 @@ func ParsePostV3TemplatesResponse(rsp *http.Response) (*PostV3TemplatesResponse, } response.JSON401 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest N403 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: var dest N500 if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -10732,6 +11085,12 @@ type ServerInterface interface { // (DELETE /access-tokens/{accessTokenID}) DeleteAccessTokensAccessTokenID(c *gin.Context, accessTokenID AccessTokenID) + // Create team API key as admin + // (POST /admin/teams/{teamID}/api-keys) + PostAdminTeamsTeamIDApiKeys(c *gin.Context, teamID openapi_types.UUID) + // Delete team API key as admin + // (DELETE /admin/teams/{teamID}/api-keys/{apiKeyID}) + DeleteAdminTeamsTeamIDApiKeysApiKeyID(c *gin.Context, teamID openapi_types.UUID, apiKeyID ApiKeyID) // Cancel all builds for a team // (POST /admin/teams/{teamID}/builds/cancel) PostAdminTeamsTeamIDBuildsCancel(c *gin.Context, teamID openapi_types.UUID) @@ -10945,6 +11304,69 @@ func (siw *ServerInterfaceWrapper) DeleteAccessTokensAccessTokenID(c *gin.Contex siw.Handler.DeleteAccessTokensAccessTokenID(c, accessTokenID) } +// PostAdminTeamsTeamIDApiKeys operation middleware +func (siw *ServerInterfaceWrapper) PostAdminTeamsTeamIDApiKeys(c *gin.Context) { + + var err error + _ = err + + // ------------- Path parameter "teamID" ------------- + var teamID openapi_types.UUID + + err = runtime.BindStyledParameterWithOptions("simple", "teamID", c.Param("teamID"), &teamID, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "uuid"}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter teamID: %w", err), http.StatusBadRequest) + return + } + + c.Set(string(AdminTokenAuthScopes), []string{}) + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostAdminTeamsTeamIDApiKeys(c, teamID) +} + +// DeleteAdminTeamsTeamIDApiKeysApiKeyID operation middleware +func (siw *ServerInterfaceWrapper) DeleteAdminTeamsTeamIDApiKeysApiKeyID(c *gin.Context) { + + var err error + _ = err + + // ------------- Path parameter "teamID" ------------- + var teamID openapi_types.UUID + + err = runtime.BindStyledParameterWithOptions("simple", "teamID", c.Param("teamID"), &teamID, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "uuid"}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter teamID: %w", err), http.StatusBadRequest) + return + } + + // ------------- Path parameter "apiKeyID" ------------- + var apiKeyID ApiKeyID + + err = runtime.BindStyledParameterWithOptions("simple", "apiKeyID", c.Param("apiKeyID"), &apiKeyID, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter apiKeyID: %w", err), http.StatusBadRequest) + return + } + + c.Set(string(AdminTokenAuthScopes), []string{}) + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.DeleteAdminTeamsTeamIDApiKeysApiKeyID(c, teamID, apiKeyID) +} + // PostAdminTeamsTeamIDBuildsCancel operation middleware func (siw *ServerInterfaceWrapper) PostAdminTeamsTeamIDBuildsCancel(c *gin.Context) { @@ -12926,6 +13348,8 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options router.POST(options.BaseURL+"/access-tokens", wrapper.PostAccessTokens) router.DELETE(options.BaseURL+"/access-tokens/:accessTokenID", wrapper.DeleteAccessTokensAccessTokenID) + router.POST(options.BaseURL+"/admin/teams/:teamID/api-keys", wrapper.PostAdminTeamsTeamIDApiKeys) + router.DELETE(options.BaseURL+"/admin/teams/:teamID/api-keys/:apiKeyID", wrapper.DeleteAdminTeamsTeamIDApiKeysApiKeyID) router.POST(options.BaseURL+"/admin/teams/:teamID/builds/cancel", wrapper.PostAdminTeamsTeamIDBuildsCancel) router.POST(options.BaseURL+"/admin/teams/:teamID/sandboxes/kill", wrapper.PostAdminTeamsTeamIDSandboxesKill) router.GET(options.BaseURL+"/api-keys", wrapper.GetApiKeys) @@ -12985,174 +13409,175 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options // const string: with thousands of chunks the chained `+` fold is several // times slower for the Go compiler than parsing a slice literal. var swaggerSpec = []string{ - "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", - "+c14/ou/C8Xk6v6/AwAA//8=", + "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", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/packages/api/internal/handlers/admin_api_keys.go b/packages/api/internal/handlers/admin_api_keys.go new file mode 100644 index 0000000000..78d956e4c6 --- /dev/null +++ b/packages/api/internal/handlers/admin_api_keys.go @@ -0,0 +1,105 @@ +package handlers + +import ( + "errors" + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + openapi_types "github.com/oapi-codegen/runtime/types" + + "github.com/e2b-dev/infra/packages/api/internal/api" + "github.com/e2b-dev/infra/packages/api/internal/team" + sharedauth "github.com/e2b-dev/infra/packages/auth/pkg/auth" + "github.com/e2b-dev/infra/packages/db/pkg/dberrors" + "github.com/e2b-dev/infra/packages/shared/pkg/ginutils" +) + +func (a *APIStore) PostAdminTeamsTeamIDApiKeys(c *gin.Context, teamID openapi_types.UUID) { + ctx := c.Request.Context() + + body, err := ginutils.ParseBody[api.NewTeamAPIKey](ctx, c) + if err != nil { + a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Error when parsing request: %s", err)) + + return + } + + teamInfo, err := a.authService.GetTeamByID(ctx, teamID) + if err != nil { + var forbiddenErr *sharedauth.TeamForbiddenError + if errors.As(err, &forbiddenErr) { + a.sendAPIStoreError(c, http.StatusForbidden, forbiddenErr.Error()) + + return + } + if dberrors.IsNotFoundError(err) { + a.sendAPIStoreError(c, http.StatusNotFound, "Team not found") + + return + } + + a.sendAPIStoreError(c, http.StatusInternalServerError, fmt.Sprintf("Error when getting team: %s", err)) + + return + } + if teamInfo == nil { + a.sendAPIStoreError(c, http.StatusNotFound, "Team not found") + + return + } + + if err := sharedauth.CheckTeamBlocked(teamInfo); err != nil { + a.sendAPIStoreError(c, http.StatusForbidden, err.Error()) + + return + } + + apiKey, err := team.CreateAPIKey(ctx, a.authDB, teamID, nil, body.Name) + if err != nil { + a.sendAPIStoreError(c, http.StatusInternalServerError, fmt.Sprintf("Error when creating team API key: %s", err)) + + return + } + + c.JSON(http.StatusCreated, api.CreatedTeamAPIKey{ + Id: apiKey.ID, + Name: apiKey.Name, + Key: apiKey.RawAPIKey, + Mask: api.IdentifierMaskingDetails{ + Prefix: apiKey.ApiKeyPrefix, + ValueLength: int(apiKey.ApiKeyLength), + MaskedValuePrefix: apiKey.ApiKeyMaskPrefix, + MaskedValueSuffix: apiKey.ApiKeyMaskSuffix, + }, + CreatedBy: nil, + CreatedAt: apiKey.CreatedAt, + LastUsed: apiKey.LastUsed, + }) +} + +func (a *APIStore) DeleteAdminTeamsTeamIDApiKeysApiKeyID(c *gin.Context, teamID openapi_types.UUID, apiKeyID api.ApiKeyID) { + ctx := c.Request.Context() + + apiKeyUUID, err := uuid.Parse(apiKeyID) + if err != nil { + a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Error when parsing API key ID: %s", err)) + + return + } + + deleted, err := team.DeleteAPIKey(ctx, a.authDB, teamID, apiKeyUUID) + if err != nil { + a.sendAPIStoreError(c, http.StatusInternalServerError, fmt.Sprintf("Error when deleting API key: %s", err)) + + return + } + if !deleted { + a.sendAPIStoreError(c, http.StatusNotFound, "API key not found") + + return + } + + c.Status(http.StatusNoContent) +} diff --git a/packages/api/internal/handlers/admin_api_keys_test.go b/packages/api/internal/handlers/admin_api_keys_test.go new file mode 100644 index 0000000000..c6301da7e8 --- /dev/null +++ b/packages/api/internal/handlers/admin_api_keys_test.go @@ -0,0 +1,286 @@ +package handlers + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/e2b-dev/infra/packages/api/internal/api" + sharedauth "github.com/e2b-dev/infra/packages/auth/pkg/auth" + authtypes "github.com/e2b-dev/infra/packages/auth/pkg/types" + authqueries "github.com/e2b-dev/infra/packages/db/pkg/auth/queries" + "github.com/e2b-dev/infra/packages/db/pkg/testutils" +) + +func TestPostAdminTeamsTeamIDApiKeysCreatesTeamKey(t *testing.T) { + t.Parallel() + + testDB := testutils.SetupDatabase(t) + teamID := testutils.CreateTestTeam(t, testDB) + + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + ginCtx.Request = httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/admin/teams/"+teamID.String()+"/api-keys", strings.NewReader(`{ + "name": "Admin integration" + }`)) + ginCtx.Request.Header.Set("Content-Type", "application/json") + + store := &APIStore{ + authDB: testDB.AuthDB, + authService: fakeAPIKeyAuthService{team: &authtypes.Team{Team: &authqueries.Team{ID: teamID}}}, + } + store.PostAdminTeamsTeamIDApiKeys(ginCtx, teamID) + + if recorder.Code != http.StatusCreated { + t.Fatalf("expected status 201, got %d: %s", recorder.Code, recorder.Body.String()) + } + + var body api.CreatedTeamAPIKey + if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if body.Id == uuid.Nil { + t.Fatal("expected API key id") + } + if body.Name != "Admin integration" { + t.Fatalf("expected API key name, got %q", body.Name) + } + if body.Key == "" { + t.Fatal("expected raw API key in response") + } + if body.CreatedBy != nil { + t.Fatalf("expected admin-created key to have nil creator, got %v", *body.CreatedBy) + } + + keys, err := testDB.AuthDB.Read.GetTeamAPIKeysWithCreator(t.Context(), teamID) + if err != nil { + t.Fatalf("failed to list API keys: %v", err) + } + if len(keys) != 1 { + t.Fatalf("expected one API key, got %d", len(keys)) + } + if keys[0].Name != "Admin integration" { + t.Fatalf("expected persisted API key name, got %q", keys[0].Name) + } + if keys[0].CreatedByID != nil { + t.Fatalf("expected nil persisted creator, got %v", *keys[0].CreatedByID) + } +} + +func TestPostAdminTeamsTeamIDApiKeysRejectsBlockedTeam(t *testing.T) { + t.Parallel() + + testDB := testutils.SetupDatabase(t) + teamID := testutils.CreateTestTeam(t, testDB) + blockedReason := "payment required" + + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + ginCtx.Request = httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/admin/teams/"+teamID.String()+"/api-keys", strings.NewReader(`{ + "name": "Admin integration" + }`)) + ginCtx.Request.Header.Set("Content-Type", "application/json") + + store := &APIStore{ + authDB: testDB.AuthDB, + authService: fakeAPIKeyAuthService{team: &authtypes.Team{Team: &authqueries.Team{ + ID: teamID, + IsBlocked: true, + BlockedReason: &blockedReason, + }}}, + } + store.PostAdminTeamsTeamIDApiKeys(ginCtx, teamID) + + if recorder.Code != http.StatusForbidden { + t.Fatalf("expected status 403, got %d: %s", recorder.Code, recorder.Body.String()) + } + + keys, err := testDB.AuthDB.Read.GetTeamAPIKeysWithCreator(t.Context(), teamID) + if err != nil { + t.Fatalf("failed to list API keys: %v", err) + } + if len(keys) != 0 { + t.Fatalf("expected no API keys for blocked team, got %d", len(keys)) + } +} + +func TestPostAdminTeamsTeamIDApiKeysRejectsBannedTeam(t *testing.T) { + t.Parallel() + + testDB := testutils.SetupDatabase(t) + teamID := testutils.CreateTestTeam(t, testDB) + + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + ginCtx.Request = httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/admin/teams/"+teamID.String()+"/api-keys", strings.NewReader(`{ + "name": "Admin integration" + }`)) + ginCtx.Request.Header.Set("Content-Type", "application/json") + + store := &APIStore{ + authDB: testDB.AuthDB, + authService: fakeAPIKeyAuthService{err: &sharedauth.TeamForbiddenError{Message: "team is banned"}}, + } + store.PostAdminTeamsTeamIDApiKeys(ginCtx, teamID) + + if recorder.Code != http.StatusForbidden { + t.Fatalf("expected status 403, got %d: %s", recorder.Code, recorder.Body.String()) + } +} + +func TestPostAdminTeamsTeamIDApiKeysRejectsMissingTeam(t *testing.T) { + t.Parallel() + + testDB := testutils.SetupDatabase(t) + teamID := uuid.New() + + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + ginCtx.Request = httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/admin/teams/"+teamID.String()+"/api-keys", strings.NewReader(`{ + "name": "Admin integration" + }`)) + ginCtx.Request.Header.Set("Content-Type", "application/json") + + store := &APIStore{ + authDB: testDB.AuthDB, + authService: fakeAPIKeyAuthService{err: sql.ErrNoRows}, + } + store.PostAdminTeamsTeamIDApiKeys(ginCtx, teamID) + + if recorder.Code != http.StatusNotFound { + t.Fatalf("expected status 404, got %d: %s", recorder.Code, recorder.Body.String()) + } +} + +func TestPostAdminTeamsTeamIDApiKeysRejectsNilTeam(t *testing.T) { + t.Parallel() + + testDB := testutils.SetupDatabase(t) + teamID := uuid.New() + + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + ginCtx.Request = httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/admin/teams/"+teamID.String()+"/api-keys", strings.NewReader(`{ + "name": "Admin integration" + }`)) + ginCtx.Request.Header.Set("Content-Type", "application/json") + + store := &APIStore{ + authDB: testDB.AuthDB, + authService: fakeAPIKeyAuthService{}, + } + store.PostAdminTeamsTeamIDApiKeys(ginCtx, teamID) + + if recorder.Code != http.StatusNotFound { + t.Fatalf("expected status 404, got %d: %s", recorder.Code, recorder.Body.String()) + } +} + +func TestDeleteAdminTeamsTeamIDApiKeysDeletesTeamKey(t *testing.T) { + t.Parallel() + + testDB := testutils.SetupDatabase(t) + teamID := testutils.CreateTestTeam(t, testDB) + + createRecorder := httptest.NewRecorder() + createCtx, _ := gin.CreateTestContext(createRecorder) + createCtx.Request = httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/admin/teams/"+teamID.String()+"/api-keys", strings.NewReader(`{"name":"Admin integration"}`)) + createCtx.Request.Header.Set("Content-Type", "application/json") + + store := &APIStore{ + authDB: testDB.AuthDB, + authService: fakeAPIKeyAuthService{team: &authtypes.Team{Team: &authqueries.Team{ID: teamID}}}, + } + store.PostAdminTeamsTeamIDApiKeys(createCtx, teamID) + if createRecorder.Code != http.StatusCreated { + t.Fatalf("expected create status 201, got %d: %s", createRecorder.Code, createRecorder.Body.String()) + } + + var created api.CreatedTeamAPIKey + if err := json.Unmarshal(createRecorder.Body.Bytes(), &created); err != nil { + t.Fatalf("failed to decode create response: %v", err) + } + + deleteRecorder := httptest.NewRecorder() + deleteCtx, _ := gin.CreateTestContext(deleteRecorder) + deleteCtx.Request = httptest.NewRequestWithContext(t.Context(), http.MethodDelete, "/admin/teams/"+teamID.String()+"/api-keys/"+created.Id.String(), nil) + + store.DeleteAdminTeamsTeamIDApiKeysApiKeyID(deleteCtx, teamID, created.Id.String()) + if deleteCtx.Writer.Status() != http.StatusNoContent { + t.Fatalf("expected delete status 204, got %d: %s", deleteCtx.Writer.Status(), deleteRecorder.Body.String()) + } + + keys, err := testDB.AuthDB.Read.GetTeamAPIKeysWithCreator(t.Context(), teamID) + if err != nil { + t.Fatalf("failed to list API keys: %v", err) + } + if len(keys) != 0 { + t.Fatalf("expected API key to be deleted, got %d keys", len(keys)) + } +} + +func TestDeleteAdminTeamsTeamIDApiKeysRejectsMissingKey(t *testing.T) { + t.Parallel() + + testDB := testutils.SetupDatabase(t) + teamID := testutils.CreateTestTeam(t, testDB) + + store := &APIStore{authDB: testDB.AuthDB} + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + missingKeyID := uuid.New() + ctx.Request = httptest.NewRequestWithContext(t.Context(), http.MethodDelete, "/admin/teams/"+teamID.String()+"/api-keys/"+missingKeyID.String(), nil) + + store.DeleteAdminTeamsTeamIDApiKeysApiKeyID(ctx, teamID, missingKeyID.String()) + + require.Equal(t, http.StatusNotFound, recorder.Code) + require.Contains(t, recorder.Header().Get("Content-Type"), "application/json") + require.JSONEq(t, `{"code":404,"message":"API key not found"}`, recorder.Body.String()) +} + +type fakeAPIKeyAuthService struct { + team *authtypes.Team + err error +} + +func (f fakeAPIKeyAuthService) ValidateAPIKey(context.Context, *gin.Context, string) (*authtypes.Team, *sharedauth.APIError) { + return nil, nil +} + +func (f fakeAPIKeyAuthService) ValidateAccessToken(context.Context, *gin.Context, string) (uuid.UUID, *sharedauth.APIError) { + return uuid.Nil, nil +} + +func (f fakeAPIKeyAuthService) ValidateAuthProviderToken(context.Context, *gin.Context, string) (uuid.UUID, *sharedauth.APIError) { + return uuid.Nil, nil +} + +func (f fakeAPIKeyAuthService) ValidateSupabaseTeam(context.Context, *gin.Context, string) (*authtypes.Team, *sharedauth.APIError) { + return nil, nil +} + +func (f fakeAPIKeyAuthService) GetTeamByID(context.Context, uuid.UUID) (*authtypes.Team, error) { + if f.err != nil { + return nil, f.err + } + + return f.team, nil +} + +func (f fakeAPIKeyAuthService) InvalidateTeamMemberCache(context.Context, uuid.UUID, string) {} + +func (f fakeAPIKeyAuthService) InvalidateTeamCache(context.Context, uuid.UUID) error { + return nil +} + +func (f fakeAPIKeyAuthService) Close(context.Context) error { + return nil +} diff --git a/packages/api/internal/handlers/apikey.go b/packages/api/internal/handlers/apikey.go index 1d899a93b4..21d1e30a32 100644 --- a/packages/api/internal/handlers/apikey.go +++ b/packages/api/internal/handlers/apikey.go @@ -40,15 +40,7 @@ func (a *APIStore) PatchApiKeysApiKeyID(c *gin.Context, apiKeyID string) { return } - teamInfo := auth.MustGetTeamInfo(c) - - if err := auth.CheckTeamBlocked(teamInfo); err != nil { - a.sendAPIStoreError(c, http.StatusForbidden, err.Error()) - - return - } - - teamID := teamInfo.Team.ID + teamID := auth.MustGetTeamID(c) now := time.Now() _, err = a.authDB.Write.UpdateTeamApiKey(ctx, authqueries.UpdateTeamApiKeyParams{ @@ -126,10 +118,7 @@ func (a *APIStore) DeleteApiKeysApiKeyID(c *gin.Context, apiKeyID string) { teamID := auth.MustGetTeamID(c) - ids, err := a.authDB.Write.DeleteTeamAPIKey(ctx, authqueries.DeleteTeamAPIKeyParams{ - ID: apiKeyIDParsed, - TeamID: teamID, - }) + deleted, err := team.DeleteAPIKey(ctx, a.authDB, teamID, apiKeyIDParsed) if err != nil { a.sendAPIStoreError(c, http.StatusInternalServerError, fmt.Sprintf("Error when deleting API key: %s", err)) @@ -137,7 +126,7 @@ func (a *APIStore) DeleteApiKeysApiKeyID(c *gin.Context, apiKeyID string) { return } - if len(ids) == 0 { + if !deleted { c.String(http.StatusNotFound, "id not found") return @@ -150,15 +139,7 @@ func (a *APIStore) PostApiKeys(c *gin.Context) { ctx := c.Request.Context() userID := auth.MustGetUserID(c) - teamInfo := auth.MustGetTeamInfo(c) - - if err := auth.CheckTeamBlocked(teamInfo); err != nil { - a.sendAPIStoreError(c, http.StatusForbidden, err.Error()) - - return - } - - teamID := teamInfo.Team.ID + teamID := auth.MustGetTeamID(c) body, err := ginutils.ParseBody[api.NewTeamAPIKey](ctx, c) if err != nil { @@ -169,7 +150,7 @@ func (a *APIStore) PostApiKeys(c *gin.Context) { return } - apiKey, err := team.CreateAPIKey(ctx, a.authDB, teamID, userID, body.Name) + apiKey, err := team.CreateAPIKey(ctx, a.authDB, teamID, &userID, body.Name) if err != nil { a.sendAPIStoreError(c, http.StatusInternalServerError, fmt.Sprintf("Error when creating team API key: %s", err)) diff --git a/packages/api/internal/handlers/auth.go b/packages/api/internal/handlers/auth.go index 88f0514b62..50d7b2d79c 100644 --- a/packages/api/internal/handlers/auth.go +++ b/packages/api/internal/handlers/auth.go @@ -12,10 +12,24 @@ import ( "github.com/e2b-dev/infra/packages/api/internal/api" templatecache "github.com/e2b-dev/infra/packages/api/internal/cache/templates" dbapi "github.com/e2b-dev/infra/packages/api/internal/db" + "github.com/e2b-dev/infra/packages/api/internal/middleware" "github.com/e2b-dev/infra/packages/auth/pkg/auth" "github.com/e2b-dev/infra/packages/auth/pkg/types" ) +// applyTeamAccessCheck maps a team-access denial to a 403 *api.APIError. +func applyTeamAccessCheck(c *gin.Context, team *types.Team) *api.APIError { + if err := middleware.CheckTeamAccessForRoute(c, team); err != nil { + return &api.APIError{ + Code: http.StatusForbidden, + ClientMsg: err.Error(), + Err: err, + } + } + + return nil +} + func (a *APIStore) GetTeam( ctx context.Context, c *gin.Context, @@ -52,6 +66,11 @@ func (a *APIStore) GetTeam( } } + // More restrictive than before, but OK on this deprecated path. + if apiErr := applyTeamAccessCheck(c, team); apiErr != nil { + return nil, apiErr + } + return team, nil } @@ -147,6 +166,11 @@ func (a *APIStore) resolveTemplateAndTeam( for _, t := range userTeams { if t.Team.ID == aliasInfo.TeamID { + // More restrictive than before, but OK on this deprecated path. + if apiErr := applyTeamAccessCheck(c, t.Team); apiErr != nil { + return nil, nil, apiErr + } + return t.Team, aliasInfo, nil } } diff --git a/packages/api/internal/handlers/deprecated_template_request_build.go b/packages/api/internal/handlers/deprecated_template_request_build.go index 0d24653fbb..844ed2cca7 100644 --- a/packages/api/internal/handlers/deprecated_template_request_build.go +++ b/packages/api/internal/handlers/deprecated_template_request_build.go @@ -44,12 +44,6 @@ func (a *APIStore) PostTemplates(c *gin.Context) { return } - if err := auth.CheckTeamBlocked(team); err != nil { - a.sendAPIStoreError(c, http.StatusForbidden, err.Error()) - - return - } - telemetry.ReportEvent(ctx, "started creating new environment") templateID := id.Generate() @@ -113,12 +107,6 @@ func (a *APIStore) PostTemplatesTemplateID(c *gin.Context, rawTemplateID api.Tem return } - if err := auth.CheckTeamBlocked(team); err != nil { - a.sendAPIStoreError(c, http.StatusForbidden, err.Error()) - - return - } - templateDB, err := a.sqlcDB.GetTemplateByID(ctx, templateID) switch { case err == nil: diff --git a/packages/api/internal/handlers/deprecated_template_start_build.go b/packages/api/internal/handlers/deprecated_template_start_build.go index 7aa7a1b6e1..0b9cacef56 100644 --- a/packages/api/internal/handlers/deprecated_template_start_build.go +++ b/packages/api/internal/handlers/deprecated_template_start_build.go @@ -134,9 +134,10 @@ func (a *APIStore) PostTemplatesTemplateIDBuildsBuildID(c *gin.Context, template return } - if err := auth.CheckTeamBlocked(team); err != nil { - a.sendAPIStoreError(c, http.StatusForbidden, err.Error()) - telemetry.ReportErrorByCode(ctx, http.StatusForbidden, "team is blocked", err) + // More restrictive than before, but OK on this deprecated path. + if apiErr := applyTeamAccessCheck(c, team); apiErr != nil { + a.sendAPIStoreError(c, apiErr.Code, apiErr.ClientMsg) + telemetry.ReportErrorByCode(ctx, apiErr.Code, "team access denied", apiErr.Err) return } diff --git a/packages/api/internal/handlers/sandbox_connect.go b/packages/api/internal/handlers/sandbox_connect.go index 8128da2547..746165556c 100644 --- a/packages/api/internal/handlers/sandbox_connect.go +++ b/packages/api/internal/handlers/sandbox_connect.go @@ -27,12 +27,6 @@ func (a *APIStore) PostSandboxesSandboxIDConnect(c *gin.Context, sandboxID api.S // Get team from context, use TeamContextKey teamInfo := auth.MustGetTeamInfo(c) - if err := auth.CheckTeamBlocked(teamInfo); err != nil { - a.sendAPIStoreError(c, http.StatusForbidden, err.Error()) - - return - } - span := trace.SpanFromContext(ctx) traceID := span.SpanContext().TraceID().String() c.Set("traceID", traceID) diff --git a/packages/api/internal/handlers/sandbox_create.go b/packages/api/internal/handlers/sandbox_create.go index 66434a3af1..95f2c0ba5e 100644 --- a/packages/api/internal/handlers/sandbox_create.go +++ b/packages/api/internal/handlers/sandbox_create.go @@ -62,12 +62,6 @@ func (a *APIStore) PostSandboxes(c *gin.Context) { // Get team from context, use TeamContextKey teamInfo := auth.MustGetTeamInfo(c) - if err := auth.CheckTeamBlocked(teamInfo); err != nil { - a.sendAPIStoreError(c, http.StatusForbidden, err.Error()) - - return - } - c.Set("teamID", teamInfo.Team.ID.String()) span := trace.SpanFromContext(ctx) diff --git a/packages/api/internal/handlers/sandbox_network_update.go b/packages/api/internal/handlers/sandbox_network_update.go index 31648977ea..e6fc796105 100644 --- a/packages/api/internal/handlers/sandbox_network_update.go +++ b/packages/api/internal/handlers/sandbox_network_update.go @@ -26,15 +26,7 @@ func (a *APIStore) PutSandboxesSandboxIDNetwork(c *gin.Context, sandboxID string return } - teamInfo := auth.MustGetTeamInfo(c) - - if err := auth.CheckTeamBlocked(teamInfo); err != nil { - a.sendAPIStoreError(c, http.StatusForbidden, err.Error()) - - return - } - - teamID := teamInfo.Team.ID + teamID := auth.MustGetTeamID(c) body, err := ginutils.ParseBody[api.SandboxNetworkUpdateConfig](ctx, c) if err != nil { diff --git a/packages/api/internal/handlers/sandbox_pause.go b/packages/api/internal/handlers/sandbox_pause.go index 5040682997..3ef7eeb6d3 100644 --- a/packages/api/internal/handlers/sandbox_pause.go +++ b/packages/api/internal/handlers/sandbox_pause.go @@ -26,14 +26,7 @@ func (a *APIStore) PostSandboxesSandboxIDPause(c *gin.Context, sandboxID api.San ctx := c.Request.Context() // Get team from context, use TeamContextKey - team := auth.MustGetTeamInfo(c) - teamID := team.Team.ID - - if err := auth.CheckTeamBlocked(team); err != nil { - a.sendAPIStoreError(c, http.StatusForbidden, err.Error()) - - return - } + teamID := auth.MustGetTeamID(c) var err error sandboxID, err = utils.ShortID(sandboxID) diff --git a/packages/api/internal/handlers/sandbox_refresh.go b/packages/api/internal/handlers/sandbox_refresh.go index 34ca0a5f36..8daa123c7f 100644 --- a/packages/api/internal/handlers/sandbox_refresh.go +++ b/packages/api/internal/handlers/sandbox_refresh.go @@ -31,14 +31,7 @@ func (a *APIStore) PostSandboxesSandboxIDRefreshes( telemetry.SetAttributes(ctx, telemetry.WithSandboxID(sandboxID)) - team := auth.MustGetTeamInfo(c) - teamID := team.Team.ID - - if err := auth.CheckTeamBlocked(team); err != nil { - a.sendAPIStoreError(c, http.StatusForbidden, err.Error()) - - return - } + teamID := auth.MustGetTeamID(c) var duration time.Duration diff --git a/packages/api/internal/handlers/sandbox_resume.go b/packages/api/internal/handlers/sandbox_resume.go index 6ee26259e2..1535e35d6f 100644 --- a/packages/api/internal/handlers/sandbox_resume.go +++ b/packages/api/internal/handlers/sandbox_resume.go @@ -31,12 +31,6 @@ func (a *APIStore) PostSandboxesSandboxIDResume(c *gin.Context, sandboxID api.Sa // Get team from context, use TeamContextKey teamInfo := auth.MustGetTeamInfo(c) - if err := auth.CheckTeamBlocked(teamInfo); err != nil { - a.sendAPIStoreError(c, http.StatusForbidden, err.Error()) - - return - } - span := trace.SpanFromContext(ctx) traceID := span.SpanContext().TraceID().String() c.Set("traceID", traceID) diff --git a/packages/api/internal/handlers/sandbox_timeout.go b/packages/api/internal/handlers/sandbox_timeout.go index 71fb4ad803..004a706459 100644 --- a/packages/api/internal/handlers/sandbox_timeout.go +++ b/packages/api/internal/handlers/sandbox_timeout.go @@ -30,15 +30,7 @@ func (a *APIStore) PostSandboxesSandboxIDTimeout( telemetry.SetAttributes(ctx, telemetry.WithSandboxID(sandboxID)) - teamInfo := auth.MustGetTeamInfo(c) - - if err := auth.CheckTeamBlocked(teamInfo); err != nil { - a.sendAPIStoreError(c, http.StatusForbidden, err.Error()) - - return - } - - teamID := teamInfo.Team.ID + teamID := auth.MustGetTeamID(c) var duration time.Duration diff --git a/packages/api/internal/handlers/snapshot_template_create.go b/packages/api/internal/handlers/snapshot_template_create.go index ab0a19f2dd..7908b5c9c0 100644 --- a/packages/api/internal/handlers/snapshot_template_create.go +++ b/packages/api/internal/handlers/snapshot_template_create.go @@ -27,12 +27,6 @@ func (a *APIStore) PostSandboxesSandboxIDSnapshots(c *gin.Context, sandboxID api teamInfo := auth.MustGetTeamInfo(c) - if err := auth.CheckTeamBlocked(teamInfo); err != nil { - a.sendAPIStoreError(c, http.StatusForbidden, err.Error()) - - return - } - teamID := teamInfo.Team.ID span := trace.SpanFromContext(ctx) diff --git a/packages/api/internal/handlers/teams.go b/packages/api/internal/handlers/teams.go index 5e2bc2b641..08df5a94a6 100644 --- a/packages/api/internal/handlers/teams.go +++ b/packages/api/internal/handlers/teams.go @@ -27,7 +27,7 @@ func (a *APIStore) GetTeams(c *gin.Context) { teams := make([]api.Team, len(results)) for i, row := range results { // We create a new API key for the CLI and backwards compatibility with API Keys hashing - apiKey, err := team.CreateAPIKey(ctx, a.authDB, row.Team.ID, userID, "CLI login/configure") + apiKey, err := team.CreateAPIKey(ctx, a.authDB, row.Team.ID, &userID, "CLI login/configure") if err != nil { telemetry.ReportCriticalError(ctx, "error when creating team API key", err) a.sendAPIStoreError(c, http.StatusInternalServerError, "Error when creating team API key") diff --git a/packages/api/internal/handlers/template_layer_files_upload.go b/packages/api/internal/handlers/template_layer_files_upload.go index 04e11bd7a9..f67a52e3c2 100644 --- a/packages/api/internal/handlers/template_layer_files_upload.go +++ b/packages/api/internal/handlers/template_layer_files_upload.go @@ -8,7 +8,6 @@ import ( "go.opentelemetry.io/otel/attribute" "github.com/e2b-dev/infra/packages/api/internal/api" - "github.com/e2b-dev/infra/packages/auth/pkg/auth" "github.com/e2b-dev/infra/packages/shared/pkg/clusters" "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" ) @@ -34,12 +33,6 @@ func (a *APIStore) GetTemplatesTemplateIDFilesHash(c *gin.Context, templateID ap return } - if err := auth.CheckTeamBlocked(team); err != nil { - a.sendAPIStoreError(c, http.StatusForbidden, err.Error()) - - return - } - // Check if the user has access to the template if team.ID != templateDB.TeamID { telemetry.ReportCriticalError(ctx, "error when getting template", err, telemetry.WithTemplateID(templateID)) diff --git a/packages/api/internal/handlers/template_request_build_v3.go b/packages/api/internal/handlers/template_request_build_v3.go index 64710e52e4..c1e31b2414 100644 --- a/packages/api/internal/handlers/template_request_build_v3.go +++ b/packages/api/internal/handlers/template_request_build_v3.go @@ -11,7 +11,6 @@ import ( "github.com/e2b-dev/infra/packages/api/internal/api" templatecache "github.com/e2b-dev/infra/packages/api/internal/cache/templates" "github.com/e2b-dev/infra/packages/api/internal/template" - "github.com/e2b-dev/infra/packages/auth/pkg/auth" "github.com/e2b-dev/infra/packages/shared/pkg/clusters" "github.com/e2b-dev/infra/packages/shared/pkg/featureflags" "github.com/e2b-dev/infra/packages/shared/pkg/ginutils" @@ -51,13 +50,6 @@ func requestTemplateBuild(ctx context.Context, c *gin.Context, a *APIStore, body return nil } - // Shared by PostV2Templates and PostV3Templates. - if err := auth.CheckTeamBlocked(team); err != nil { - a.sendAPIStoreError(c, http.StatusForbidden, err.Error()) - - return nil - } - // Determine the input based on which field is provided var input string switch { diff --git a/packages/api/internal/handlers/template_start_build_v2.go b/packages/api/internal/handlers/template_start_build_v2.go index 1490cde8ed..b9b31095a3 100644 --- a/packages/api/internal/handlers/template_start_build_v2.go +++ b/packages/api/internal/handlers/template_start_build_v2.go @@ -14,7 +14,6 @@ import ( "go.uber.org/zap" "github.com/e2b-dev/infra/packages/api/internal/api" - "github.com/e2b-dev/infra/packages/auth/pkg/auth" "github.com/e2b-dev/infra/packages/db/pkg/types" "github.com/e2b-dev/infra/packages/db/queries" "github.com/e2b-dev/infra/packages/shared/pkg/clusters" @@ -81,12 +80,6 @@ func (a *APIStore) PostV2TemplatesTemplateIDBuildsBuildID(c *gin.Context, templa return } - if err := auth.CheckTeamBlocked(team); err != nil { - a.sendAPIStoreError(c, http.StatusForbidden, err.Error()) - - return - } - if team.ID != templateBuildDB.Env.TeamID { a.sendAPIStoreError(c, http.StatusForbidden, "User does not have access to the template") diff --git a/packages/api/internal/handlers/template_tags.go b/packages/api/internal/handlers/template_tags.go index 4718bbb0a8..1e43e6bc7f 100644 --- a/packages/api/internal/handlers/template_tags.go +++ b/packages/api/internal/handlers/template_tags.go @@ -11,7 +11,6 @@ import ( "github.com/e2b-dev/infra/packages/api/internal/api" templatecache "github.com/e2b-dev/infra/packages/api/internal/cache/templates" - "github.com/e2b-dev/infra/packages/auth/pkg/auth" "github.com/e2b-dev/infra/packages/db/pkg/dberrors" "github.com/e2b-dev/infra/packages/db/queries" "github.com/e2b-dev/infra/packages/shared/pkg/ginutils" @@ -48,12 +47,6 @@ func (a *APIStore) PostTemplatesTags(c *gin.Context) { return } - if err := auth.CheckTeamBlocked(team); err != nil { - a.sendAPIStoreError(c, http.StatusForbidden, err.Error()) - - return - } - identifier, tag, err := id.ParseName(body.Target) if err != nil { a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Invalid target template format: %s", err)) diff --git a/packages/api/internal/handlers/template_update.go b/packages/api/internal/handlers/template_update.go index 0774e57032..de3c2e9063 100644 --- a/packages/api/internal/handlers/template_update.go +++ b/packages/api/internal/handlers/template_update.go @@ -11,7 +11,6 @@ import ( "github.com/e2b-dev/infra/packages/api/internal/api" templatecache "github.com/e2b-dev/infra/packages/api/internal/cache/templates" - "github.com/e2b-dev/infra/packages/auth/pkg/auth" "github.com/e2b-dev/infra/packages/auth/pkg/types" "github.com/e2b-dev/infra/packages/db/pkg/dberrors" "github.com/e2b-dev/infra/packages/db/queries" @@ -92,14 +91,6 @@ func (a *APIStore) updateTemplate(ctx context.Context, c *gin.Context, aliasOrTe return nil, nil, apiErr } - if err := auth.CheckTeamBlocked(team); err != nil { - return nil, nil, &api.APIError{ - Code: http.StatusForbidden, - ClientMsg: err.Error(), - Err: err, - } - } - telemetry.SetAttributes(ctx, attribute.String("env.team.id", team.ID.String()), attribute.String("env.team.name", team.Name), diff --git a/packages/api/internal/handlers/volume_create.go b/packages/api/internal/handlers/volume_create.go index f059d13126..f13bbb97ff 100644 --- a/packages/api/internal/handlers/volume_create.go +++ b/packages/api/internal/handlers/volume_create.go @@ -12,7 +12,6 @@ import ( "github.com/e2b-dev/infra/packages/api/internal/api" "github.com/e2b-dev/infra/packages/api/internal/clusters" - "github.com/e2b-dev/infra/packages/auth/pkg/auth" "github.com/e2b-dev/infra/packages/db/pkg/dberrors" "github.com/e2b-dev/infra/packages/db/queries" clustershared "github.com/e2b-dev/infra/packages/shared/pkg/clusters" @@ -34,12 +33,6 @@ func (a *APIStore) PostVolumes(c *gin.Context) { return } - if err := auth.CheckTeamBlocked(team); err != nil { - a.sendAPIStoreError(c, http.StatusForbidden, err.Error()) - - return - } - telemetry.SetAttributes(ctx, telemetry.WithTeamID(team.ID.String()), ) diff --git a/packages/api/internal/middleware/blocked_team.go b/packages/api/internal/middleware/blocked_team.go new file mode 100644 index 0000000000..be9157f5d3 --- /dev/null +++ b/packages/api/internal/middleware/blocked_team.go @@ -0,0 +1,56 @@ +package middleware + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/e2b-dev/infra/packages/auth/pkg/auth" + "github.com/e2b-dev/infra/packages/auth/pkg/types" +) + +// blockedTeamAllowlist enumerates routes a blocked team MAY still reach on +// the api service. Admin and access-token-only routes are omitted: they +// have no team on the gin context so the middleware short-circuits. +var blockedTeamAllowlist = auth.BlockedTeamAllowlist{ + http.MethodGet: { + "/api-keys": {}, + "/sandboxes": {}, + "/sandboxes/metrics": {}, + "/sandboxes/:sandboxID": {}, + "/sandboxes/:sandboxID/logs": {}, + "/sandboxes/:sandboxID/metrics": {}, + "/snapshots": {}, + "/teams/:teamID/metrics": {}, + "/teams/:teamID/metrics/max": {}, + "/templates": {}, + "/templates/:templateID": {}, + "/templates/:templateID/tags": {}, + "/templates/aliases/:alias": {}, + "/templates/:templateID/builds/:buildID/logs": {}, + "/templates/:templateID/builds/:buildID/status": {}, + "/v2/sandboxes": {}, + "/v2/sandboxes/:sandboxID/logs": {}, + "/volumes": {}, + "/volumes/:volumeID": {}, + }, + http.MethodDelete: { + "/sandboxes/:sandboxID": {}, + "/templates/:templateID": {}, + "/templates/tags": {}, + "/volumes/:volumeID": {}, + "/api-keys/:apiKeyID": {}, + }, +} + +// EnforceBlockedTeam returns the gin middleware for the api service, +// configured with the api-specific blocked-team allowlist. +func EnforceBlockedTeam() gin.HandlerFunc { + return auth.EnforceBlockedTeam(blockedTeamAllowlist) +} + +// CheckTeamAccessForRoute applies CheckTeamBanned + the api blocked-team +// allowlist to a late-resolved team. +func CheckTeamAccessForRoute(c *gin.Context, team *types.Team) error { + return auth.CheckTeamAccess(c, team, blockedTeamAllowlist) +} diff --git a/packages/api/internal/team/apikeys.go b/packages/api/internal/team/apikeys.go index d89e49eca8..e075a4ed54 100644 --- a/packages/api/internal/team/apikeys.go +++ b/packages/api/internal/team/apikeys.go @@ -18,7 +18,7 @@ type CreateAPIKeyResponse struct { RawAPIKey string } -func CreateAPIKey(ctx context.Context, authDB *authdb.Client, teamID uuid.UUID, userID uuid.UUID, name string) (CreateAPIKeyResponse, error) { +func CreateAPIKey(ctx context.Context, authDB *authdb.Client, teamID uuid.UUID, createdBy *uuid.UUID, name string) (CreateAPIKeyResponse, error) { teamApiKey, err := keys.GenerateKey(keys.ApiKeyPrefix) if err != nil { telemetry.ReportCriticalError(ctx, "error when generating team API key", err) @@ -28,7 +28,7 @@ func CreateAPIKey(ctx context.Context, authDB *authdb.Client, teamID uuid.UUID, apiKey, err := authDB.Write.CreateTeamAPIKey(ctx, authqueries.CreateTeamAPIKeyParams{ TeamID: teamID, - CreatedBy: &userID, + CreatedBy: createdBy, ApiKeyHash: teamApiKey.HashedValue, ApiKeyPrefix: teamApiKey.Masked.Prefix, ApiKeyLength: int32(teamApiKey.Masked.ValueLength), @@ -47,3 +47,17 @@ func CreateAPIKey(ctx context.Context, authDB *authdb.Client, teamID uuid.UUID, RawAPIKey: teamApiKey.PrefixedRawValue, }, nil } + +func DeleteAPIKey(ctx context.Context, authDB *authdb.Client, teamID uuid.UUID, apiKeyID uuid.UUID) (bool, error) { + ids, err := authDB.Write.DeleteTeamAPIKey(ctx, authqueries.DeleteTeamAPIKeyParams{ + ID: apiKeyID, + TeamID: teamID, + }) + if err != nil { + telemetry.ReportCriticalError(ctx, "error when deleting API key", err) + + return false, fmt.Errorf("error when deleting API key: %w", err) + } + + return len(ids) > 0, nil +} diff --git a/packages/api/main.go b/packages/api/main.go index 0e6b1c77c8..ae9c59ec88 100644 --- a/packages/api/main.go +++ b/packages/api/main.go @@ -229,6 +229,11 @@ func NewGinServer(ctx context.Context, config cfg.Config, tel *telemetry.Client, limiter := ratelimit.NewLimiter(redisClient) r.Use(ratelimit.Middleware(limiter, ratelimit.Config{FailOpen: true}, ff)) //nolint:contextcheck // Gin middleware sets context via c.Request.WithContext + // Deny blocked teams on every mutating route unless allowlisted in + // EnforceBlockedTeam. Must run after auth (which populates team info on + // the gin context) and before the handlers. + r.Use(customMiddleware.EnforceBlockedTeam()) + // We now register our store above as the handler for the interface api.RegisterHandlersWithOptions(r, apiStore, api.GinServerOptions{ ErrorHandler: func(c *gin.Context, err error, statusCode int) { diff --git a/packages/auth/pkg/auth/service.go b/packages/auth/pkg/auth/service.go index 38b2f6afaa..c4e0888796 100644 --- a/packages/auth/pkg/auth/service.go +++ b/packages/auth/pkg/auth/service.go @@ -102,18 +102,16 @@ func (s *authService) ValidateAPIKey(ctx context.Context, ginCtx *gin.Context, a return s.store.GetTeamByHashedAPIKey(ctx, key) }) if err != nil { - var zero *types.Team - var forbiddenErr *TeamForbiddenError if errors.As(err, &forbiddenErr) { - return zero, &APIError{ + return nil, &APIError{ Err: err, ClientMsg: err.Error(), Code: http.StatusForbidden, } } - return zero, &APIError{ + return nil, &APIError{ Err: fmt.Errorf("failed to get the team from db for an api key: %w", err), ClientMsg: "Cannot get the team for the given API key", Code: http.StatusUnauthorized, @@ -226,18 +224,16 @@ func (s *authService) ValidateSupabaseTeam(ctx context.Context, ginCtx *gin.Cont return s.store.GetTeamByIDAndUserID(ctx, userID, teamID) }) if err != nil { - var zero *types.Team - var forbiddenErr *TeamForbiddenError if errors.As(err, &forbiddenErr) { - return zero, &APIError{ + return nil, &APIError{ Err: fmt.Errorf("failed getting team: %w", err), ClientMsg: fmt.Sprintf("Forbidden: %s", err.Error()), Code: http.StatusForbidden, } } - return zero, &APIError{ + return nil, &APIError{ Err: fmt.Errorf("failed getting team: %w", err), ClientMsg: "Backend authentication failed", Code: http.StatusUnauthorized, diff --git a/packages/auth/pkg/auth/team_middleware.go b/packages/auth/pkg/auth/team_middleware.go new file mode 100644 index 0000000000..c90e2f5288 --- /dev/null +++ b/packages/auth/pkg/auth/team_middleware.go @@ -0,0 +1,77 @@ +package auth + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/e2b-dev/infra/packages/auth/pkg/types" + "github.com/e2b-dev/infra/packages/shared/pkg/apierrors" +) + +// BlockedTeamAllowlist maps HTTP method to gin route patterns (c.FullPath()) +// that blocked teams MAY still reach. +type BlockedTeamAllowlist map[string]map[string]struct{} + +// Allows reports whether the matched route + method on c is in the +// allowlist. Nil-safe. +func (a BlockedTeamAllowlist) Allows(c *gin.Context) bool { + if a == nil { + return false + } + + _, ok := a[c.Request.Method][c.FullPath()] + + return ok +} + +// CheckBlockedTeamForRoute returns *TeamBlockedError if team is blocked +// and the matched route is not in allowlist. +func CheckBlockedTeamForRoute(c *gin.Context, team *types.Team, allowlist BlockedTeamAllowlist) error { + err := CheckTeamBlocked(team) + if err == nil { + return nil + } + + if allowlist.Allows(c) { + return nil + } + + return err +} + +// CheckTeamAccess returns an error if team is banned, or if team is +// blocked and the matched route is not in allowlist. +func CheckTeamAccess(c *gin.Context, team *types.Team, allowlist BlockedTeamAllowlist) error { + if team == nil || team.Team == nil { + return nil + } + + if err := CheckTeamBanned(*team.Team); err != nil { + return err + } + + return CheckBlockedTeamForRoute(c, team, allowlist) +} + +// EnforceBlockedTeam returns a gin middleware that denies blocked teams +// with 403 unless the matched route is in allowlist. Must run after auth. +func EnforceBlockedTeam(allowlist BlockedTeamAllowlist) gin.HandlerFunc { + return func(c *gin.Context) { + team, ok := GetTeamInfo(c) + if !ok || team == nil { + c.Next() + + return + } + + if err := CheckBlockedTeamForRoute(c, team, allowlist); err != nil { + apierrors.SendAPIStoreError(c, http.StatusForbidden, err.Error()) + c.Abort() + + return + } + + c.Next() + } +} diff --git a/packages/auth/pkg/auth/team_middleware_test.go b/packages/auth/pkg/auth/team_middleware_test.go new file mode 100644 index 0000000000..39a6a27bd5 --- /dev/null +++ b/packages/auth/pkg/auth/team_middleware_test.go @@ -0,0 +1,292 @@ +package auth + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/e2b-dev/infra/packages/auth/pkg/types" + authqueries "github.com/e2b-dev/infra/packages/db/pkg/auth/queries" +) + +func TestEnforceBlockedTeam(t *testing.T) { + t.Parallel() + + reason := "payment failed" + allowlist := BlockedTeamAllowlist{ + http.MethodGet: {"/sandboxes": {}}, + http.MethodDelete: {"/sandboxes/:sandboxID": {}}, + } + + cases := []struct { + name string + method string + fullPath string + team *types.Team + wantHandlerRan bool + wantStatus int + wantBodyHas string + }{ + { + name: "no team on context passes", + method: http.MethodPost, + fullPath: "/sandboxes", + team: nil, + wantHandlerRan: true, + wantStatus: http.StatusOK, + }, + { + name: "not blocked passes", + method: http.MethodPost, + fullPath: "/sandboxes", + team: types.NewTeam(&authqueries.Team{IsBlocked: false}, &authqueries.TeamLimit{}), + wantHandlerRan: true, + wantStatus: http.StatusOK, + }, + { + name: "blocked and allowlisted get passes", + method: http.MethodGet, + fullPath: "/sandboxes", + team: types.NewTeam(&authqueries.Team{IsBlocked: true, BlockedReason: &reason}, &authqueries.TeamLimit{}), + wantHandlerRan: true, + wantStatus: http.StatusOK, + }, + { + name: "blocked and allowlisted delete passes", + method: http.MethodDelete, + fullPath: "/sandboxes/:sandboxID", + team: types.NewTeam(&authqueries.Team{IsBlocked: true, BlockedReason: &reason}, &authqueries.TeamLimit{}), + wantHandlerRan: true, + wantStatus: http.StatusOK, + }, + { + name: "blocked and non-allowlisted post denied", + method: http.MethodPost, + fullPath: "/sandboxes", + team: types.NewTeam(&authqueries.Team{IsBlocked: true, BlockedReason: &reason}, &authqueries.TeamLimit{}), + wantHandlerRan: false, + wantStatus: http.StatusForbidden, + wantBodyHas: "team is blocked: " + reason, + }, + { + name: "blocked and method mismatch on allowlisted path denied", + method: http.MethodPost, + fullPath: "/sandboxes/:sandboxID", + team: types.NewTeam(&authqueries.Team{IsBlocked: true}, &authqueries.TeamLimit{}), + wantHandlerRan: false, + wantStatus: http.StatusForbidden, + wantBodyHas: "team is blocked", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + w, ran := runEnforceBlockedTeam(t, allowlist, tc.method, tc.fullPath, tc.team) + + assert.Equal(t, tc.wantHandlerRan, ran) + assert.Equal(t, tc.wantStatus, w.Code) + if tc.wantBodyHas != "" { + assert.Contains(t, w.Body.String(), tc.wantBodyHas) + } + }) + } +} + +func TestEnforceBlockedTeam_AllowlistParameterization(t *testing.T) { + t.Parallel() + + team := types.NewTeam(&authqueries.Team{IsBlocked: true}, &authqueries.TeamLimit{}) + const method, path = http.MethodGet, "/teams/:teamID/members" + + permissive := BlockedTeamAllowlist{method: {path: {}}} + restrictive := BlockedTeamAllowlist{} + + wAllowed, ranAllowed := runEnforceBlockedTeam(t, permissive, method, path, team) + require.True(t, ranAllowed) + assert.Equal(t, http.StatusOK, wAllowed.Code) + + wDenied, ranDenied := runEnforceBlockedTeam(t, restrictive, method, path, team) + require.False(t, ranDenied) + assert.Equal(t, http.StatusForbidden, wDenied.Code) +} + +func TestCheckTeamAccess(t *testing.T) { + t.Parallel() + + reason := "payment failed" + allowlist := BlockedTeamAllowlist{ + http.MethodGet: {"/sandboxes": {}}, + } + + cleanTeam := types.NewTeam(&authqueries.Team{}, &authqueries.TeamLimit{}) + bannedTeam := types.NewTeam(&authqueries.Team{IsBanned: true}, &authqueries.TeamLimit{}) + blockedTeam := types.NewTeam(&authqueries.Team{IsBlocked: true, BlockedReason: &reason}, &authqueries.TeamLimit{}) + bannedAndBlockedTeam := types.NewTeam(&authqueries.Team{IsBanned: true, IsBlocked: true, BlockedReason: &reason}, &authqueries.TeamLimit{}) + + wantForbidden := func(t *testing.T, err error) { + t.Helper() + + var target *TeamForbiddenError + require.ErrorAs(t, err, &target) + } + wantBlocked := func(t *testing.T, err error) { + t.Helper() + + var target *TeamBlockedError + require.ErrorAs(t, err, &target) + } + + cases := []struct { + name string + method string + fullPath string + team *types.Team + wantErrAs func(t *testing.T, err error) + }{ + { + name: "nil team passes", + method: http.MethodPost, + fullPath: "/sandboxes", + team: nil, + }, + { + name: "clean team passes", + method: http.MethodPost, + fullPath: "/sandboxes", + team: cleanTeam, + }, + { + name: "banned non-allowlisted denied", + method: http.MethodPost, + fullPath: "/sandboxes", + team: bannedTeam, + wantErrAs: wantForbidden, + }, + { + name: "banned allowlisted still denied", + method: http.MethodGet, + fullPath: "/sandboxes", + team: bannedTeam, + wantErrAs: wantForbidden, + }, + { + name: "blocked allowlisted passes", + method: http.MethodGet, + fullPath: "/sandboxes", + team: blockedTeam, + }, + { + name: "blocked non-allowlisted denied", + method: http.MethodPost, + fullPath: "/sandboxes", + team: blockedTeam, + wantErrAs: wantBlocked, + }, + { + name: "banned and blocked allowlisted reports banned", + method: http.MethodGet, + fullPath: "/sandboxes", + team: bannedAndBlockedTeam, + wantErrAs: wantForbidden, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := runCheckTeamAccess(t, allowlist, tc.method, tc.fullPath, tc.team) + + if tc.wantErrAs == nil { + assert.NoError(t, err) + + return + } + + require.Error(t, err) + tc.wantErrAs(t, err) + }) + } +} + +func runCheckTeamAccess( + t *testing.T, + allowlist BlockedTeamAllowlist, + method, fullPath string, + team *types.Team, +) error { + t.Helper() + + var gotErr error + + r := gin.New() + r.Handle(method, fullPath, func(c *gin.Context) { + gotErr = CheckTeamAccess(c, team, allowlist) + }) + + r.ServeHTTP(httptest.NewRecorder(), httptest.NewRequestWithContext(t.Context(), method, concretePath(fullPath), nil)) + + return gotErr +} + +func runEnforceBlockedTeam( + t *testing.T, + allowlist BlockedTeamAllowlist, + method, fullPath string, + team *types.Team, +) (*httptest.ResponseRecorder, bool) { + t.Helper() + + handlerRan := false + + r := gin.New() + r.Handle(method, fullPath, func(c *gin.Context) { + if team != nil { + SetTeamInfoForTest(t, c, team) + } + EnforceBlockedTeam(allowlist)(c) + if !c.IsAborted() { + handlerRan = true + c.Status(http.StatusOK) + } + }) + + w := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), method, concretePath(fullPath), nil) + r.ServeHTTP(w, req) + + return w, handlerRan +} + +// concretePath substitutes ":param" segments with "x" so httptest hits +// the route while c.FullPath() still reports the original pattern. +func concretePath(pattern string) string { + var b strings.Builder + b.Grow(len(pattern)) + + i := 0 + for i < len(pattern) { + if pattern[i] == ':' { + b.WriteByte('x') + i++ + + for i < len(pattern) && pattern[i] != '/' { + i++ + } + + continue + } + + b.WriteByte(pattern[i]) + i++ + } + + return b.String() +} diff --git a/packages/dashboard-api/internal/api/api.gen.go b/packages/dashboard-api/internal/api/api.gen.go index 94b4a35bcb..023f1f24e2 100644 --- a/packages/dashboard-api/internal/api/api.gen.go +++ b/packages/dashboard-api/internal/api/api.gen.go @@ -78,6 +78,15 @@ type AdminAuthProviderProfilesResponse struct { Profiles []AdminAuthProviderProfile `json:"profiles"` } +// AdminTeamBootstrapRequest defines model for AdminTeamBootstrapRequest. +type AdminTeamBootstrapRequest struct { + // Email Billing/contact email for the team. + Email openapi_types.Email `json:"email"` + + // Name Team name. + Name string `json:"name"` +} + // BuildInfo defines model for BuildInfo. type BuildInfo struct { // CreatedAt Build creation timestamp in RFC3339 format. @@ -346,6 +355,9 @@ type N404 = Error // N500 defines model for 500. type N500 = Error +// N502 defines model for 502. +type N502 = Error + // adminTokenAuthContextKey is the context key for AdminTokenAuth security scheme type adminTokenAuthContextKey string @@ -388,6 +400,9 @@ type GetTeamsResolveParams struct { Slug TeamSlug `form:"slug" json:"slug"` } +// PostAdminTeamsBootstrapJSONRequestBody defines body for PostAdminTeamsBootstrap for application/json ContentType. +type PostAdminTeamsBootstrapJSONRequestBody = AdminTeamBootstrapRequest + // PostAdminUserProfilesByEmailJSONRequestBody defines body for PostAdminUserProfilesByEmail for application/json ContentType. type PostAdminUserProfilesByEmailJSONRequestBody = AdminAuthProviderProfilesLookupEmailRequest @@ -405,6 +420,9 @@ type PostTeamsTeamIDMembersJSONRequestBody = AddTeamMemberRequest // ServerInterface represents all server handlers. type ServerInterface interface { + // Bootstrap team + // (POST /admin/teams/bootstrap) + PostAdminTeamsBootstrap(c *gin.Context) // Lookup user profiles by email // (POST /admin/user-profiles/by-email) PostAdminUserProfilesByEmail(c *gin.Context) @@ -467,6 +485,21 @@ type ServerInterfaceWrapper struct { type MiddlewareFunc func(c *gin.Context) +// PostAdminTeamsBootstrap operation middleware +func (siw *ServerInterfaceWrapper) PostAdminTeamsBootstrap(c *gin.Context) { + + c.Set(string(AdminTokenAuthScopes), []string{}) + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostAdminTeamsBootstrap(c) +} + // PostAdminUserProfilesByEmail operation middleware func (siw *ServerInterfaceWrapper) PostAdminUserProfilesByEmail(c *gin.Context) { @@ -974,6 +1007,7 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options ErrorHandler: errorHandler, } + router.POST(options.BaseURL+"/admin/teams/bootstrap", wrapper.PostAdminTeamsBootstrap) router.POST(options.BaseURL+"/admin/user-profiles/by-email", wrapper.PostAdminUserProfilesByEmail) router.POST(options.BaseURL+"/admin/user-profiles/resolve", wrapper.PostAdminUserProfilesResolve) router.GET(options.BaseURL+"/admin/user-profiles/:userId", wrapper.GetAdminUserProfilesUserId) @@ -998,61 +1032,63 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options // const string: with thousands of chunks the chained `+` fold is several // times slower for the Go compiler than parsing a slice literal. var swaggerSpec = []string{ - "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", - "87fxoPHQwK3xwK27vl7/PwAA//8=", + "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", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/packages/dashboard-api/internal/handlers/admin_teams_bootstrap.go b/packages/dashboard-api/internal/handlers/admin_teams_bootstrap.go new file mode 100644 index 0000000000..433f5b3c9d --- /dev/null +++ b/packages/dashboard-api/internal/handlers/admin_teams_bootstrap.go @@ -0,0 +1,43 @@ +package handlers + +import ( + "fmt" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "github.com/e2b-dev/infra/packages/dashboard-api/internal/api" + "github.com/e2b-dev/infra/packages/shared/pkg/ginutils" +) + +func (s *APIStore) PostAdminTeamsBootstrap(c *gin.Context) { + ctx := c.Request.Context() + + body, err := ginutils.ParseBody[api.AdminTeamBootstrapRequest](ctx, c) + if err != nil { + s.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Error when parsing request: %s", err)) + + return + } + + name := strings.TrimSpace(body.Name) + email := string(body.Email) + if name == "" || email == "" { + s.sendAPIStoreError(c, http.StatusBadRequest, "Team name and email are required") + + return + } + + team, err := s.bootstrapTeam(ctx, name, email) + if err != nil { + s.handleProvisioningError(ctx, c, "provision team", err) + + return + } + + c.JSON(http.StatusOK, api.TeamResolveResponse{ + Id: team.ID, + Slug: team.Slug, + }) +} diff --git a/packages/dashboard-api/internal/handlers/admin_teams_bootstrap_test.go b/packages/dashboard-api/internal/handlers/admin_teams_bootstrap_test.go new file mode 100644 index 0000000000..8bd5368e3c --- /dev/null +++ b/packages/dashboard-api/internal/handlers/admin_teams_bootstrap_test.go @@ -0,0 +1,160 @@ +package handlers + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + + internalteamprovision "github.com/e2b-dev/infra/packages/dashboard-api/internal/teamprovision" + "github.com/e2b-dev/infra/packages/db/pkg/testutils" + "github.com/e2b-dev/infra/packages/shared/pkg/teamprovision" +) + +func TestPostAdminTeamsBootstrapCreatesTeam(t *testing.T) { + t.Parallel() + + testDB := testutils.SetupDatabase(t) + ctx := t.Context() + sink := &fakeTeamProvisionSink{} + + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + ginCtx.Request = httptest.NewRequestWithContext(ctx, http.MethodPost, "/admin/teams/bootstrap", strings.NewReader(`{ + "name": " Bootstrap team ", + "email": "bootstrap-team@example.com" + }`)) + ginCtx.Request.Header.Set("Content-Type", "application/json") + + store := &APIStore{ + authDB: testDB.AuthDB, + teamProvisionSink: sink, + } + store.PostAdminTeamsBootstrap(ginCtx) + + if recorder.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", recorder.Code, recorder.Body.String()) + } + + var body struct { + ID uuid.UUID `json:"id"` + Slug string `json:"slug"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if body.ID == uuid.Nil { + t.Fatal("expected created team id") + } + if body.Slug == "" { + t.Fatal("expected created team slug") + } + + var name, email string + if err := testDB.SqlcClient.TestsRawSQLQuery(ctx, `SELECT name, email FROM public.teams WHERE id = $1`, func(rows pgx.Rows) error { + if !rows.Next() { + return pgx.ErrNoRows + } + + return rows.Scan(&name, &email) + }, body.ID); err != nil { + t.Fatalf("failed to query created team: %v", err) + } + if name != "Bootstrap team" { + t.Fatalf("expected trimmed name, got %q", name) + } + if email != "bootstrap-team@example.com" { + t.Fatalf("expected email, got %q", email) + } + + if len(sink.requests) != 1 { + t.Fatalf("expected one provisioning request, got %d", len(sink.requests)) + } + req := sink.requests[0] + if req.TeamID != body.ID || req.TeamName != name || req.TeamEmail != email { + t.Fatalf("unexpected provisioning request: %+v", req) + } + if req.CreatorUserID != uuid.Nil { + t.Fatalf("expected bootstrap creator user id, got %s", req.CreatorUserID) + } + if req.Reason != teamprovision.ReasonAdditionalTeam { + t.Fatalf("expected additional team reason, got %q", req.Reason) + } +} + +func TestPostAdminTeamsBootstrapRollsBackOnProvisioningFailure(t *testing.T) { + t.Parallel() + + testDB := testutils.SetupDatabase(t) + ctx := t.Context() + sink := &fakeTeamProvisionSink{ + err: &internalteamprovision.ProvisionError{ + StatusCode: http.StatusBadGateway, + Message: "billing unavailable", + }, + } + + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + ginCtx.Request = httptest.NewRequestWithContext(ctx, http.MethodPost, "/admin/teams/bootstrap", strings.NewReader(`{ + "name": "Bootstrap team", + "email": "bootstrap-team@example.com" + }`)) + ginCtx.Request.Header.Set("Content-Type", "application/json") + + store := &APIStore{ + authDB: testDB.AuthDB, + teamProvisionSink: sink, + } + store.PostAdminTeamsBootstrap(ginCtx) + + if recorder.Code != http.StatusBadGateway { + t.Fatalf("expected status 502, got %d: %s", recorder.Code, recorder.Body.String()) + } + if len(sink.requests) != 1 { + t.Fatalf("expected one provisioning request, got %d", len(sink.requests)) + } + + var count int + if err := testDB.SqlcClient.TestsRawSQLQuery(ctx, `SELECT count(*) FROM public.teams WHERE email = $1`, func(rows pgx.Rows) error { + if !rows.Next() { + return pgx.ErrNoRows + } + + return rows.Scan(&count) + }, "bootstrap-team@example.com"); err != nil { + t.Fatalf("failed to count teams: %v", err) + } + if count != 0 { + t.Fatalf("expected rolled back team, found %d", count) + } +} + +func TestPostAdminTeamsBootstrapRejectsMissingFields(t *testing.T) { + t.Parallel() + + testDB := testutils.SetupDatabase(t) + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + ginCtx.Request = httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/admin/teams/bootstrap", strings.NewReader(`{ + "name": "Bootstrap team", + "email": "" + }`)) + ginCtx.Request.Header.Set("Content-Type", "application/json") + + store := &APIStore{ + authDB: testDB.AuthDB, + teamProvisionSink: &fakeTeamProvisionSink{err: errors.New("should not provision")}, + } + store.PostAdminTeamsBootstrap(ginCtx) + + if recorder.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d: %s", recorder.Code, recorder.Body.String()) + } +} diff --git a/packages/dashboard-api/internal/handlers/utils_team_provisioning.go b/packages/dashboard-api/internal/handlers/utils_team_provisioning.go index 1fe9cf97db..b977f0a6d5 100644 --- a/packages/dashboard-api/internal/handlers/utils_team_provisioning.go +++ b/packages/dashboard-api/internal/handlers/utils_team_provisioning.go @@ -218,6 +218,46 @@ func (s *APIStore) createTeam(ctx context.Context, userID uuid.UUID, name string }, nil } +func (s *APIStore) bootstrapTeam(ctx context.Context, name string, email string) (provisionedTeam, error) { + team, err := s.authDB.Write.CreateTeam(ctx, authqueries.CreateTeamParams{ + Name: name, + Tier: baseTierID, + Email: email, + IsBlocked: false, + BlockedReason: nil, + }) + if err != nil { + return provisionedTeam{}, fmt.Errorf("create team: %w", err) + } + + req := teamprovision.TeamBillingProvisionRequestedV1{ + TeamID: team.ID, + TeamName: team.Name, + TeamEmail: team.Email, + CreatorUserID: uuid.Nil, + Reason: teamprovision.ReasonAdditionalTeam, + } + if err := s.teamProvisionSink.ProvisionTeam(ctx, req); err != nil { + rollbackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), teamProvisionRollbackTimeout) + defer cancel() + + if deleteErr := s.authDB.Write.DeleteTeamByID(rollbackCtx, team.ID); deleteErr != nil { + return provisionedTeam{}, fmt.Errorf("delete team after provisioning failure: provision=%s delete=%w", err.Error(), deleteErr) + } + + return provisionedTeam{}, err + } + + return provisionedTeam{ + ID: team.ID, + Name: team.Name, + Email: team.Email, + Slug: team.Slug, + IsBlocked: team.IsBlocked, + BlockedReason: team.BlockedReason, + }, nil +} + func validateTeamCreationAllowed(ctx context.Context, authTxDB *authqueries.Queries, ownerUserID uuid.UUID) error { teams, err := authTxDB.GetTeamsWithUsersTeamsWithTierForUpdate(ctx, ownerUserID) if err != nil { diff --git a/packages/dashboard-api/internal/middleware/blocked_team.go b/packages/dashboard-api/internal/middleware/blocked_team.go new file mode 100644 index 0000000000..ef76a39950 --- /dev/null +++ b/packages/dashboard-api/internal/middleware/blocked_team.go @@ -0,0 +1,30 @@ +package middleware + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/e2b-dev/infra/packages/auth/pkg/auth" +) + +// blockedTeamAllowlist enumerates routes a blocked team MAY still reach on +// the dashboard-api service. User-only and admin routes are omitted: they +// have no team on the gin context so the middleware short-circuits. +var blockedTeamAllowlist = auth.BlockedTeamAllowlist{ + http.MethodGet: { + "/builds": {}, + "/builds/statuses": {}, + "/builds/:build_id": {}, + "/sandboxes/:sandboxID/record": {}, + "/teams/:teamID/members": {}, + "/teams/resolve": {}, + "/templates/defaults": {}, + }, +} + +// EnforceBlockedTeam returns the gin middleware for the dashboard-api +// service, configured with the dashboard-specific blocked-team allowlist. +func EnforceBlockedTeam() gin.HandlerFunc { + return auth.EnforceBlockedTeam(blockedTeamAllowlist) +} diff --git a/packages/dashboard-api/main.go b/packages/dashboard-api/main.go index a4ad8e1d81..e544a7bab2 100644 --- a/packages/dashboard-api/main.go +++ b/packages/dashboard-api/main.go @@ -28,6 +28,7 @@ import ( "github.com/e2b-dev/infra/packages/dashboard-api/internal/api" "github.com/e2b-dev/infra/packages/dashboard-api/internal/cfg" "github.com/e2b-dev/infra/packages/dashboard-api/internal/handlers" + dashboardmiddleware "github.com/e2b-dev/infra/packages/dashboard-api/internal/middleware" internalteamprovision "github.com/e2b-dev/infra/packages/dashboard-api/internal/teamprovision" sqlcdb "github.com/e2b-dev/infra/packages/db/client" authdb "github.com/e2b-dev/infra/packages/db/pkg/auth" @@ -338,6 +339,8 @@ func newHTTPServer( }), ) + r.Use(dashboardmiddleware.EnforceBlockedTeam()) + api.RegisterHandlers(r, apiStore) s := &http.Server{ diff --git a/packages/orchestrator/README.md b/packages/orchestrator/README.md index d51767b63a..47bd5b72d6 100644 --- a/packages/orchestrator/README.md +++ b/packages/orchestrator/README.md @@ -300,3 +300,7 @@ Automatically set in local mode. Set before running to override: - `ORCHESTRATOR_BASE_PATH` - Base orchestrator data (local: `{storage}/orchestrator`, prod: `/orchestrator`) - `SNAPSHOT_CACHE_DIR` - Snapshot cache, ideally on NVMe (local: `{storage}/snapshot-cache`, prod: `/mnt/snapshot-cache`) - `SANDBOX_DIR` - Sandbox working dir (default: `/fc-vm`) + +## Limitations + +- Custom template builds require Debian/Ubuntu-based base images (images that provide the `apt` package manager). Non-Debian images such as Alpine, CentOS/RHEL, or other distributions without `apt` are not supported and will fail during the template build/provisioning process. The provisioning scripts used during template build call `apt` and expect Debian-specific package names and file locations. \ No newline at end of file diff --git a/packages/orchestrator/pkg/sandbox/block/streaming_chunk.go b/packages/orchestrator/pkg/sandbox/block/streaming_chunk.go index 1cc50ca6c4..a3041f9256 100644 --- a/packages/orchestrator/pkg/sandbox/block/streaming_chunk.go +++ b/packages/orchestrator/pkg/sandbox/block/streaming_chunk.go @@ -246,7 +246,7 @@ func (c *Chunker) progressiveRead(ctx context.Context, s *fetchSession, mmapSlic return 0, fmt.Errorf("failed to open range reader at %d: %w", s.chunkOff, err) } defer func() { - if closeErr := reader.Close(); closeErr != nil && err == nil { + if closeErr := reader.Close(ctx); closeErr != nil && err == nil { err = closeErr } }() diff --git a/packages/orchestrator/pkg/sandbox/block/streaming_chunk_test.go b/packages/orchestrator/pkg/sandbox/block/streaming_chunk_test.go index 2263dc7c4e..ff730fa372 100644 --- a/packages/orchestrator/pkg/sandbox/block/streaming_chunk_test.go +++ b/packages/orchestrator/pkg/sandbox/block/streaming_chunk_test.go @@ -11,6 +11,7 @@ import ( "sync/atomic" "testing" + lz4 "github.com/pierrec/lz4/v4" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel/metric/noop" "golang.org/x/sync/errgroup" @@ -82,7 +83,7 @@ func (s *fakeSeekable) StoreFile(context.Context, string, ...storage.PutOption) panic("not used") } -func (s *fakeSeekable) OpenRangeReader(_ context.Context, offsetU int64, length int64, frameTable *storage.FrameTable) (io.ReadCloser, error) { +func (s *fakeSeekable) OpenRangeReader(_ context.Context, offsetU int64, length int64, frameTable *storage.FrameTable) (storage.RangeReader, error) { s.fetchCount.Add(1) if s.ctrl != nil { @@ -97,13 +98,13 @@ func (s *fakeSeekable) OpenRangeReader(_ context.Context, offsetU int64, length end := min(offsetU+length, int64(len(s.data))) - return &controlledReader{ + return storage.NewRangeReader(&controlledReader{ data: s.data[offsetU:end], step: max(16*1024, testBlockSize), advance: s.ctrl.advance, consumed: s.ctrl.consumed, closed: s.ctrl.closed, - }, nil + }), nil } var fetchOff, fetchLen int64 @@ -127,10 +128,11 @@ func (s *fakeSeekable) OpenRangeReader(_ context.Context, offsetU int64, length r := io.Reader(bytes.NewReader(s.data[fetchOff:end])) if frameTable.IsCompressed() { - return storage.NewDecompressingReader(r, frameTable.CompressionType()) + // makeCompressedTestData only ever produces LZ4 frames. + return storage.NewRangeReader(io.NopCloser(lz4.NewReader(r))), nil } - return io.NopCloser(r), nil + return storage.NewRangeReader(io.NopCloser(r)), nil } func makeCompressedTestData(tb testing.TB, data []byte) (*storage.FrameTable, *fakeSeekable) { @@ -428,13 +430,13 @@ func (s *panicSeekable) StoreFile(context.Context, string, ...storage.PutOption) panic("not used") } -func (s *panicSeekable) OpenRangeReader(_ context.Context, off int64, length int64, _ *storage.FrameTable) (io.ReadCloser, error) { +func (s *panicSeekable) OpenRangeReader(_ context.Context, off int64, length int64, _ *storage.FrameTable) (storage.RangeReader, error) { end := min(off+length, int64(len(s.data))) - return &panicReader{ + return storage.NewRangeReader(&panicReader{ data: s.data[off:end], panicAfter: int(s.panicAfter - off), - }, nil + }), nil } type panicReader struct { diff --git a/packages/orchestrator/pkg/sandbox/template/peerclient/blob.go b/packages/orchestrator/pkg/sandbox/template/peerclient/blob.go index 65891b2f70..c546f1467c 100644 --- a/packages/orchestrator/pkg/sandbox/template/peerclient/blob.go +++ b/packages/orchestrator/pkg/sandbox/template/peerclient/blob.go @@ -65,7 +65,7 @@ func (b *peerBlob) WriteTo(ctx context.Context, dst io.Writer) (int64, error) { } reader := newPeerStreamReader(recv, cancel) - defer reader.Close() + defer reader.Close(ctx) n, err := io.Copy(dst, reader) if err != nil { diff --git a/packages/orchestrator/pkg/sandbox/template/peerclient/seekable.go b/packages/orchestrator/pkg/sandbox/template/peerclient/seekable.go index 4b9230da73..540850205d 100644 --- a/packages/orchestrator/pkg/sandbox/template/peerclient/seekable.go +++ b/packages/orchestrator/pkg/sandbox/template/peerclient/seekable.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "io" "sync" "sync/atomic" "time" @@ -103,9 +102,9 @@ func (s *peerSeekable) Size(ctx context.Context) (int64, error) { return base.Size(ctx) } -func (s *peerSeekable) OpenRangeReader(ctx context.Context, off int64, length int64, frameTable *storage.FrameTable) (io.ReadCloser, error) { +func (s *peerSeekable) OpenRangeReader(ctx context.Context, off int64, length int64, frameTable *storage.FrameTable) (storage.RangeReader, error) { res, err := tryPeer(ctx, &s.peerHandle, "peer-seekable-open-range-reader", attrOpRangeReader, - func(ctx context.Context) (peerAttempt[io.ReadCloser], error) { + func(ctx context.Context) (peerAttempt[storage.RangeReader], error) { streamCtx, cancel := context.WithCancel(ctx) recv, err := openPeerSeekableStream(streamCtx, s.client, &orchestrator.ReadAtBuildSeekableRequest{ @@ -118,10 +117,10 @@ func (s *peerSeekable) OpenRangeReader(ctx context.Context, off int64, length in logger.L().Warn(ctx, "failed to open range reader from peer", logger.WithBuildID(s.buildID), zap.Int64("off", off), zap.Int64("length", length), zap.Error(err)) cancel() - return peerAttempt[io.ReadCloser]{}, nil + return peerAttempt[storage.RangeReader]{}, nil } - return peerAttempt[io.ReadCloser]{ + return peerAttempt[storage.RangeReader]{ value: newPeerStreamReader(recv, cancel), hit: true, }, nil diff --git a/packages/orchestrator/pkg/sandbox/template/peerclient/seekable_test.go b/packages/orchestrator/pkg/sandbox/template/peerclient/seekable_test.go index b5266e9094..e90187d971 100644 --- a/packages/orchestrator/pkg/sandbox/template/peerclient/seekable_test.go +++ b/packages/orchestrator/pkg/sandbox/template/peerclient/seekable_test.go @@ -74,7 +74,7 @@ func TestPeerSeekable_OpenRangeReader_PeerSucceeds(t *testing.T) { s := &peerSeekable{peerHandle: peerHandle{client: client, buildID: "build-1", name: storage.MemfileName, uploaded: &atomic.Bool{}}} rc, err := s.OpenRangeReader(t.Context(), 10, int64(len(data)), nil) require.NoError(t, err) - defer rc.Close() + defer rc.Close(t.Context()) got, err := io.ReadAll(rc) require.NoError(t, err) @@ -89,7 +89,7 @@ func TestPeerSeekable_OpenRangeReader_PeerError_FallsBackToBase(t *testing.T) { client.EXPECT().ReadAtBuildSeekable(mock.Anything, mock.Anything).Return(nil, errors.New("peer unavailable")) baseSeekable := storage.NewMockSeekable(t) - baseSeekable.EXPECT().OpenRangeReader(mock.Anything, int64(0), int64(len(baseData)), (*storage.FrameTable)(nil)).Return(io.NopCloser(bytes.NewReader(baseData)), nil) + baseSeekable.EXPECT().OpenRangeReader(mock.Anything, int64(0), int64(len(baseData)), (*storage.FrameTable)(nil)).Return(storage.NewRangeReader(io.NopCloser(bytes.NewReader(baseData))), nil) base := storage.NewMockStorageProvider(t) base.EXPECT().OpenSeekable(mock.Anything, "build-1/memfile", storage.MemfileObjectType).Return(baseSeekable, nil) @@ -106,7 +106,7 @@ func TestPeerSeekable_OpenRangeReader_PeerError_FallsBackToBase(t *testing.T) { } rc, err := s.OpenRangeReader(t.Context(), 0, int64(len(baseData)), nil) require.NoError(t, err) - defer rc.Close() + defer rc.Close(t.Context()) got, err := io.ReadAll(rc) require.NoError(t, err) @@ -187,7 +187,7 @@ func TestPeerStorageProvider_FullTransitionFlow(t *testing.T) { postBaseSeekable := storage.NewMockSeekable(t) postBaseSeekable.EXPECT(). OpenRangeReader(mock.Anything, int64(0), int64(len(postBaseBytes)), mock.Anything). - Return(io.NopCloser(bytes.NewReader(postBaseBytes)), nil).Once() + Return(storage.NewRangeReader(io.NopCloser(bytes.NewReader(postBaseBytes))), nil).Once() base := storage.NewMockStorageProvider(t) base.EXPECT(). @@ -204,7 +204,7 @@ func TestPeerStorageProvider_FullTransitionFlow(t *testing.T) { require.NoError(t, err) got, err := io.ReadAll(rc) require.NoError(t, err) - require.NoError(t, rc.Close()) + require.NoError(t, rc.Close(t.Context())) assert.Equal(t, prePeerBytes, got) require.True(t, uploaded.Load(), "uploaded flag should be set after peer EOF with UseStorage") @@ -221,6 +221,6 @@ func TestPeerStorageProvider_FullTransitionFlow(t *testing.T) { require.NoError(t, err) got, err = io.ReadAll(rc) require.NoError(t, err) - require.NoError(t, rc.Close()) + require.NoError(t, rc.Close(t.Context())) assert.Equal(t, postBaseBytes, got) } diff --git a/packages/orchestrator/pkg/sandbox/template/peerclient/storage.go b/packages/orchestrator/pkg/sandbox/template/peerclient/storage.go index 9e7b7b6883..c6313149af 100644 --- a/packages/orchestrator/pkg/sandbox/template/peerclient/storage.go +++ b/packages/orchestrator/pkg/sandbox/template/peerclient/storage.go @@ -260,9 +260,9 @@ func tryPeer[T any]( return peerAttempt[T]{}, nil } -var _ io.ReadCloser = (*peerStreamReader)(nil) +var _ storage.RangeReader = (*peerStreamReader)(nil) -// peerStreamReader wraps a gRPC streaming recv function as an io.ReadCloser. +// peerStreamReader wraps a gRPC streaming recv function as a storage.RangeReader. // cancel is called on Close to signal the server to terminate the stream. type peerStreamReader struct { recv func() ([]byte, error) @@ -304,7 +304,7 @@ func (r *peerStreamReader) Read(p []byte) (int, error) { } } -func (r *peerStreamReader) Close() error { +func (r *peerStreamReader) Close(context.Context) error { r.cancel() return nil diff --git a/packages/orchestrator/pkg/server/sandboxes.go b/packages/orchestrator/pkg/server/sandboxes.go index c29eda76c1..c9e69e7fc8 100644 --- a/packages/orchestrator/pkg/server/sandboxes.go +++ b/packages/orchestrator/pkg/server/sandboxes.go @@ -277,7 +277,6 @@ func (s *Server) Update(ctx context.Context, req *orchestrator.SandboxUpdateRequ childSpan.SetAttributes( telemetry.WithSandboxID(req.GetSandboxId()), - attribute.String("client.id", s.info.ClientId), ) sbx, ok := s.sandboxFactory.Sandboxes.Get(req.GetSandboxId()) @@ -287,6 +286,15 @@ func (s *Server) Update(ctx context.Context, req *orchestrator.SandboxUpdateRequ return nil, status.Error(codes.NotFound, "sandbox not found") } + childSpan.SetAttributes( + telemetry.WithTeamID(sbx.Runtime.TeamID), + telemetry.WithTemplateID(sbx.Runtime.TemplateID), + telemetry.WithBuildID(sbx.Runtime.BuildID), + telemetry.WithFirecrackerVersion(sbx.Config.FirecrackerConfig.FirecrackerVersion), + telemetry.WithKernelVersion(sbx.Config.FirecrackerConfig.KernelVersion), + telemetry.WithEnvdVersion(sbx.Config.Envd.Version), + ) + var updates []utils.UpdateFunc if req.GetEndTime() != nil { @@ -402,7 +410,6 @@ func (s *Server) Delete(ctxConn context.Context, in *orchestrator.SandboxDeleteR childSpan.SetAttributes( telemetry.WithSandboxID(in.GetSandboxId()), - attribute.String("client.id", s.info.ClientId), ) sbx, ok := s.sandboxFactory.Sandboxes.Get(in.GetSandboxId()) @@ -412,6 +419,15 @@ func (s *Server) Delete(ctxConn context.Context, in *orchestrator.SandboxDeleteR return nil, status.Errorf(codes.NotFound, "sandbox '%s' not found", in.GetSandboxId()) } + childSpan.SetAttributes( + telemetry.WithTeamID(sbx.Runtime.TeamID), + telemetry.WithTemplateID(sbx.Runtime.TemplateID), + telemetry.WithBuildID(sbx.Runtime.BuildID), + telemetry.WithFirecrackerVersion(sbx.Config.FirecrackerConfig.FirecrackerVersion), + telemetry.WithKernelVersion(sbx.Config.FirecrackerConfig.KernelVersion), + telemetry.WithEnvdVersion(sbx.Config.Envd.Version), + ) + // Mark the sandbox as stopping so it is excluded from live queries (Get, Items, // Count) but remains findable by IP (GetByHostPort) while the Firecracker // process finishes shutting down. @@ -468,6 +484,12 @@ func (s *Server) Pause(ctx context.Context, in *orchestrator.SandboxPauseRequest ctx, childSpan := tracer.Start(ctx, "sandbox-pause") defer childSpan.End() + childSpan.SetAttributes( + telemetry.WithSandboxID(in.GetSandboxId()), + telemetry.WithTemplateID(in.GetTemplateId()), + telemetry.WithBuildID(in.GetBuildId()), + ) + ctx = featureflags.AddToContext( ctx, ldcontext.NewBuilder(in.GetSandboxId()). @@ -483,6 +505,13 @@ func (s *Server) Pause(ctx context.Context, in *orchestrator.SandboxPauseRequest return nil, status.Error(codes.NotFound, "sandbox not found") } + childSpan.SetAttributes( + telemetry.WithTeamID(sbx.Runtime.TeamID), + telemetry.WithFirecrackerVersion(sbx.Config.FirecrackerConfig.FirecrackerVersion), + telemetry.WithKernelVersion(sbx.Config.FirecrackerConfig.KernelVersion), + telemetry.WithEnvdVersion(sbx.Config.Envd.Version), + ) + marked := s.sandboxFactory.Sandboxes.MarkStopping(ctx, sbx.Runtime.SandboxID, sbx.LifecycleID) if !marked { telemetry.ReportCriticalError(ctx, "failed to mark sandbox as stopping", nil, telemetry.WithSandboxID(in.GetSandboxId())) @@ -536,6 +565,11 @@ func (s *Server) Checkpoint(ctx context.Context, in *orchestrator.SandboxCheckpo ctx, childSpan := tracer.Start(ctx, "sandbox-checkpoint") defer childSpan.End() + childSpan.SetAttributes( + telemetry.WithSandboxID(in.GetSandboxId()), + telemetry.WithBuildID(in.GetBuildId()), + ) + ctx = featureflags.AddToContext( ctx, ldcontext.NewBuilder(in.GetSandboxId()). @@ -550,6 +584,14 @@ func (s *Server) Checkpoint(ctx context.Context, in *orchestrator.SandboxCheckpo return nil, status.Errorf(codes.NotFound, "sandbox '%s' not found", in.GetSandboxId()) } + childSpan.SetAttributes( + telemetry.WithTeamID(sbx.Runtime.TeamID), + telemetry.WithTemplateID(sbx.Runtime.TemplateID), + telemetry.WithFirecrackerVersion(sbx.Config.FirecrackerConfig.FirecrackerVersion), + telemetry.WithKernelVersion(sbx.Config.FirecrackerConfig.KernelVersion), + telemetry.WithEnvdVersion(sbx.Config.Envd.Version), + ) + // Check envd version before snapshotting. if err := utils.CheckEnvdVersionForSnapshot(sbx.Config.Envd.Version); err != nil { return nil, status.Errorf(codes.FailedPrecondition, "%s", err.Error()) diff --git a/packages/shared/pkg/storage/compress_decode.go b/packages/shared/pkg/storage/compress_decode.go index b8a6d414a7..c559faa728 100644 --- a/packages/shared/pkg/storage/compress_decode.go +++ b/packages/shared/pkg/storage/compress_decode.go @@ -1,6 +1,7 @@ package storage import ( + "context" "fmt" "io" "sync" @@ -9,6 +10,8 @@ import ( lz4 "github.com/pierrec/lz4/v4" ) +var _ RangeReader = (*decompressReader)(nil) + var lz4DecoderPool sync.Pool func getLZ4Decoder(r io.Reader) *lz4.Reader { @@ -53,78 +56,47 @@ func putZstdDecoder(dec *zstd.Decoder) { zstdDecoderPool.Put(dec) } -// NewDecompressingReader wraps a reader with the appropriate decompressor. -// Close releases the decompressor back to its pool but does NOT close the -// underlying reader — the caller is responsible for closing it. -func NewDecompressingReader(raw io.Reader, ct CompressionType) (io.ReadCloser, error) { +// decompressReader decompresses inner on Read; Close releases the codec back +// to its pool and closes inner. +type decompressReader struct { + inner RangeReader + dec io.Reader + releaseCodec func() +} + +func newDecompressReader(inner RangeReader, ct CompressionType) (*decompressReader, error) { + var dec io.Reader + var releaseCodec func() + switch ct { case CompressionLZ4: - dec := getLZ4Decoder(raw) - - return &pooledDecoder{ - Reader: dec, - close: func() { putLZ4Decoder(dec) }, - }, nil + d := getLZ4Decoder(inner) + dec, releaseCodec = d, func() { putLZ4Decoder(d) } case CompressionZstd: - dec, err := getZstdDecoder(raw) + d, err := getZstdDecoder(inner) if err != nil { return nil, fmt.Errorf("failed to create zstd decoder: %w", err) } - - return &pooledDecoder{ - Reader: dec, - close: func() { putZstdDecoder(dec) }, - }, nil + dec, releaseCodec = d, func() { putZstdDecoder(d) } default: return nil, fmt.Errorf("unsupported compression type: %s", ct) } -} - -// pooledDecoder wraps a decompressor from a sync.Pool. -// Close returns the decompressor to the pool. -type pooledDecoder struct { - io.Reader - close func() + return &decompressReader{ + inner: inner, + dec: dec, + releaseCodec: releaseCodec, + }, nil } -func (r *pooledDecoder) Close() error { - r.close() - - return nil +func (r *decompressReader) Read(p []byte) (int, error) { + return r.dec.Read(p) } -// newDecompressingReadCloser wraps raw with the appropriate decompressor and -// takes ownership: Close releases the decompressor back to the pool AND closes raw. -func newDecompressingReadCloser(raw io.ReadCloser, ct CompressionType) (io.ReadCloser, error) { - dec, err := NewDecompressingReader(raw, ct) - if err != nil { - return nil, err - } - - return &decompressingReadCloser{dec: dec, raw: raw}, nil -} - -// decompressingReadCloser reads from the decompressor and closes both the -// decompressor (returning it to the pool) and the underlying raw stream. -type decompressingReadCloser struct { - dec io.ReadCloser // decompressor — reads from raw - raw io.Closer // underlying stream -} - -func (c *decompressingReadCloser) Read(p []byte) (int, error) { - return c.dec.Read(p) -} - -func (c *decompressingReadCloser) Close() error { - decErr := c.dec.Close() - rawErr := c.raw.Close() - - if decErr != nil { - return decErr - } +func (r *decompressReader) Close(ctx context.Context) error { + r.releaseCodec() - return rawErr + return r.inner.Close(ctx) } diff --git a/packages/shared/pkg/storage/io_wrappers.go b/packages/shared/pkg/storage/io_wrappers.go new file mode 100644 index 0000000000..1e79763b89 --- /dev/null +++ b/packages/shared/pkg/storage/io_wrappers.go @@ -0,0 +1,160 @@ +package storage + +import ( + "bytes" + "context" + "errors" + "io" + "os" + + "go.opentelemetry.io/otel/trace" + + "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" +) + +var ( + _ io.Reader = (*offsetReader)(nil) + _ RangeReader = (*sectionReader)(nil) + _ RangeReader = (*observableReader)(nil) + _ RangeReader = (*rangeReader)(nil) + _ RangeReader = (*captureReader)(nil) +) + +// offsetReader adapts an io.ReaderAt into a sequential io.Reader +// starting at the given offset. +type offsetReader struct { + wrapped io.ReaderAt + offset int64 +} + +func (r *offsetReader) Read(p []byte) (n int, err error) { + n, err = r.wrapped.ReadAt(p, r.offset) + r.offset += int64(n) + + return +} + +func newOffsetReader(reader io.ReaderAt, offset int64) *offsetReader { + return &offsetReader{reader, offset} +} + +// rangeReader adapts an io.ReadCloser into a RangeReader by ignoring the +// Close context. +type rangeReader struct { + io.ReadCloser +} + +func NewRangeReader(rc io.ReadCloser) RangeReader { return &rangeReader{ReadCloser: rc} } + +func (p *rangeReader) Close(context.Context) error { + return p.ReadCloser.Close() +} + +type sectionReader struct { + *io.SectionReader + + file *os.File +} + +func newSectionReader(f *os.File, off, length int64) *sectionReader { + return §ionReader{ + SectionReader: io.NewSectionReader(f, off, length), + file: f, + } +} + +func (r *sectionReader) Close(context.Context) error { + return r.file.Close() +} + +// captureReader tees every read byte into a buffer and hands the captured +// bytes to onClose on Close. Used by the cache writeback paths. +// +// drainOnClose=true reads inner to EOF on Close even if the caller above hasn't +// consumed everything. Needed when capturing under a decoder that stops short +// of EOF on its source (e.g. lz4.Reader skips the 4-byte EndMark). +type captureReader struct { + inner RangeReader + buf *bytes.Buffer + onClose func(ctx context.Context, captured []byte) + drainOnClose bool +} + +func newCaptureReader(inner RangeReader, capHint int, drainOnClose bool, onClose func(context.Context, []byte)) *captureReader { + return &captureReader{ + inner: inner, + buf: bytes.NewBuffer(make([]byte, 0, capHint)), + onClose: onClose, + drainOnClose: drainOnClose, + } +} + +func (r *captureReader) Read(p []byte) (int, error) { + n, err := r.inner.Read(p) + if n > 0 { + r.buf.Write(p[:n]) + } + + return n, err +} + +func (r *captureReader) Close(ctx context.Context) error { + if r.drainOnClose { + _, _ = io.Copy(io.Discard, r) + } + err := r.inner.Close(ctx) + r.onClose(ctx, r.buf.Bytes()) + + return err +} + +// observableReader layers OTEL observability (legacy per-backend timer + span) +// onto an inner RangeReader, all applied on Close. timer and span are optional; +// pass nil if unused. +type observableReader struct { + inner RangeReader + timer *telemetry.Stopwatch + span trace.Span + + bytes int64 + readErr error +} + +func newObservableReader(inner RangeReader, timer *telemetry.Stopwatch, span trace.Span) *observableReader { + return &observableReader{inner: inner, timer: timer, span: span} +} + +func (r *observableReader) Read(p []byte) (int, error) { + n, err := r.inner.Read(p) + r.bytes += int64(n) + + if err != nil && !errors.Is(err, io.EOF) { + r.readErr = err + } + + return n, err +} + +func (r *observableReader) Close(ctx context.Context) error { + closeErr := r.inner.Close(ctx) + + if r.timer != nil { + if r.readErr != nil || closeErr != nil { + r.timer.Failure(ctx, r.bytes) + } else { + r.timer.Success(ctx, r.bytes) + } + } + + if r.span != nil { + if closeErr != nil { + recordError(r.span, closeErr) + } else if r.readErr != nil { + recordError(r.span, r.readErr) + } + + r.span.End() + } + + return closeErr +} diff --git a/packages/shared/pkg/storage/offset_reader_test.go b/packages/shared/pkg/storage/io_wrappers_test.go similarity index 100% rename from packages/shared/pkg/storage/offset_reader_test.go rename to packages/shared/pkg/storage/io_wrappers_test.go diff --git a/packages/shared/pkg/storage/mock_seekable.go b/packages/shared/pkg/storage/mock_seekable.go index 440836bc54..e33c5d8141 100644 --- a/packages/shared/pkg/storage/mock_seekable.go +++ b/packages/shared/pkg/storage/mock_seekable.go @@ -6,7 +6,6 @@ package storage import ( "context" - "io" mock "github.com/stretchr/testify/mock" ) @@ -39,23 +38,23 @@ func (_m *MockSeekable) EXPECT() *MockSeekable_Expecter { } // OpenRangeReader provides a mock function for the type MockSeekable -func (_mock *MockSeekable) OpenRangeReader(ctx context.Context, offsetU int64, length int64, frameTable *FrameTable) (io.ReadCloser, error) { +func (_mock *MockSeekable) OpenRangeReader(ctx context.Context, offsetU int64, length int64, frameTable *FrameTable) (RangeReader, error) { ret := _mock.Called(ctx, offsetU, length, frameTable) if len(ret) == 0 { panic("no return value specified for OpenRangeReader") } - var r0 io.ReadCloser + var r0 RangeReader var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, int64, int64, *FrameTable) (io.ReadCloser, error)); ok { + if returnFunc, ok := ret.Get(0).(func(context.Context, int64, int64, *FrameTable) (RangeReader, error)); ok { return returnFunc(ctx, offsetU, length, frameTable) } - if returnFunc, ok := ret.Get(0).(func(context.Context, int64, int64, *FrameTable) io.ReadCloser); ok { + if returnFunc, ok := ret.Get(0).(func(context.Context, int64, int64, *FrameTable) RangeReader); ok { r0 = returnFunc(ctx, offsetU, length, frameTable) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).(io.ReadCloser) + r0 = ret.Get(0).(RangeReader) } } if returnFunc, ok := ret.Get(1).(func(context.Context, int64, int64, *FrameTable) error); ok { @@ -108,12 +107,12 @@ func (_c *MockSeekable_OpenRangeReader_Call) Run(run func(ctx context.Context, o return _c } -func (_c *MockSeekable_OpenRangeReader_Call) Return(readCloser io.ReadCloser, err error) *MockSeekable_OpenRangeReader_Call { - _c.Call.Return(readCloser, err) +func (_c *MockSeekable_OpenRangeReader_Call) Return(rangeReader RangeReader, err error) *MockSeekable_OpenRangeReader_Call { + _c.Call.Return(rangeReader, err) return _c } -func (_c *MockSeekable_OpenRangeReader_Call) RunAndReturn(run func(ctx context.Context, offsetU int64, length int64, frameTable *FrameTable) (io.ReadCloser, error)) *MockSeekable_OpenRangeReader_Call { +func (_c *MockSeekable_OpenRangeReader_Call) RunAndReturn(run func(ctx context.Context, offsetU int64, length int64, frameTable *FrameTable) (RangeReader, error)) *MockSeekable_OpenRangeReader_Call { _c.Call.Return(run) return _c } diff --git a/packages/shared/pkg/storage/offset_reader.go b/packages/shared/pkg/storage/offset_reader.go deleted file mode 100644 index 29d9048d6c..0000000000 --- a/packages/shared/pkg/storage/offset_reader.go +++ /dev/null @@ -1,23 +0,0 @@ -package storage - -import ( - "io" -) - -type offsetReader struct { - wrapped io.ReaderAt - offset int64 -} - -var _ io.Reader = (*offsetReader)(nil) - -func (r *offsetReader) Read(p []byte) (n int, err error) { - n, err = r.wrapped.ReadAt(p, r.offset) - r.offset += int64(n) - - return -} - -func newOffsetReader(reader io.ReaderAt, offset int64) *offsetReader { - return &offsetReader{reader, offset} -} diff --git a/packages/shared/pkg/storage/storage.go b/packages/shared/pkg/storage/storage.go index 03d31ffab0..2ec1607463 100644 --- a/packages/shared/pkg/storage/storage.go +++ b/packages/shared/pkg/storage/storage.go @@ -143,9 +143,14 @@ type SeekableReader interface { Size(ctx context.Context) (int64, error) } +type RangeReader interface { + io.Reader + Close(ctx context.Context) error +} + // StreamingReader supports progressive reads via a streaming range reader. type StreamingReader interface { - OpenRangeReader(ctx context.Context, offsetU int64, length int64, frameTable *FrameTable) (io.ReadCloser, error) + OpenRangeReader(ctx context.Context, offsetU int64, length int64, frameTable *FrameTable) (RangeReader, error) } type SeekableWriter interface { diff --git a/packages/shared/pkg/storage/storage_aws.go b/packages/shared/pkg/storage/storage_aws.go index 5e62a784b9..8429d33e61 100644 --- a/packages/shared/pkg/storage/storage_aws.go +++ b/packages/shared/pkg/storage/storage_aws.go @@ -233,7 +233,7 @@ func (o *awsObject) Put(ctx context.Context, data []byte, opts ...PutOption) err return nil } -func (o *awsObject) OpenRangeReader(ctx context.Context, off, length int64, frameTable *FrameTable) (io.ReadCloser, error) { +func (o *awsObject) OpenRangeReader(ctx context.Context, off, length int64, frameTable *FrameTable) (RangeReader, error) { if frameTable.IsCompressed() { return nil, errors.New("compressed reads are not supported on AWS") } @@ -253,7 +253,7 @@ func (o *awsObject) OpenRangeReader(ctx context.Context, off, length int64, fram return nil, fmt.Errorf("failed to create S3 range reader for %q: %w", o.path, err) } - return resp.Body, nil + return NewRangeReader(resp.Body), nil } func (o *awsObject) Size(ctx context.Context) (int64, error) { diff --git a/packages/shared/pkg/storage/storage_cache.go b/packages/shared/pkg/storage/storage_cache.go index 9f93559662..7d5838758c 100644 --- a/packages/shared/pkg/storage/storage_cache.go +++ b/packages/shared/pkg/storage/storage_cache.go @@ -151,6 +151,10 @@ func ignoreEOF(err error) error { // isCompleteRead reports whether a read of n bytes into a buffer of expected // size represents a valid, cacheable result. A read is complete when either // the full buffer was filled or io.EOF explains a non-empty short read (last chunk). +// +// Writeback callers pass err=nil: a streaming reader always ends in io.EOF +// regardless of whether the upstream was truncated, so the byte count is the +// only reliable signal that the captured bytes are safe to cache. func isCompleteRead(n, expected int, err error) bool { return n == expected || (n > 0 && errors.Is(err, io.EOF)) } diff --git a/packages/shared/pkg/storage/storage_cache_compressed_test.go b/packages/shared/pkg/storage/storage_cache_compressed_test.go index 8314abb0a1..0aa5771f65 100644 --- a/packages/shared/pkg/storage/storage_cache_compressed_test.go +++ b/packages/shared/pkg/storage/storage_cache_compressed_test.go @@ -8,6 +8,7 @@ import ( lz4 "github.com/pierrec/lz4/v4" "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/trace" ) // lz4Compress is a test helper that LZ4-compresses src. @@ -68,19 +69,16 @@ func TestDecompressingCacheReader(t *testing.T) { c := newTestCache(t) framePath := makeFrameFilename(c.path, Range{Offset: 0, Length: len(compressed)}) - rc, err := newDecompressingCacheReader( - io.NopCloser(bytes.NewReader(compressed)), - CompressionLZ4, - len(compressed), - &c, t.Context(), framePath, 0, - ) + capturing := newCaptureReader(bytesRangeReader(compressed), len(compressed), true, + c.compressedFrameWriteback(framePath, 0, len(compressed), trace.SpanContext{})) + rc, err := newDecompressReader(capturing, CompressionLZ4) require.NoError(t, err) got, err := io.ReadAll(rc) require.NoError(t, err) require.Equal(t, original, got) - require.NoError(t, rc.Close()) + mustClose(t, rc) c.wg.Wait() cached, err := os.ReadFile(framePath) @@ -104,12 +102,9 @@ func TestDecompressingCacheReader(t *testing.T) { compressedProd := lz4CompressProd(t, original) framePath := makeFrameFilename(c.path, Range{Offset: 0, Length: len(compressedProd)}) - rc, err := newDecompressingCacheReader( - io.NopCloser(bytes.NewReader(compressedProd)), - CompressionLZ4, - len(compressedProd), - &c, t.Context(), framePath, 0, - ) + capturing := newCaptureReader(bytesRangeReader(compressedProd), len(compressedProd), true, + c.compressedFrameWriteback(framePath, 0, len(compressedProd), trace.SpanContext{})) + rc, err := newDecompressReader(capturing, CompressionLZ4) require.NoError(t, err) out := make([]byte, len(original)) @@ -118,7 +113,8 @@ func TestDecompressingCacheReader(t *testing.T) { require.Equal(t, len(original), n) require.Equal(t, original, out) - require.NoError(t, rc.Close(), "writeback failure must not surface as a read error") + closeErr := rc.Close(t.Context()) + require.NoError(t, closeErr, "writeback failure must not surface as a read error") c.wg.Wait() _, err = os.Stat(framePath) @@ -131,19 +127,17 @@ func TestDecompressingCacheReader(t *testing.T) { c := newTestCache(t) framePath := makeFrameFilename(c.path, Range{Offset: 0, Length: len(compressed)}) - rc, err := newDecompressingCacheReader( - io.NopCloser(bytes.NewReader(compressed)), - CompressionLZ4, - len(compressed)+100, // wrong size - &c, t.Context(), framePath, 0, - ) + capturing := newCaptureReader(bytesRangeReader(compressed), len(compressed)+100, true, + c.compressedFrameWriteback(framePath, 0, len(compressed)+100, trace.SpanContext{})) // wrong expected size + rc, err := newDecompressReader(capturing, CompressionLZ4) require.NoError(t, err) got, err := io.ReadAll(rc) require.NoError(t, err) require.Equal(t, original, got, "decompressed data should be correct regardless") - require.NoError(t, rc.Close(), "writeback failure must not surface as a read error") + closeErr := rc.Close(t.Context()) + require.NoError(t, closeErr, "writeback failure must not surface as a read error") c.wg.Wait() diff --git a/packages/shared/pkg/storage/storage_cache_seekable.go b/packages/shared/pkg/storage/storage_cache_seekable.go index 91e25be9f6..5b542120bd 100644 --- a/packages/shared/pkg/storage/storage_cache_seekable.go +++ b/packages/shared/pkg/storage/storage_cache_seekable.go @@ -1,7 +1,6 @@ package storage import ( - "bytes" "context" "errors" "fmt" @@ -14,7 +13,6 @@ import ( "github.com/google/uuid" "github.com/launchdarkly/go-sdk-common/v3/ldcontext" "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/metric" "go.opentelemetry.io/otel/trace" "go.uber.org/zap" @@ -53,11 +51,6 @@ var ( "Total bytes written to NFS", "Total writes to NFS", )) - nfsCacheConcurrentReads = utils.Must(meter.Int64UpDownCounter( - "orchestrator.storage.slab.nfs.read.concurrent", - metric.WithDescription("Number of NFS cache range readers currently open"), - metric.WithUnit("{read}"), - )) ) type featureFlagsClient interface { @@ -80,7 +73,7 @@ var ( _ StreamingReader = (*cachedSeekable)(nil) ) -func (c *cachedSeekable) OpenRangeReader(ctx context.Context, off int64, length int64, frameTable *FrameTable) (io.ReadCloser, error) { +func (c *cachedSeekable) OpenRangeReader(ctx context.Context, off int64, length int64, frameTable *FrameTable) (RangeReader, error) { compressed := frameTable.IsCompressed() ctx, span := c.tracer.Start(ctx, "read", trace.WithAttributes( @@ -89,27 +82,25 @@ func (c *cachedSeekable) OpenRangeReader(ctx context.Context, off int64, length attribute.Bool("compressed", compressed), )) + var rc RangeReader + var err error if compressed { - rc, err := c.openReaderCompressed(ctx, off, frameTable) - if err != nil { - recordError(span, err) - span.End() - - return nil, err - } - - rc = withSpan(rc, span) - - return rc, nil + rc, err = c.openReaderCompressed(ctx, off, frameTable) + } else if err = c.validateReadParams(length, off); err == nil { + rc, err = c.openReaderUncompressed(ctx, off, length) } - if err := c.validateReadParams(length, off); err != nil { + if err != nil { recordError(span, err) span.End() return nil, err } + return newObservableReader(rc, nil, span), nil +} + +func (c *cachedSeekable) openReaderUncompressed(ctx context.Context, off, length int64) (RangeReader, error) { timer := cacheSlabReadTimerFactory.Begin( attribute.String(nfsCacheOperationAttr, nfsCacheOperationAttrReadAt), attribute.Bool("compressed", false), @@ -122,10 +113,7 @@ func (c *cachedSeekable) OpenRangeReader(ctx context.Context, off int64, length recordCacheRead(ctx, true, length, cacheTypeSeekable, cacheOpOpenRangeReader) timer.Success(ctx, length) - rc := io.ReadCloser(&fsRangeReadCloser{Reader: io.NewSectionReader(fp, 0, length), file: fp}) - rc = withSpan(rc, span) - - return withNFSGauge(ctx, rc), nil + return newSectionReader(fp, 0, length), nil } if !os.IsNotExist(err) { @@ -136,122 +124,40 @@ func (c *cachedSeekable) OpenRangeReader(ctx context.Context, off int64, length rc, err := c.inner.OpenRangeReader(ctx, off, length, nil) if err != nil { - recordError(span, err) - span.End() - return nil, fmt.Errorf("failed to open inner range reader: %w", err) } recordCacheRead(ctx, false, length, cacheTypeSeekable, cacheOpOpenRangeReader) if !skipCacheWriteback(ctx) { - rc = newCacheWriteThroughReader(rc, c, ctx, off, length, chunkPath) + rc = newCaptureReader(rc, int(length), false, + c.uncompressedChunkWriteback(chunkPath, off, length, trace.SpanContextFromContext(ctx))) } - rc = withSpan(rc, span) - return rc, nil } -// withSpan wraps a reader with an OTEL span that ends on Close. -func withSpan(rc io.ReadCloser, span trace.Span) io.ReadCloser { - return &spanReadCloser{inner: rc, span: span} -} - -type spanReadCloser struct { - inner io.ReadCloser - span trace.Span -} - -func (r *spanReadCloser) Read(p []byte) (int, error) { - return r.inner.Read(p) -} - -func (r *spanReadCloser) Close() error { - err := r.inner.Close() - recordError(r.span, err) - r.span.End() - - return err -} - -// nfsGaugeReadCloser wraps a reader and decrements the NFS concurrent reads -// gauge on Close. -type nfsGaugeReadCloser struct { - io.ReadCloser - - ctx context.Context //nolint:containedctx // needed for gauge decrement in Close -} - -func (r *nfsGaugeReadCloser) Close() error { - nfsCacheConcurrentReads.Add(r.ctx, -1) - - return r.ReadCloser.Close() -} - -func withNFSGauge(ctx context.Context, rc io.ReadCloser) io.ReadCloser { - nfsCacheConcurrentReads.Add(ctx, 1) - - return &nfsGaugeReadCloser{ReadCloser: rc, ctx: ctx} -} - -// newCacheWriteThroughReader wraps a reader, buffering all data read through it. -// On Close, it asynchronously writes the buffered data to the NFS cache only -// if the total bytes read match the expected length (to avoid caching truncated data). -func newCacheWriteThroughReader(inner io.ReadCloser, cache *cachedSeekable, ctx context.Context, off, expectedLen int64, chunkPath string) io.ReadCloser { - return &cacheWriteThroughReader{ - inner: inner, - buf: bytes.NewBuffer(make([]byte, 0, expectedLen)), - cache: cache, - ctx: ctx, - off: off, - expectedLen: expectedLen, - chunkPath: chunkPath, - } -} - -type cacheWriteThroughReader struct { - inner io.ReadCloser - buf *bytes.Buffer - cache *cachedSeekable - ctx context.Context //nolint:containedctx // needed for async cache write-back in Close - off int64 - expectedLen int64 - chunkPath string -} - -func (r *cacheWriteThroughReader) Read(p []byte) (int, error) { - n, err := r.inner.Read(p) - if n > 0 { - r.buf.Write(p[:n]) - } - - return n, err -} - -func (r *cacheWriteThroughReader) Close() error { - closeErr := r.inner.Close() - - // Only cache when the total bytes read match the expected length. - // Unlike ReadAt where io.EOF can justify a short read (last chunk), - // a streaming reader always ends with EOF regardless of whether the - // data was truncated, so the byte count is the only reliable check. - if isCompleteRead(r.buf.Len(), int(r.expectedLen), nil) { - data := make([]byte, r.buf.Len()) - copy(data, r.buf.Bytes()) +// uncompressedChunkWriteback returns a captureReader callback that persists +// the captured chunk to the NFS cache in a detached goroutine. Best-effort: +// a short capture (e.g. upstream truncation) is dropped silently — a streaming +// reader always ends in EOF, so byte count is the only reliable signal. +func (c *cachedSeekable) uncompressedChunkWriteback(chunkPath string, off, expectedLen int64, fetchSpan trace.SpanContext) func(context.Context, []byte) { + return func(ctx context.Context, captured []byte) { + if !isCompleteRead(len(captured), int(expectedLen), nil) { + return + } - r.cache.goCtx(r.ctx, func(ctx context.Context) { - ctx, span := r.cache.tracer.Start(ctx, "write range reader chunk back to cache") + c.goCtx(ctx, func(ctx context.Context) { + ctx, span := c.tracer.Start(ctx, "write range reader chunk back to cache", writebackSpanOpts(fetchSpan)...) defer span.End() - if err := r.cache.writeToCache(ctx, r.off, r.chunkPath, data); err != nil { + err := c.writeToCache(ctx, off, chunkPath, captured) + if err != nil { recordError(span, err) recordCacheWriteError(ctx, cacheTypeSeekable, cacheOpOpenRangeReader, err) } }) } - - return closeErr } func (c *cachedSeekable) Size(ctx context.Context) (n int64, e error) { @@ -336,6 +242,19 @@ func (c *cachedSeekable) StoreFile(ctx context.Context, path string, opts ...Put return c.inner.StoreFile(ctx, path, opts...) } +// writebackSpanOpts roots the writeback span and links it to the fetch span; +// writeback outlives the fetch, so a child span would extend an ended span. +func writebackSpanOpts(fetch trace.SpanContext) []trace.SpanStartOption { + if !fetch.IsValid() { + return nil + } + + return []trace.SpanStartOption{ + trace.WithNewRoot(), + trace.WithLinks(trace.Link{SpanContext: fetch}), + } +} + func (c *cachedSeekable) goCtx(ctx context.Context, fn func(context.Context)) { c.wg.Go(func() { fn(context.WithoutCancel(ctx)) diff --git a/packages/shared/pkg/storage/storage_cache_seekable_compressed.go b/packages/shared/pkg/storage/storage_cache_seekable_compressed.go index 4886b449d4..0c30ad00c2 100644 --- a/packages/shared/pkg/storage/storage_cache_seekable_compressed.go +++ b/packages/shared/pkg/storage/storage_cache_seekable_compressed.go @@ -1,13 +1,12 @@ package storage import ( - "bytes" "context" "fmt" - "io" "os" "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" ) // Precomputed OTEL attributes for compressed cache reads (avoids per-read allocation). @@ -19,13 +18,14 @@ var compressedCacheReadAttrs = []attribute.KeyValue{ // openReaderCompressed handles the compressed cache path for OpenRangeReader. // NFS stores compressed frames (.frm); on hit we decompress, on miss we fetch // raw compressed bytes and tee them to NFS on Close. -func (c *cachedSeekable) openReaderCompressed(ctx context.Context, offsetU int64, frameTable *FrameTable) (io.ReadCloser, error) { +func (c *cachedSeekable) openReaderCompressed(ctx context.Context, offsetU int64, frameTable *FrameTable) (RangeReader, error) { r, err := frameTable.LocateCompressed(offsetU) if err != nil { return nil, fmt.Errorf("frame lookup for offset %d: %w", offsetU, err) } path := makeFrameFilename(c.path, r) + ct := frameTable.CompressionType() timer := cacheSlabReadTimerFactory.Begin(compressedCacheReadAttrs...) @@ -37,14 +37,14 @@ func (c *cachedSeekable) openReaderCompressed(ctx context.Context, offsetU int64 recordCacheRead(ctx, true, int64(r.Length), cacheTypeSeekable, cacheOpOpenRangeReader) timer.Success(ctx, int64(r.Length)) - decompressed, err := newDecompressingReadCloser(f, frameTable.CompressionType()) + dec, err := newDecompressReader(NewRangeReader(f), ct) if err != nil { f.Close() return nil, fmt.Errorf("decompress cached frame: %w", err) } - return withNFSGauge(ctx, decompressed), nil + return dec, nil case statErr == nil: // Confirmed size mismatch: drop the file so the miss path rewrites it. f.Close() @@ -70,111 +70,46 @@ func (c *cachedSeekable) openReaderCompressed(ctx context.Context, offsetU int64 recordCacheRead(ctx, false, int64(r.Length), cacheTypeSeekable, cacheOpOpenRangeReader) - rc, err := newDecompressingCacheReader(raw, frameTable.CompressionType(), r.Length, c, ctx, path, offsetU) - if err != nil { - raw.Close() - - return nil, fmt.Errorf("create decompressor: %w", err) + in := raw + if !skipCacheWriteback(ctx) { + in = newCaptureReader(raw, r.Length, true, + c.compressedFrameWriteback(path, offsetU, r.Length, trace.SpanContextFromContext(ctx))) } - return rc, nil -} - -// newDecompressingCacheReader creates a reader that decompresses on Read and -// writes the accumulated compressed bytes to the NFS cache on Close. -func newDecompressingCacheReader( - raw io.ReadCloser, - ct CompressionType, - expectedSize int, - cache *cachedSeekable, - ctx context.Context, //nolint:revive // ctx after other params for readability at call site - framePath string, - offset int64, -) (io.ReadCloser, error) { - var compressedBuf bytes.Buffer - compressedBuf.Grow(expectedSize) - - tee := io.TeeReader(raw, &compressedBuf) - - dec, err := NewDecompressingReader(tee, ct) + dec, err := newDecompressReader(in, ct) if err != nil { - return nil, err - } + in.Close(ctx) - return &decompressingCacheReader{ - decompressor: dec, - raw: raw, - compressedBuf: &compressedBuf, - expectedSize: expectedSize, - cache: cache, - ctx: ctx, - framePath: framePath, - offset: offset, - }, nil -} - -type decompressingCacheReader struct { - decompressor io.ReadCloser // decompresses on Read - raw io.ReadCloser // underlying compressed stream (must be closed) - compressedBuf *bytes.Buffer - expectedSize int - cache *cachedSeekable - ctx context.Context //nolint:containedctx // needed for async cache write-back in Close - framePath string - offset int64 -} + return nil, fmt.Errorf("create decompressor: %w", err) + } -func (r *decompressingCacheReader) Read(p []byte) (int, error) { - return r.decompressor.Read(p) + return dec, nil } -func (r *decompressingCacheReader) Close() error { - // Drive the decompressor to EOF before closing it. With io.ReadFull bounded - // by the uncompressed size, an LZ4 frame written with BlockChecksum=true / - // Checksum=false leaves the 4-byte EndMark unread — the next Read on the - // decoder pulls the EndMark (block-size = 0 → io.EOF) from raw through the - // tee, populating compressedBuf with the full encoded frame for cache writeback. - _, _ = io.Copy(io.Discard, r.decompressor) - - decErr := r.decompressor.Close() - rawErr := r.raw.Close() - - if decErr != nil { - return decErr - } - if rawErr != nil { - return rawErr - } - - got := r.compressedBuf.Len() - if skipCacheWriteback(r.ctx) { - return nil - } +// compressedFrameWriteback returns a captureReader callback that +// persists the captured frame to the NFS cache in a detached goroutine. +// Best-effort: a short capture is logged and skipped — the caller already +// got valid decompressed bytes. +func (c *cachedSeekable) compressedFrameWriteback(framePath string, offset int64, expectedSize int, fetchSpan trace.SpanContext) func(context.Context, []byte) { + return func(ctx context.Context, frame []byte) { + if !isCompleteRead(len(frame), expectedSize, nil) { + recordCacheWriteError(ctx, cacheTypeSeekable, cacheOpOpenRangeReader, + fmt.Errorf("compressed frame cache writeback short: got %d bytes, expected %d for %s", len(frame), expectedSize, framePath)) + + return + } - // Cache writeback is best-effort. After draining above, a remaining shortfall - // implies upstream truncation — log/metric and skip writeback rather than - // poison the read (the caller already received valid decompressed bytes). - if !isCompleteRead(got, r.expectedSize, nil) { - recordCacheWriteError(r.ctx, cacheTypeSeekable, cacheOpOpenRangeReader, - fmt.Errorf("compressed frame cache writeback short: got %d bytes, expected %d for %s", got, r.expectedSize, r.framePath)) + c.goCtx(ctx, func(ctx context.Context) { + ctx, span := c.tracer.Start(ctx, "write compressed frame back to cache", writebackSpanOpts(fetchSpan)...) + defer span.End() - return nil + err := c.writeToCache(ctx, offset, framePath, frame) + if err != nil { + recordError(span, err) + recordCacheWriteError(ctx, cacheTypeSeekable, cacheOpOpenRangeReader, err) + } + }) } - - data := r.compressedBuf.Bytes() - r.compressedBuf = nil - - r.cache.goCtx(r.ctx, func(ctx context.Context) { - ctx, span := r.cache.tracer.Start(ctx, "write compressed frame back to cache") - defer span.End() - - if err := r.cache.writeToCache(ctx, r.offset, r.framePath, data); err != nil { - recordError(span, err) - recordCacheWriteError(ctx, cacheTypeSeekable, cacheOpOpenRangeReader, err) - } - }) - - return nil } // makeFrameFilename returns the NFS cache path for a compressed frame. diff --git a/packages/shared/pkg/storage/storage_cache_seekable_test.go b/packages/shared/pkg/storage/storage_cache_seekable_test.go index e0ea301f70..50c584bd88 100644 --- a/packages/shared/pkg/storage/storage_cache_seekable_test.go +++ b/packages/shared/pkg/storage/storage_cache_seekable_test.go @@ -12,8 +12,20 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/trace" ) +// mustClose closes a RangeReader and asserts no error. +func mustClose(t *testing.T, rc RangeReader) { + t.Helper() + require.NoError(t, rc.Close(t.Context())) +} + +// bytesRangeReader wraps an in-memory byte slice as a RangeReader for tests. +func bytesRangeReader(b []byte) RangeReader { + return NewRangeReader(io.NopCloser(bytes.NewReader(b))) +} + // testReadAt emulates the removed cachedSeekable.ReadAt via OpenRangeReader. // This preserves the base test structure after ReadAt was removed from the Seekable interface. func testReadAt(ctx context.Context, c *cachedSeekable, buff []byte, off int64) (int, error) { @@ -24,7 +36,7 @@ func testReadAt(ctx context.Context, c *cachedSeekable, buff []byte, off int64) n, err := io.ReadFull(rc, buff) - closeErr := rc.Close() + closeErr := rc.Close(ctx) if errors.Is(err, io.ErrUnexpectedEOF) { err = io.EOF } @@ -181,10 +193,10 @@ func TestCachedFileObjectProvider_WriteTo(t *testing.T) { inner.EXPECT(). OpenRangeReader(mock.Anything, mock.Anything, mock.Anything, (*FrameTable)(nil)). - RunAndReturn(func(_ context.Context, off int64, length int64, _ *FrameTable) (io.ReadCloser, error) { + RunAndReturn(func(_ context.Context, off int64, length int64, _ *FrameTable) (RangeReader, error) { end := min(int(off)+int(length), len(fakeData)) - return io.NopCloser(bytes.NewReader(fakeData[off:end])), nil + return NewRangeReader(io.NopCloser(bytes.NewReader(fakeData[off:end]))), nil }) tempDir := t.TempDir() @@ -316,7 +328,7 @@ func TestCachedSeekableObjectProvider_ReadAt(t *testing.T) { inner := NewMockSeekable(t) inner.EXPECT(). OpenRangeReader(mock.Anything, mock.Anything, mock.Anything, (*FrameTable)(nil)). - Return(io.NopCloser(bytes.NewReader(nil)), nil) + Return(NewRangeReader(io.NopCloser(bytes.NewReader(nil))), nil) c := cachedSeekable{ path: tempDir, @@ -345,7 +357,7 @@ func TestCachedSeekableObjectProvider_ReadAt(t *testing.T) { inner := NewMockSeekable(t) inner.EXPECT(). OpenRangeReader(mock.Anything, mock.Anything, mock.Anything, (*FrameTable)(nil)). - Return(io.NopCloser(bytes.NewReader(data)), nil) + Return(NewRangeReader(io.NopCloser(bytes.NewReader(data))), nil) c := cachedSeekable{ path: tempDir, @@ -407,7 +419,7 @@ func TestCachedSeekable_ReadAt_PreservesEOF(t *testing.T) { inner := NewMockSeekable(t) inner.EXPECT(). OpenRangeReader(mock.Anything, mock.Anything, mock.Anything, (*FrameTable)(nil)). - Return(io.NopCloser(bytes.NewReader([]byte{1, 2, 3})), nil) + Return(NewRangeReader(io.NopCloser(bytes.NewReader([]byte{1, 2, 3}))), nil) c := cachedSeekable{ path: tempDir, @@ -431,7 +443,7 @@ func TestCachedSeekable_ReadAt_PreservesEOF(t *testing.T) { inner := NewMockSeekable(t) inner.EXPECT(). OpenRangeReader(mock.Anything, mock.Anything, mock.Anything, (*FrameTable)(nil)). - Return(io.NopCloser(bytes.NewReader([]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10})), nil) + Return(NewRangeReader(io.NopCloser(bytes.NewReader([]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}))), nil) c := cachedSeekable{ path: tempDir, @@ -457,8 +469,8 @@ func TestCachedSeekable_ReadAt_SkipCacheWriteback(t *testing.T) { inner := NewMockSeekable(t) inner.EXPECT(). OpenRangeReader(mock.Anything, mock.Anything, mock.Anything, (*FrameTable)(nil)). - RunAndReturn(func(_ context.Context, _ int64, _ int64, _ *FrameTable) (io.ReadCloser, error) { - return io.NopCloser(bytes.NewReader(data)), nil + RunAndReturn(func(_ context.Context, _ int64, _ int64, _ *FrameTable) (RangeReader, error) { + return NewRangeReader(io.NopCloser(bytes.NewReader(data))), nil }) c := cachedSeekable{ @@ -493,7 +505,7 @@ func TestCachedSeekable_OpenRangeReader(t *testing.T) { inner := NewMockSeekable(t) inner.EXPECT(). OpenRangeReader(mock.Anything, int64(0), int64(len(data)), (*FrameTable)(nil)). - Return(io.NopCloser(bytes.NewReader(data)), nil). + Return(NewRangeReader(io.NopCloser(bytes.NewReader(data))), nil). Once() c := cachedSeekable{ @@ -510,7 +522,7 @@ func TestCachedSeekable_OpenRangeReader(t *testing.T) { got, err := io.ReadAll(rc) require.NoError(t, err) assert.Equal(t, data, got) - require.NoError(t, rc.Close()) + require.NoError(t, rc.Close(t.Context())) c.wg.Wait() @@ -522,7 +534,7 @@ func TestCachedSeekable_OpenRangeReader(t *testing.T) { got2, err := io.ReadAll(rc2) require.NoError(t, err) assert.Equal(t, data, got2) - require.NoError(t, rc2.Close()) + require.NoError(t, rc2.Close(t.Context())) }) t.Run("skip cache writeback returns inner directly", func(t *testing.T) { @@ -534,8 +546,8 @@ func TestCachedSeekable_OpenRangeReader(t *testing.T) { inner := NewMockSeekable(t) inner.EXPECT(). OpenRangeReader(mock.Anything, int64(0), int64(len(data)), (*FrameTable)(nil)). - RunAndReturn(func(_ context.Context, _ int64, _ int64, _ *FrameTable) (io.ReadCloser, error) { - return io.NopCloser(bytes.NewReader(data)), nil + RunAndReturn(func(_ context.Context, _ int64, _ int64, _ *FrameTable) (RangeReader, error) { + return NewRangeReader(io.NopCloser(bytes.NewReader(data))), nil }). Times(2) @@ -554,7 +566,7 @@ func TestCachedSeekable_OpenRangeReader(t *testing.T) { got, err := io.ReadAll(rc) require.NoError(t, err) assert.Equal(t, data, got) - require.NoError(t, rc.Close()) + require.NoError(t, rc.Close(ctx)) c.wg.Wait() @@ -569,7 +581,7 @@ func TestCachedSeekable_OpenRangeReader(t *testing.T) { got2, err := io.ReadAll(rc2) require.NoError(t, err) assert.Equal(t, data, got2) - require.NoError(t, rc2.Close()) + require.NoError(t, rc2.Close(ctx)) }) t.Run("truncated inner read does not populate cache", func(t *testing.T) { @@ -580,7 +592,7 @@ func TestCachedSeekable_OpenRangeReader(t *testing.T) { inner := NewMockSeekable(t) inner.EXPECT(). OpenRangeReader(mock.Anything, int64(0), int64(5), (*FrameTable)(nil)). - Return(io.NopCloser(bytes.NewReader([]byte{0xAA, 0xBB})), nil) + Return(NewRangeReader(io.NopCloser(bytes.NewReader([]byte{0xAA, 0xBB}))), nil) c := cachedSeekable{ path: tempDir, @@ -595,7 +607,7 @@ func TestCachedSeekable_OpenRangeReader(t *testing.T) { got, err := io.ReadAll(rc) require.NoError(t, err) assert.Equal(t, []byte{0xAA, 0xBB}, got) - require.NoError(t, rc.Close()) + require.NoError(t, rc.Close(t.Context())) c.wg.Wait() @@ -623,23 +635,16 @@ func TestCacheWriteThroughReader(t *testing.T) { c := newTestCache(t) data := []byte("hello") - inner := io.NopCloser(bytes.NewReader(data)) - - r := &cacheWriteThroughReader{ - inner: inner, - buf: bytes.NewBuffer(make([]byte, 0, len(data))), - cache: &c, - ctx: t.Context(), - off: 0, - expectedLen: int64(len(data)), - chunkPath: c.makeChunkFilename(0), - } + inner := NewRangeReader(io.NopCloser(bytes.NewReader(data))) + + r := newCaptureReader(inner, len(data), false, + c.uncompressedChunkWriteback(c.makeChunkFilename(0), 0, int64(len(data)), trace.SpanContext{})) got, err := io.ReadAll(r) require.NoError(t, err) assert.Equal(t, data, got) - require.NoError(t, r.Close()) + require.NoError(t, r.Close(t.Context())) c.wg.Wait() cached, err := os.ReadFile(c.makeChunkFilename(0)) @@ -654,23 +659,16 @@ func TestCacheWriteThroughReader(t *testing.T) { // Inner has only 2 bytes but expectedLen is 5. The reader is // fully consumed (EOF is reached), yet the total doesn't match // the expected length so it must not be cached. - inner := io.NopCloser(bytes.NewReader([]byte{0xAA, 0xBB})) - - r := &cacheWriteThroughReader{ - inner: inner, - buf: bytes.NewBuffer(make([]byte, 0, 5)), - cache: &c, - ctx: t.Context(), - off: 0, - expectedLen: 5, - chunkPath: c.makeChunkFilename(0), - } + inner := NewRangeReader(io.NopCloser(bytes.NewReader([]byte{0xAA, 0xBB}))) + + r := newCaptureReader(inner, 5, false, + c.uncompressedChunkWriteback(c.makeChunkFilename(0), 0, 5, trace.SpanContext{})) got, err := io.ReadAll(r) require.NoError(t, err) assert.Equal(t, []byte{0xAA, 0xBB}, got) - require.NoError(t, r.Close()) + require.NoError(t, r.Close(t.Context())) c.wg.Wait() _, err = os.Stat(c.makeChunkFilename(0)) @@ -682,17 +680,10 @@ func TestCacheWriteThroughReader(t *testing.T) { c := newTestCache(t) data := []byte("hello") - inner := io.NopCloser(bytes.NewReader(data)) - - r := &cacheWriteThroughReader{ - inner: inner, - buf: bytes.NewBuffer(make([]byte, 0, len(data))), - cache: &c, - ctx: t.Context(), - off: 0, - expectedLen: int64(len(data)), - chunkPath: c.makeChunkFilename(0), - } + inner := NewRangeReader(io.NopCloser(bytes.NewReader(data))) + + r := newCaptureReader(inner, len(data), false, + c.uncompressedChunkWriteback(c.makeChunkFilename(0), 0, int64(len(data)), trace.SpanContext{})) // Read only 2 of 5 bytes, then close without reaching EOF. buf := make([]byte, 2) @@ -700,7 +691,7 @@ func TestCacheWriteThroughReader(t *testing.T) { require.NoError(t, err) assert.Equal(t, 2, n) - require.NoError(t, r.Close()) + require.NoError(t, r.Close(t.Context())) c.wg.Wait() _, err = os.Stat(c.makeChunkFilename(0)) diff --git a/packages/shared/pkg/storage/storage_fs.go b/packages/shared/pkg/storage/storage_fs.go index 045f61e921..d4411a20ec 100644 --- a/packages/shared/pkg/storage/storage_fs.go +++ b/packages/shared/pkg/storage/storage_fs.go @@ -39,16 +39,6 @@ var ( _ StreamingReader = (*fsObject)(nil) ) -type fsRangeReadCloser struct { - io.Reader - - file *os.File -} - -func (r *fsRangeReadCloser) Close() error { - return r.file.Close() -} - func newFileSystemStorage(cfg StorageConfig) *fsStorage { return &fsStorage{ basePath: cfg.GetLocalBasePath(), @@ -204,16 +194,13 @@ func (o *fsObject) storeFileCompressed(ctx context.Context, localPath string, cf return ft, checksum, nil } -func (o *fsObject) openRangeReader(_ context.Context, off, length int64) (io.ReadCloser, error) { +func (o *fsObject) openRangeReader(_ context.Context, off, length int64) (RangeReader, error) { f, err := o.getHandle(true) if err != nil { return nil, err } - return &fsRangeReadCloser{ - Reader: io.NewSectionReader(f, off, length), - file: f, - }, nil + return newSectionReader(f, off, length), nil } func (o *fsObject) Exists(_ context.Context) (bool, error) { @@ -314,7 +301,7 @@ func (u *fsPartUploader) Complete(_ context.Context) error { return os.WriteFile(u.fullPath, u.Assemble(), 0o644) } -func (o *fsObject) OpenRangeReader(ctx context.Context, offsetU int64, length int64, frameTable *FrameTable) (io.ReadCloser, error) { +func (o *fsObject) OpenRangeReader(ctx context.Context, offsetU int64, length int64, frameTable *FrameTable) (RangeReader, error) { if frameTable.IsCompressed() { r, err := frameTable.LocateCompressed(offsetU) if err != nil { @@ -326,14 +313,14 @@ func (o *fsObject) OpenRangeReader(ctx context.Context, offsetU int64, length in return nil, err } - decompressed, err := newDecompressingReadCloser(raw, frameTable.CompressionType()) + dec, err := newDecompressReader(raw, frameTable.CompressionType()) if err != nil { - raw.Close() + raw.Close(ctx) return nil, err } - return decompressed, nil + return dec, nil } return o.openRangeReader(ctx, offsetU, length) diff --git a/packages/shared/pkg/storage/storage_google.go b/packages/shared/pkg/storage/storage_google.go index 8288b671e3..0eaff6ae2b 100644 --- a/packages/shared/pkg/storage/storage_google.go +++ b/packages/shared/pkg/storage/storage_google.go @@ -19,7 +19,6 @@ import ( "cloud.google.com/go/storage" "github.com/googleapis/gax-go/v2" "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/metric" "go.uber.org/zap" "google.golang.org/api/iterator" "google.golang.org/api/option" @@ -71,11 +70,6 @@ var ( "Total bytes written to GCS", "Total writes to GCS", )) - gcsConcurrentReads = utils.Must(meter.Int64UpDownCounter( - "orchestrator.storage.gcs.read.concurrent", - metric.WithDescription("Number of GCS range readers currently open"), - metric.WithUnit("{read}"), - )) ) type gcpStorage struct { @@ -598,7 +592,7 @@ func parseServiceAccountBase64(serviceAccount string) (*gcpServiceToken, error) return &sa, nil } -func (o *gcpObject) OpenRangeReader(ctx context.Context, offsetU int64, length int64, frameTable *FrameTable) (io.ReadCloser, error) { +func (o *gcpObject) OpenRangeReader(ctx context.Context, offsetU int64, length int64, frameTable *FrameTable) (RangeReader, error) { timer := googleReadTimerFactory.Begin(attribute.String(gcsOperationAttr, gcsOperationAttrReadAt)) if !frameTable.IsCompressed() { @@ -609,9 +603,7 @@ func (o *gcpObject) OpenRangeReader(ctx context.Context, offsetU int64, length i return nil, err } - gcsConcurrentReads.Add(ctx, 1) - - return &timedReadCloser{inner: rc, timer: timer, ctx: ctx}, nil + return newObservableReader(NewRangeReader(rc), timer, nil), nil } r, err := frameTable.LocateCompressed(offsetU) @@ -628,7 +620,7 @@ func (o *gcpObject) OpenRangeReader(ctx context.Context, offsetU int64, length i return nil, err } - decompressed, err := newDecompressingReadCloser(raw, frameTable.CompressionType()) + dec, err := newDecompressReader(NewRangeReader(raw), frameTable.CompressionType()) if err != nil { raw.Close() timer.Failure(ctx, 0) @@ -636,44 +628,7 @@ func (o *gcpObject) OpenRangeReader(ctx context.Context, offsetU int64, length i return nil, err } - gcsConcurrentReads.Add(ctx, 1) - - return &timedReadCloser{inner: decompressed, timer: timer, ctx: ctx}, nil -} - -// timedReadCloser wraps a reader with OTEL timer metrics. -// Close records success (with total bytes read) or failure on the timer. -type timedReadCloser struct { - inner io.ReadCloser - timer *telemetry.Stopwatch - ctx context.Context //nolint:containedctx // needed for timer recording in Close - bytesRead int64 - closeErr error -} - -func (r *timedReadCloser) Read(p []byte) (int, error) { - n, err := r.inner.Read(p) - r.bytesRead += int64(n) - - if err != nil && err != io.EOF { - r.closeErr = err - } - - return n, err -} - -func (r *timedReadCloser) Close() error { - gcsConcurrentReads.Add(r.ctx, -1) - - err := r.inner.Close() - - if r.closeErr != nil || err != nil { - r.timer.Failure(r.ctx, r.bytesRead) - } else { - r.timer.Success(r.ctx, r.bytesRead) - } - - return err + return newObservableReader(dec, timer, nil), nil } func isResourceExhausted(err error) bool { diff --git a/spec/openapi-dashboard.yml b/spec/openapi-dashboard.yml index be8c2102e1..6b8afc27c1 100644 --- a/spec/openapi-dashboard.yml +++ b/spec/openapi-dashboard.yml @@ -153,6 +153,12 @@ components: application/json: schema: $ref: "#/components/schemas/Error" + "502": + description: Upstream error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" schemas: Error: @@ -217,6 +223,21 @@ components: type: string format: email + AdminTeamBootstrapRequest: + type: object + required: + - name + - email + properties: + name: + type: string + minLength: 1 + description: Team name. + email: + type: string + format: email + description: Billing/contact email for the team. + BuildStatus: type: string description: Build status mapped for dashboard clients. @@ -848,6 +869,35 @@ paths: "500": $ref: "#/components/responses/500" + /admin/teams/bootstrap: + post: + summary: Bootstrap team + description: Creates and provisions a team for an admin-authenticated bootstrap workflow. + tags: [admin] + security: + - AdminTokenAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AdminTeamBootstrapRequest" + responses: + "200": + description: Successfully bootstrapped team. + content: + application/json: + schema: + $ref: "#/components/schemas/TeamResolveResponse" + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "502": + $ref: "#/components/responses/502" + "500": + $ref: "#/components/responses/500" + /admin/user-profiles/resolve: post: summary: Resolve user profiles diff --git a/spec/openapi.yml b/spec/openapi.yml index 6e39265fd0..3dd67764f5 100644 --- a/spec/openapi.yml +++ b/spec/openapi.yml @@ -2631,6 +2631,8 @@ paths: $ref: "#/components/responses/400" "401": $ref: "#/components/responses/401" + "403": + $ref: "#/components/responses/403" "500": $ref: "#/components/responses/500" @@ -3314,6 +3316,73 @@ paths: "500": $ref: "#/components/responses/500" + /admin/teams/{teamID}/api-keys: + post: + summary: Create team API key as admin + description: Creates a team API key for internal service workflows. + tags: [admin] + security: + - AdminTokenAuth: [] + parameters: + - name: teamID + in: path + required: true + schema: + type: string + format: uuid + description: Team ID + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/NewTeamAPIKey" + responses: + "201": + description: Team API key created successfully + content: + application/json: + schema: + $ref: "#/components/schemas/CreatedTeamAPIKey" + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "403": + $ref: "#/components/responses/403" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" + + /admin/teams/{teamID}/api-keys/{apiKeyID}: + delete: + summary: Delete team API key as admin + description: Deletes a team API key for internal service workflows. + tags: [admin] + security: + - AdminTokenAuth: [] + parameters: + - name: teamID + in: path + required: true + schema: + type: string + format: uuid + description: Team ID + - $ref: "#/components/parameters/apiKeyID" + responses: + "204": + description: Team API key deleted successfully + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" + /access-tokens: post: description: Create a new access token diff --git a/tests/integration/internal/api/generated.go b/tests/integration/internal/api/generated.go index d245e339c6..a9b495038b 100644 --- a/tests/integration/internal/api/generated.go +++ b/tests/integration/internal/api/generated.go @@ -1620,6 +1620,9 @@ type GetV2SandboxesSandboxIDLogsParams struct { // PostAccessTokensJSONRequestBody defines body for PostAccessTokens for application/json ContentType. type PostAccessTokensJSONRequestBody = NewAccessToken +// PostAdminTeamsTeamIDApiKeysJSONRequestBody defines body for PostAdminTeamsTeamIDApiKeys for application/json ContentType. +type PostAdminTeamsTeamIDApiKeysJSONRequestBody = NewTeamAPIKey + // PostApiKeysJSONRequestBody defines body for PostApiKeys for application/json ContentType. type PostApiKeysJSONRequestBody = NewTeamAPIKey @@ -1880,6 +1883,14 @@ type ClientInterface interface { // DeleteAccessTokensAccessTokenID request DeleteAccessTokensAccessTokenID(ctx context.Context, accessTokenID AccessTokenID, reqEditors ...RequestEditorFn) (*http.Response, error) + // PostAdminTeamsTeamIDApiKeysWithBody request with any body + PostAdminTeamsTeamIDApiKeysWithBody(ctx context.Context, teamID openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + PostAdminTeamsTeamIDApiKeys(ctx context.Context, teamID openapi_types.UUID, body PostAdminTeamsTeamIDApiKeysJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // DeleteAdminTeamsTeamIDApiKeysApiKeyID request + DeleteAdminTeamsTeamIDApiKeysApiKeyID(ctx context.Context, teamID openapi_types.UUID, apiKeyID ApiKeyID, reqEditors ...RequestEditorFn) (*http.Response, error) + // PostAdminTeamsTeamIDBuildsCancel request PostAdminTeamsTeamIDBuildsCancel(ctx context.Context, teamID openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -2113,6 +2124,42 @@ func (c *Client) DeleteAccessTokensAccessTokenID(ctx context.Context, accessToke return c.Client.Do(req) } +func (c *Client) PostAdminTeamsTeamIDApiKeysWithBody(ctx context.Context, teamID openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPostAdminTeamsTeamIDApiKeysRequestWithBody(c.Server, teamID, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) PostAdminTeamsTeamIDApiKeys(ctx context.Context, teamID openapi_types.UUID, body PostAdminTeamsTeamIDApiKeysJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPostAdminTeamsTeamIDApiKeysRequest(c.Server, teamID, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) DeleteAdminTeamsTeamIDApiKeysApiKeyID(ctx context.Context, teamID openapi_types.UUID, apiKeyID ApiKeyID, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDeleteAdminTeamsTeamIDApiKeysApiKeyIDRequest(c.Server, teamID, apiKeyID) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) PostAdminTeamsTeamIDBuildsCancel(ctx context.Context, teamID openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewPostAdminTeamsTeamIDBuildsCancelRequest(c.Server, teamID) if err != nil { @@ -3051,6 +3098,94 @@ func NewDeleteAccessTokensAccessTokenIDRequest(server string, accessTokenID Acce return req, nil } +// NewPostAdminTeamsTeamIDApiKeysRequest calls the generic PostAdminTeamsTeamIDApiKeys builder with application/json body +func NewPostAdminTeamsTeamIDApiKeysRequest(server string, teamID openapi_types.UUID, body PostAdminTeamsTeamIDApiKeysJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewPostAdminTeamsTeamIDApiKeysRequestWithBody(server, teamID, "application/json", bodyReader) +} + +// NewPostAdminTeamsTeamIDApiKeysRequestWithBody generates requests for PostAdminTeamsTeamIDApiKeys with any type of body +func NewPostAdminTeamsTeamIDApiKeysRequestWithBody(server string, teamID openapi_types.UUID, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "teamID", teamID, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/admin/teams/%s/api-keys", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewDeleteAdminTeamsTeamIDApiKeysApiKeyIDRequest generates requests for DeleteAdminTeamsTeamIDApiKeysApiKeyID +func NewDeleteAdminTeamsTeamIDApiKeysApiKeyIDRequest(server string, teamID openapi_types.UUID, apiKeyID ApiKeyID) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "teamID", teamID, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "apiKeyID", apiKeyID, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/admin/teams/%s/api-keys/%s", pathParam0, pathParam1) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewPostAdminTeamsTeamIDBuildsCancelRequest generates requests for PostAdminTeamsTeamIDBuildsCancel func NewPostAdminTeamsTeamIDBuildsCancelRequest(server string, teamID openapi_types.UUID) (*http.Request, error) { var err error @@ -5692,6 +5827,14 @@ type ClientWithResponsesInterface interface { // DeleteAccessTokensAccessTokenIDWithResponse request DeleteAccessTokensAccessTokenIDWithResponse(ctx context.Context, accessTokenID AccessTokenID, reqEditors ...RequestEditorFn) (*DeleteAccessTokensAccessTokenIDResponse, error) + // PostAdminTeamsTeamIDApiKeysWithBodyWithResponse request with any body + PostAdminTeamsTeamIDApiKeysWithBodyWithResponse(ctx context.Context, teamID openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostAdminTeamsTeamIDApiKeysResponse, error) + + PostAdminTeamsTeamIDApiKeysWithResponse(ctx context.Context, teamID openapi_types.UUID, body PostAdminTeamsTeamIDApiKeysJSONRequestBody, reqEditors ...RequestEditorFn) (*PostAdminTeamsTeamIDApiKeysResponse, error) + + // DeleteAdminTeamsTeamIDApiKeysApiKeyIDWithResponse request + DeleteAdminTeamsTeamIDApiKeysApiKeyIDWithResponse(ctx context.Context, teamID openapi_types.UUID, apiKeyID ApiKeyID, reqEditors ...RequestEditorFn) (*DeleteAdminTeamsTeamIDApiKeysApiKeyIDResponse, error) + // PostAdminTeamsTeamIDBuildsCancelWithResponse request PostAdminTeamsTeamIDBuildsCancelWithResponse(ctx context.Context, teamID openapi_types.UUID, reqEditors ...RequestEditorFn) (*PostAdminTeamsTeamIDBuildsCancelResponse, error) @@ -5953,6 +6096,74 @@ func (r DeleteAccessTokensAccessTokenIDResponse) ContentType() string { return "" } +type PostAdminTeamsTeamIDApiKeysResponse struct { + Body []byte + HTTPResponse *http.Response + JSON201 *CreatedTeamAPIKey + JSON400 *N400 + JSON401 *N401 + JSON403 *N403 + JSON404 *N404 + JSON500 *N500 +} + +// Status returns HTTPResponse.Status +func (r PostAdminTeamsTeamIDApiKeysResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r PostAdminTeamsTeamIDApiKeysResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r PostAdminTeamsTeamIDApiKeysResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type DeleteAdminTeamsTeamIDApiKeysApiKeyIDResponse struct { + Body []byte + HTTPResponse *http.Response + JSON400 *N400 + JSON401 *N401 + JSON404 *N404 + JSON500 *N500 +} + +// Status returns HTTPResponse.Status +func (r DeleteAdminTeamsTeamIDApiKeysApiKeyIDResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DeleteAdminTeamsTeamIDApiKeysApiKeyIDResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r DeleteAdminTeamsTeamIDApiKeysApiKeyIDResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + type PostAdminTeamsTeamIDBuildsCancelResponse struct { Body []byte HTTPResponse *http.Response @@ -7496,6 +7707,7 @@ type PostV3TemplatesResponse struct { JSON202 *TemplateRequestResponseV3 JSON400 *N400 JSON401 *N401 + JSON403 *N403 JSON500 *N500 } @@ -7679,6 +7891,32 @@ func (c *ClientWithResponses) DeleteAccessTokensAccessTokenIDWithResponse(ctx co return ParseDeleteAccessTokensAccessTokenIDResponse(rsp) } +// PostAdminTeamsTeamIDApiKeysWithBodyWithResponse request with arbitrary body returning *PostAdminTeamsTeamIDApiKeysResponse +func (c *ClientWithResponses) PostAdminTeamsTeamIDApiKeysWithBodyWithResponse(ctx context.Context, teamID openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostAdminTeamsTeamIDApiKeysResponse, error) { + rsp, err := c.PostAdminTeamsTeamIDApiKeysWithBody(ctx, teamID, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePostAdminTeamsTeamIDApiKeysResponse(rsp) +} + +func (c *ClientWithResponses) PostAdminTeamsTeamIDApiKeysWithResponse(ctx context.Context, teamID openapi_types.UUID, body PostAdminTeamsTeamIDApiKeysJSONRequestBody, reqEditors ...RequestEditorFn) (*PostAdminTeamsTeamIDApiKeysResponse, error) { + rsp, err := c.PostAdminTeamsTeamIDApiKeys(ctx, teamID, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePostAdminTeamsTeamIDApiKeysResponse(rsp) +} + +// DeleteAdminTeamsTeamIDApiKeysApiKeyIDWithResponse request returning *DeleteAdminTeamsTeamIDApiKeysApiKeyIDResponse +func (c *ClientWithResponses) DeleteAdminTeamsTeamIDApiKeysApiKeyIDWithResponse(ctx context.Context, teamID openapi_types.UUID, apiKeyID ApiKeyID, reqEditors ...RequestEditorFn) (*DeleteAdminTeamsTeamIDApiKeysApiKeyIDResponse, error) { + rsp, err := c.DeleteAdminTeamsTeamIDApiKeysApiKeyID(ctx, teamID, apiKeyID, reqEditors...) + if err != nil { + return nil, err + } + return ParseDeleteAdminTeamsTeamIDApiKeysApiKeyIDResponse(rsp) +} + // PostAdminTeamsTeamIDBuildsCancelWithResponse request returning *PostAdminTeamsTeamIDBuildsCancelResponse func (c *ClientWithResponses) PostAdminTeamsTeamIDBuildsCancelWithResponse(ctx context.Context, teamID openapi_types.UUID, reqEditors ...RequestEditorFn) (*PostAdminTeamsTeamIDBuildsCancelResponse, error) { rsp, err := c.PostAdminTeamsTeamIDBuildsCancel(ctx, teamID, reqEditors...) @@ -8387,6 +8625,114 @@ func ParseDeleteAccessTokensAccessTokenIDResponse(rsp *http.Response) (*DeleteAc return response, nil } +// ParsePostAdminTeamsTeamIDApiKeysResponse parses an HTTP response from a PostAdminTeamsTeamIDApiKeysWithResponse call +func ParsePostAdminTeamsTeamIDApiKeysResponse(rsp *http.Response) (*PostAdminTeamsTeamIDApiKeysResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &PostAdminTeamsTeamIDApiKeysResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: + var dest CreatedTeamAPIKey + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON201 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest N400 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest N401 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest N403 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest N404 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest N500 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseDeleteAdminTeamsTeamIDApiKeysApiKeyIDResponse parses an HTTP response from a DeleteAdminTeamsTeamIDApiKeysApiKeyIDWithResponse call +func ParseDeleteAdminTeamsTeamIDApiKeysApiKeyIDResponse(rsp *http.Response) (*DeleteAdminTeamsTeamIDApiKeysApiKeyIDResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DeleteAdminTeamsTeamIDApiKeysApiKeyIDResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest N400 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest N401 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest N404 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest N500 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ParsePostAdminTeamsTeamIDBuildsCancelResponse parses an HTTP response from a PostAdminTeamsTeamIDBuildsCancelWithResponse call func ParsePostAdminTeamsTeamIDBuildsCancelResponse(rsp *http.Response) (*PostAdminTeamsTeamIDBuildsCancelResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -10533,6 +10879,13 @@ func ParsePostV3TemplatesResponse(rsp *http.Response) (*PostV3TemplatesResponse, } response.JSON401 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest N403 + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: var dest N500 if err := json.Unmarshal(bodyBytes, &dest); err != nil { diff --git a/tests/integration/internal/tests/api/sandboxes/sandbox_rapid_pause_resume_test.go b/tests/integration/internal/tests/api/sandboxes/sandbox_rapid_pause_resume_test.go index 0cdbd75eb9..6da9cb9b90 100644 --- a/tests/integration/internal/tests/api/sandboxes/sandbox_rapid_pause_resume_test.go +++ b/tests/integration/internal/tests/api/sandboxes/sandbox_rapid_pause_resume_test.go @@ -149,7 +149,7 @@ func verifyChecksum(t *testing.T, ctx context.Context, persistence storage.Stora rc, err := obj.OpenRangeReader(ctx, 0, bd.Size, bd.FrameData) require.NoErrorf(t, err, "%s/%s: open range reader", node.name, fileName) - defer rc.Close() + defer rc.Close(ctx) hasher := sha256.New() n, err := io.Copy(hasher, rc) diff --git a/tests/integration/internal/tests/team_test.go b/tests/integration/internal/tests/team_test.go index 71aa6bbb26..b2a6b2b300 100644 --- a/tests/integration/internal/tests/team_test.go +++ b/tests/integration/internal/tests/team_test.go @@ -45,18 +45,19 @@ UPDATE teams SET is_banned = $1 WHERE id = $2 assert.Equal(t, "team is banned", errResp.Message) } -// TestBlockedTeam verifies the per-endpoint blocked-team enforcement -// implemented via inline auth.CheckTeamBlocked calls -// (see packages/auth/pkg/auth/team_state.go). +// TestBlockedTeam verifies the blocked-team enforcement performed by +// middleware.EnforceBlockedTeam against the route allowlist +// (see packages/api/internal/middleware/blocked_team.go). Late-team- +// resolution paths (access-token auth) get the same check via +// APIStore.GetTeam / resolveTemplateAndTeam. // // Blocked teams are: // - allowed for read and delete operations (recovery + cleanup) // - denied for create and mutate operations (resource-consuming) // // Mutate / Delete subtests use a synthetic sandbox ID because the -// blocked-team check runs at the top of each handler, before the -// resource is resolved — we only care that the request was (not) -// rejected by the blocked-team policy. +// blocked-team check runs before the resource is resolved — we only +// care that the request was (not) rejected by the blocked-team policy. func TestBlockedTeam(t *testing.T) { t.Parallel() ctx := t.Context()