diff --git a/svc/api/internal/ctrlclient/BUILD.bazel b/svc/api/internal/ctrlclient/BUILD.bazel new file mode 100644 index 0000000000..d5a88a1a09 --- /dev/null +++ b/svc/api/internal/ctrlclient/BUILD.bazel @@ -0,0 +1,13 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "ctrlclient", + srcs = ["errors.go"], + importpath = "github.com/unkeyed/unkey/svc/api/internal/ctrlclient", + visibility = ["//svc/api:__subpackages__"], + deps = [ + "//pkg/codes", + "//pkg/fault", + "@com_connectrpc_connect//:connect", + ], +) diff --git a/svc/api/internal/ctrlclient/errors.go b/svc/api/internal/ctrlclient/errors.go new file mode 100644 index 0000000000..e6c74ceddb --- /dev/null +++ b/svc/api/internal/ctrlclient/errors.go @@ -0,0 +1,52 @@ +package ctrlclient + +import ( + "errors" + "fmt" + + "connectrpc.com/connect" + "github.com/unkeyed/unkey/pkg/codes" + "github.com/unkeyed/unkey/pkg/fault" +) + +// HandleError converts Connect RPC errors from ctrl services to fault errors +// with appropriate error codes and user-facing messages. +// +// The context parameter should describe the operation being performed (e.g., "create deployment", +// "generate upload URL") and will be used to generate user-facing error messages. +func HandleError(err error, context string) error { + // Convert Connect errors to fault errors + var connectErr *connect.Error + if !errors.As(err, &connectErr) { + // Non-Connect errors + return fault.Wrap(err, + fault.Code(codes.App.Internal.ServiceUnavailable.URN()), + fault.Public(fmt.Sprintf("Failed to %s.", context)), + ) + } + + //nolint:exhaustive // Default case handles all other Connect error codes + switch connectErr.Code() { + case connect.CodeNotFound: + return fault.Wrap(err, + fault.Code(codes.Data.Project.NotFound.URN()), + fault.Public("Project not found."), + ) + case connect.CodeInvalidArgument: + return fault.Wrap(err, + fault.Code(codes.App.Validation.InvalidInput.URN()), + fault.Public(fmt.Sprintf("Invalid request for %s.", context)), + ) + case connect.CodeUnauthenticated: + return fault.Wrap(err, + fault.Code(codes.App.Internal.ServiceUnavailable.URN()), + fault.Public("Failed to authenticate with service."), + ) + default: + // All other Connect errors (Internal, Unavailable, etc.) + return fault.Wrap(err, + fault.Code(codes.App.Internal.ServiceUnavailable.URN()), + fault.Public(fmt.Sprintf("Failed to %s.", context)), + ) + } +} diff --git a/svc/api/openapi/gen.go b/svc/api/openapi/gen.go index 53252e28d1..f0955f4897 100644 --- a/svc/api/openapi/gen.go +++ b/svc/api/openapi/gen.go @@ -743,6 +743,29 @@ type V2DeployCreateDeploymentResponseData struct { DeploymentId string `json:"deploymentId"` } +// V2DeployGenerateUploadUrlRequestBody defines model for V2DeployGenerateUploadUrlRequestBody. +type V2DeployGenerateUploadUrlRequestBody struct { + // ProjectId Unkey project ID for which to generate the upload URL + ProjectId string `json:"projectId"` +} + +// V2DeployGenerateUploadUrlResponseBody defines model for V2DeployGenerateUploadUrlResponseBody. +type V2DeployGenerateUploadUrlResponseBody struct { + Data V2DeployGenerateUploadUrlResponseData `json:"data"` + + // Meta Metadata object included in every API response. This provides context about the request and is essential for debugging, audit trails, and support inquiries. The `requestId` is particularly important when troubleshooting issues with the Unkey support team. + Meta Meta `json:"meta"` +} + +// V2DeployGenerateUploadUrlResponseData defines model for V2DeployGenerateUploadUrlResponseData. +type V2DeployGenerateUploadUrlResponseData struct { + // Context S3 path to use in the createDeployment request when building from source + Context string `json:"context"` + + // UploadUrl Presigned PUT URL for uploading the build context tar file + UploadUrl string `json:"uploadUrl"` +} + // V2DeployGitCommit Optional git commit information type V2DeployGitCommit struct { // AuthorAvatarUrl Git author avatar URL @@ -2335,8 +2358,11 @@ type GetApiJSONRequestBody = V2ApisGetApiRequestBody // ListKeysJSONRequestBody defines body for ListKeys for application/json ContentType. type ListKeysJSONRequestBody = V2ApisListKeysRequestBody -// CreateDeploymentJSONRequestBody defines body for CreateDeployment for application/json ContentType. -type CreateDeploymentJSONRequestBody = V2DeployCreateDeploymentRequestBody +// DeployCreateDeploymentJSONRequestBody defines body for DeployCreateDeployment for application/json ContentType. +type DeployCreateDeploymentJSONRequestBody = V2DeployCreateDeploymentRequestBody + +// DeployGenerateUploadUrlJSONRequestBody defines body for DeployGenerateUploadUrl for application/json ContentType. +type DeployGenerateUploadUrlJSONRequestBody = V2DeployGenerateUploadUrlRequestBody // IdentitiesCreateIdentityJSONRequestBody defines body for IdentitiesCreateIdentity for application/json ContentType. type IdentitiesCreateIdentityJSONRequestBody = V2IdentitiesCreateIdentityRequestBody diff --git a/svc/api/openapi/openapi-generated.yaml b/svc/api/openapi/openapi-generated.yaml index cce09fd128..27bc08f36b 100644 --- a/svc/api/openapi/openapi-generated.yaml +++ b/svc/api/openapi/openapi-generated.yaml @@ -429,6 +429,26 @@ components: $ref: "#/components/schemas/Meta" data: $ref: "#/components/schemas/V2DeployCreateDeploymentResponseData" + V2DeployGenerateUploadUrlRequestBody: + type: object + required: + - projectId + properties: + projectId: + type: string + minLength: 1 + description: Unkey project ID for which to generate the upload URL + example: "proj_123abc" + V2DeployGenerateUploadUrlResponseBody: + type: object + required: + - meta + - data + properties: + meta: + $ref: "#/components/schemas/Meta" + data: + $ref: "#/components/schemas/V2DeployGenerateUploadUrlResponseData" V2IdentitiesCreateIdentityRequestBody: type: object required: @@ -2611,6 +2631,20 @@ components: type: string description: Unique deployment identifier example: "dep_abc123xyz" + V2DeployGenerateUploadUrlResponseData: + type: object + required: + - uploadUrl + - context + properties: + uploadUrl: + type: string + description: Presigned PUT URL for uploading the build context tar file + example: "https://s3.amazonaws.com/bucket/path?signature=..." + context: + type: string + description: S3 path to use in the createDeployment request when building from source + example: "proj_123abc/ctx_456def.tar.gz" RatelimitRequest: type: object required: @@ -4160,7 +4194,7 @@ paths: Creates a new deployment for a project using either a pre-built Docker image or build context. **Authentication**: Requires a valid root key with appropriate permissions. - operationId: createDeployment + operationId: deploy.createDeployment requestBody: content: application/json: @@ -4204,6 +4238,56 @@ paths: tags: - deploy x-speakeasy-name-override: createDeployment + /v2/deploy.generateUploadUrl: + post: + description: | + Generates a presigned S3 URL for uploading build context archives. + + **Authentication**: Requires a valid root key with appropriate permissions. + operationId: deploy.generateUploadUrl + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/V2DeployGenerateUploadUrlRequestBody' + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/V2DeployGenerateUploadUrlResponseBody' + description: Upload URL generated successfully + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/BadRequestErrorResponse' + description: Bad request + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/UnauthorizedErrorResponse' + description: Unauthorized + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/NotFoundErrorResponse' + description: Not found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/InternalServerErrorResponse' + description: Internal server error + security: + - rootKey: [] + summary: Generate upload URL + tags: + - deploy + x-speakeasy-name-override: generateUploadUrl /v2/identities.createIdentity: post: description: | diff --git a/svc/api/openapi/openapi-split.yaml b/svc/api/openapi/openapi-split.yaml index 0381167bd3..1038f3390f 100644 --- a/svc/api/openapi/openapi-split.yaml +++ b/svc/api/openapi/openapi-split.yaml @@ -137,6 +137,8 @@ paths: # Deploy Endpoints /v2/deploy.createDeployment: $ref: "./spec/paths/v2/deploy/createDeployment/index.yaml" + /v2/deploy.generateUploadUrl: + $ref: "./spec/paths/v2/deploy/generateUploadUrl/index.yaml" # Identity Endpoints /v2/identities.createIdentity: diff --git a/svc/api/openapi/spec/paths/v2/deploy/createDeployment/index.yaml b/svc/api/openapi/spec/paths/v2/deploy/createDeployment/index.yaml index da61aa7dc7..b40270fe3e 100644 --- a/svc/api/openapi/spec/paths/v2/deploy/createDeployment/index.yaml +++ b/svc/api/openapi/spec/paths/v2/deploy/createDeployment/index.yaml @@ -6,7 +6,7 @@ post: Creates a new deployment for a project using either a pre-built Docker image or build context. **Authentication**: Requires a valid root key with appropriate permissions. - operationId: createDeployment + operationId: deploy.createDeployment x-speakeasy-name-override: createDeployment security: - rootKey: [] diff --git a/svc/api/openapi/spec/paths/v2/deploy/generateUploadUrl/V2DeployGenerateUploadUrlRequestBody.yaml b/svc/api/openapi/spec/paths/v2/deploy/generateUploadUrl/V2DeployGenerateUploadUrlRequestBody.yaml new file mode 100644 index 0000000000..513029e5ae --- /dev/null +++ b/svc/api/openapi/spec/paths/v2/deploy/generateUploadUrl/V2DeployGenerateUploadUrlRequestBody.yaml @@ -0,0 +1,9 @@ +type: object +required: + - projectId +properties: + projectId: + type: string + minLength: 1 + description: Unkey project ID for which to generate the upload URL + example: "proj_123abc" diff --git a/svc/api/openapi/spec/paths/v2/deploy/generateUploadUrl/V2DeployGenerateUploadUrlResponseBody.yaml b/svc/api/openapi/spec/paths/v2/deploy/generateUploadUrl/V2DeployGenerateUploadUrlResponseBody.yaml new file mode 100644 index 0000000000..ede97a3c64 --- /dev/null +++ b/svc/api/openapi/spec/paths/v2/deploy/generateUploadUrl/V2DeployGenerateUploadUrlResponseBody.yaml @@ -0,0 +1,9 @@ +type: object +required: + - meta + - data +properties: + meta: + $ref: "../../../../common/Meta.yaml" + data: + $ref: "./V2DeployGenerateUploadUrlResponseData.yaml" diff --git a/svc/api/openapi/spec/paths/v2/deploy/generateUploadUrl/V2DeployGenerateUploadUrlResponseData.yaml b/svc/api/openapi/spec/paths/v2/deploy/generateUploadUrl/V2DeployGenerateUploadUrlResponseData.yaml new file mode 100644 index 0000000000..84a56a3cfc --- /dev/null +++ b/svc/api/openapi/spec/paths/v2/deploy/generateUploadUrl/V2DeployGenerateUploadUrlResponseData.yaml @@ -0,0 +1,13 @@ +type: object +required: + - uploadUrl + - context +properties: + uploadUrl: + type: string + description: Presigned PUT URL for uploading the build context tar file + example: "https://s3.amazonaws.com/bucket/path?signature=..." + context: + type: string + description: S3 path to use in the createDeployment request when building from source + example: "proj_123abc/ctx_456def.tar.gz" diff --git a/svc/api/openapi/spec/paths/v2/deploy/generateUploadUrl/index.yaml b/svc/api/openapi/spec/paths/v2/deploy/generateUploadUrl/index.yaml new file mode 100644 index 0000000000..9153b7d838 --- /dev/null +++ b/svc/api/openapi/spec/paths/v2/deploy/generateUploadUrl/index.yaml @@ -0,0 +1,49 @@ +post: + tags: + - deploy + summary: Generate upload URL + description: | + Generates a presigned S3 URL for uploading build context archives. + + **Authentication**: Requires a valid root key with appropriate permissions. + operationId: deploy.generateUploadUrl + x-speakeasy-name-override: generateUploadUrl + security: + - rootKey: [] + requestBody: + required: true + content: + application/json: + schema: + "$ref": "./V2DeployGenerateUploadUrlRequestBody.yaml" + responses: + "200": + description: Upload URL generated successfully + content: + application/json: + schema: + "$ref": "./V2DeployGenerateUploadUrlResponseBody.yaml" + "400": + description: Bad request + content: + application/json: + schema: + "$ref": "../../../../error/BadRequestErrorResponse.yaml" + "401": + description: Unauthorized + content: + application/json: + schema: + "$ref": "../../../../error/UnauthorizedErrorResponse.yaml" + "404": + description: Not found + content: + application/json: + schema: + "$ref": "../../../../error/NotFoundErrorResponse.yaml" + "500": + description: Internal server error + content: + application/json: + schema: + "$ref": "../../../../error/InternalServerErrorResponse.yaml" diff --git a/svc/api/routes/BUILD.bazel b/svc/api/routes/BUILD.bazel index ece855f511..7b442a9c2b 100644 --- a/svc/api/routes/BUILD.bazel +++ b/svc/api/routes/BUILD.bazel @@ -35,6 +35,7 @@ go_library( "//svc/api/routes/v2_apis_get_api", "//svc/api/routes/v2_apis_list_keys", "//svc/api/routes/v2_deploy_create_deployment", + "//svc/api/routes/v2_deploy_generate_upload_url", "//svc/api/routes/v2_identities_create_identity", "//svc/api/routes/v2_identities_delete_identity", "//svc/api/routes/v2_identities_get_identity", diff --git a/svc/api/routes/register.go b/svc/api/routes/register.go index 6767e20508..d9fc707a36 100644 --- a/svc/api/routes/register.go +++ b/svc/api/routes/register.go @@ -27,6 +27,7 @@ import ( v2ApisListKeys "github.com/unkeyed/unkey/svc/api/routes/v2_apis_list_keys" v2DeployCreateDeployment "github.com/unkeyed/unkey/svc/api/routes/v2_deploy_create_deployment" + v2DeployGenerateUploadUrl "github.com/unkeyed/unkey/svc/api/routes/v2_deploy_generate_upload_url" v2IdentitiesCreateIdentity "github.com/unkeyed/unkey/svc/api/routes/v2_identities_create_identity" v2IdentitiesDeleteIdentity "github.com/unkeyed/unkey/svc/api/routes/v2_identities_delete_identity" @@ -324,7 +325,7 @@ func Register(srv *zen.Server, svc *Services, info zen.InstanceInfo) { // --------------------------------------------------------------------------- // v2/deploy - if svc.CtrlDeploymentClient != nil { + if svc.CtrlBuildClient != nil { // v2/deploy.createDeployment srv.RegisterRoute( defaultMiddlewares, @@ -335,6 +336,17 @@ func Register(srv *zen.Server, svc *Services, info zen.InstanceInfo) { CtrlClient: svc.CtrlDeploymentClient, }, ) + + // v2/deploy.generateUploadUrl + srv.RegisterRoute( + defaultMiddlewares, + &v2DeployGenerateUploadUrl.Handler{ + Logger: svc.Logger, + DB: svc.Database, + Keys: svc.Keys, + CtrlClient: svc.CtrlBuildClient, + }, + ) } // --------------------------------------------------------------------------- @@ -628,7 +640,7 @@ func Register(srv *zen.Server, svc *Services, info zen.InstanceInfo) { // --------------------------------------------------------------------------- // misc - var miscMiddlewares = []zen.Middleware{ + miscMiddlewares := []zen.Middleware{ withObservability, withMetrics, withLogging, diff --git a/svc/api/routes/services.go b/svc/api/routes/services.go index e728b19410..6de3bd9364 100644 --- a/svc/api/routes/services.go +++ b/svc/api/routes/services.go @@ -27,6 +27,7 @@ type Services struct { Vault *vault.Service ChproxyToken string CtrlDeploymentClient ctrlv1connect.DeploymentServiceClient + CtrlBuildClient ctrlv1connect.BuildServiceClient PprofEnabled bool PprofUsername string PprofPassword string diff --git a/svc/api/routes/v2_deploy_create_deployment/404_test.go b/svc/api/routes/v2_deploy_create_deployment/404_test.go index 3f31912f43..555b018679 100644 --- a/svc/api/routes/v2_deploy_create_deployment/404_test.go +++ b/svc/api/routes/v2_deploy_create_deployment/404_test.go @@ -65,7 +65,7 @@ func TestNotFound(t *testing.T) { require.NotNil(t, res.Body) require.Equal(t, "https://unkey.com/docs/errors/unkey/data/project_not_found", res.Body.Error.Type) require.Equal(t, http.StatusInternalServerError, res.Body.Error.Status) - require.Equal(t, "Project or environment not found.", res.Body.Error.Detail) + require.Equal(t, "Project not found.", res.Body.Error.Detail) }) t.Run("environment not found", func(t *testing.T) { @@ -95,6 +95,6 @@ func TestNotFound(t *testing.T) { require.NotNil(t, res.Body) require.Equal(t, "https://unkey.com/docs/errors/unkey/data/project_not_found", res.Body.Error.Type) require.Equal(t, http.StatusInternalServerError, res.Body.Error.Status) - require.Equal(t, "Project or environment not found.", res.Body.Error.Detail) + require.Equal(t, "Project not found.", res.Body.Error.Detail) }) } diff --git a/svc/api/routes/v2_deploy_create_deployment/BUILD.bazel b/svc/api/routes/v2_deploy_create_deployment/BUILD.bazel index 0748076b8b..3d72fc2eda 100644 --- a/svc/api/routes/v2_deploy_create_deployment/BUILD.bazel +++ b/svc/api/routes/v2_deploy_create_deployment/BUILD.bazel @@ -9,11 +9,10 @@ go_library( "//gen/proto/ctrl/v1:ctrl", "//gen/proto/ctrl/v1/ctrlv1connect", "//internal/services/keys", - "//pkg/codes", "//pkg/db", - "//pkg/fault", "//pkg/otel/logging", "//pkg/zen", + "//svc/api/internal/ctrlclient", "//svc/api/openapi", "@com_connectrpc_connect//:connect", ], diff --git a/svc/api/routes/v2_deploy_create_deployment/handler.go b/svc/api/routes/v2_deploy_create_deployment/handler.go index 1cb8be1a76..e8fefae3a1 100644 --- a/svc/api/routes/v2_deploy_create_deployment/handler.go +++ b/svc/api/routes/v2_deploy_create_deployment/handler.go @@ -2,18 +2,16 @@ package handler import ( "context" - "errors" "net/http" "connectrpc.com/connect" ctrlv1 "github.com/unkeyed/unkey/gen/proto/ctrl/v1" "github.com/unkeyed/unkey/gen/proto/ctrl/v1/ctrlv1connect" "github.com/unkeyed/unkey/internal/services/keys" - "github.com/unkeyed/unkey/pkg/codes" "github.com/unkeyed/unkey/pkg/db" - "github.com/unkeyed/unkey/pkg/fault" "github.com/unkeyed/unkey/pkg/otel/logging" "github.com/unkeyed/unkey/pkg/zen" + "github.com/unkeyed/unkey/svc/api/internal/ctrlclient" "github.com/unkeyed/unkey/svc/api/openapi" ) @@ -49,13 +47,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { return err } - if req.ProjectId == "" { - return fault.New("projectId is required", - fault.Code(codes.App.Validation.InvalidInput.URN()), - fault.Public("projectId is required."), - ) - } - // nolint: exhaustruct // optional proto fields, only setting whats provided ctrlReq := &ctrlv1.CreateDeploymentRequest{ ProjectId: req.ProjectId, @@ -118,7 +109,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { ctrlResp, err := h.CtrlClient.CreateDeployment(ctx, connectReq) if err != nil { - return h.handleCtrlError(err) + return ctrlclient.HandleError(err, "create deployment") } return s.JSON(http.StatusCreated, Response{ @@ -130,40 +121,3 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { }, }) } - -func (h *Handler) handleCtrlError(err error) error { - // Convert Connect errors to fault errors - var connectErr *connect.Error - if errors.As(err, &connectErr) { - //nolint:exhaustive // Default case handles all other Connect error codes - switch connectErr.Code() { - case connect.CodeNotFound: - return fault.Wrap(err, - fault.Code(codes.Data.Project.NotFound.URN()), - fault.Public("Project or environment not found."), - ) - case connect.CodeInvalidArgument: - return fault.Wrap(err, - fault.Code(codes.App.Validation.InvalidInput.URN()), - fault.Public("Invalid deployment request."), - ) - case connect.CodeUnauthenticated: - return fault.Wrap(err, - fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Public("Failed to authenticate with deployment service."), - ) - default: - // All other Connect errors (Internal, Unavailable, etc.) - return fault.Wrap(err, - fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Public("Failed to create deployment."), - ) - } - } - - // Non-Connect errors - return fault.Wrap(err, - fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Public("Failed to create deployment."), - ) -} diff --git a/svc/api/routes/v2_deploy_generate_upload_url/200_test.go b/svc/api/routes/v2_deploy_generate_upload_url/200_test.go new file mode 100644 index 0000000000..84900e7e3f --- /dev/null +++ b/svc/api/routes/v2_deploy_generate_upload_url/200_test.go @@ -0,0 +1,76 @@ +package handler_test + +import ( + "fmt" + "net/http" + "testing" + + "connectrpc.com/connect" + "github.com/stretchr/testify/require" + "github.com/unkeyed/unkey/gen/proto/ctrl/v1/ctrlv1connect" + "github.com/unkeyed/unkey/pkg/rpc/interceptor" + "github.com/unkeyed/unkey/pkg/testutil" + "github.com/unkeyed/unkey/pkg/testutil/containers" + "github.com/unkeyed/unkey/pkg/testutil/seed" + "github.com/unkeyed/unkey/pkg/uid" + handler "github.com/unkeyed/unkey/svc/api/routes/v2_deploy_generate_upload_url" +) + +func TestGenerateUploadUrlSuccessfully(t *testing.T) { + h := testutil.NewHarness(t) + + // Get CTRL service URL and token + ctrlURL, ctrlToken := containers.ControlPlane(t) + + ctrlClient := ctrlv1connect.NewBuildServiceClient( + http.DefaultClient, + ctrlURL, + connect.WithInterceptors(interceptor.NewHeaderInjector(map[string]string{ + "Authorization": fmt.Sprintf("Bearer %s", ctrlToken), + })), + ) + + route := &handler.Handler{ + Logger: h.Logger, + DB: h.DB, + Keys: h.Keys, + CtrlClient: ctrlClient, + } + h.Register(route) + + t.Run("generate upload URL successfully", func(t *testing.T) { + workspace := h.CreateWorkspace() + rootKey := h.CreateRootKey(workspace.ID) + + projectID := uid.New(uid.ProjectPrefix) + projectName := "test-project" + + h.CreateProject(seed.CreateProjectRequest{ + WorkspaceID: workspace.ID, + Name: projectName, + ID: projectID, + Slug: "production", + }) + + headers := http.Header{ + "Content-Type": {"application/json"}, + "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, + } + + req := handler.Request{ + ProjectId: projectID, + } + + res := testutil.CallRoute[handler.Request, handler.Response]( + h, + route, + headers, + req, + ) + + require.Equal(t, 200, res.Status, "expected 200, received: %#v", res) + require.NotNil(t, res.Body) + require.NotEmpty(t, res.Body.Data.UploadUrl, "upload URL should not be empty") + require.NotEmpty(t, res.Body.Data.Context, "build context path should not be empty") + }) +} diff --git a/svc/api/routes/v2_deploy_generate_upload_url/400_test.go b/svc/api/routes/v2_deploy_generate_upload_url/400_test.go new file mode 100644 index 0000000000..41bbd5f722 --- /dev/null +++ b/svc/api/routes/v2_deploy_generate_upload_url/400_test.go @@ -0,0 +1,114 @@ +package handler_test + +import ( + "fmt" + "net/http" + "testing" + + "connectrpc.com/connect" + "github.com/stretchr/testify/require" + "github.com/unkeyed/unkey/gen/proto/ctrl/v1/ctrlv1connect" + "github.com/unkeyed/unkey/pkg/rpc/interceptor" + "github.com/unkeyed/unkey/pkg/testutil" + "github.com/unkeyed/unkey/pkg/testutil/containers" + "github.com/unkeyed/unkey/pkg/testutil/seed" + "github.com/unkeyed/unkey/pkg/uid" + "github.com/unkeyed/unkey/svc/api/openapi" + handler "github.com/unkeyed/unkey/svc/api/routes/v2_deploy_generate_upload_url" +) + +func TestBadRequests(t *testing.T) { + h := testutil.NewHarness(t) + + // Get CTRL service URL and token + ctrlURL, ctrlToken := containers.ControlPlane(t) + + ctrlClient := ctrlv1connect.NewBuildServiceClient( + http.DefaultClient, + ctrlURL, + connect.WithInterceptors(interceptor.NewHeaderInjector(map[string]string{ + "Authorization": fmt.Sprintf("Bearer %s", ctrlToken), + })), + ) + + route := &handler.Handler{ + Logger: h.Logger, + DB: h.DB, + Keys: h.Keys, + CtrlClient: ctrlClient, + } + h.Register(route) + + workspace := h.CreateWorkspace() + rootKey := h.CreateRootKey(workspace.ID) + + projectID := uid.New(uid.ProjectPrefix) + projectName := "test-project" + + h.CreateProject(seed.CreateProjectRequest{ + WorkspaceID: workspace.ID, + Name: projectName, + ID: projectID, + Slug: "production", + }) + + headers := http.Header{ + "Content-Type": {"application/json"}, + "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, + } + + t.Run("missing projectId", func(t *testing.T) { + req := handler.Request{} + + res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req) + + require.Equal(t, 400, res.Status, "expected 400, sent: %+v, received: %s", req, res.RawBody) + require.NotNil(t, res.Body) + require.Equal(t, "https://unkey.com/docs/errors/unkey/application/invalid_input", res.Body.Error.Type) + require.Equal(t, http.StatusBadRequest, res.Body.Error.Status) + }) + + t.Run("empty projectId", func(t *testing.T) { + req := handler.Request{ + ProjectId: "", + } + + res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req) + + require.Equal(t, 400, res.Status, "expected 400, sent: %+v, received: %s", req, res.RawBody) + require.NotNil(t, res.Body) + require.Equal(t, "https://unkey.com/docs/errors/unkey/application/invalid_input", res.Body.Error.Type) + require.Equal(t, http.StatusBadRequest, res.Body.Error.Status) + require.NotEmpty(t, res.Body.Meta.RequestId) + }) + + t.Run("missing authorization header", func(t *testing.T) { + headers := http.Header{ + "Content-Type": {"application/json"}, + // No Authorization header + } + + req := handler.Request{ + ProjectId: projectID, + } + + res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) + require.Equal(t, http.StatusBadRequest, res.Status) + require.NotNil(t, res.Body) + }) + + t.Run("malformed authorization header", func(t *testing.T) { + headers := http.Header{ + "Content-Type": {"application/json"}, + "Authorization": {"malformed_header"}, + } + + req := handler.Request{ + ProjectId: projectID, + } + + res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) + require.Equal(t, http.StatusBadRequest, res.Status) + require.NotNil(t, res.Body) + }) +} diff --git a/svc/api/routes/v2_deploy_generate_upload_url/401_test.go b/svc/api/routes/v2_deploy_generate_upload_url/401_test.go new file mode 100644 index 0000000000..168728f963 --- /dev/null +++ b/svc/api/routes/v2_deploy_generate_upload_url/401_test.go @@ -0,0 +1,67 @@ +package handler_test + +import ( + "fmt" + "net/http" + "testing" + + "connectrpc.com/connect" + "github.com/stretchr/testify/require" + "github.com/unkeyed/unkey/gen/proto/ctrl/v1/ctrlv1connect" + "github.com/unkeyed/unkey/pkg/rpc/interceptor" + "github.com/unkeyed/unkey/pkg/testutil" + "github.com/unkeyed/unkey/pkg/testutil/containers" + "github.com/unkeyed/unkey/pkg/testutil/seed" + "github.com/unkeyed/unkey/pkg/uid" + handler "github.com/unkeyed/unkey/svc/api/routes/v2_deploy_generate_upload_url" +) + +func TestUnauthorizedAccess(t *testing.T) { + h := testutil.NewHarness(t) + + // Get CTRL service URL and token + ctrlURL, ctrlToken := containers.ControlPlane(t) + + ctrlClient := ctrlv1connect.NewBuildServiceClient( + http.DefaultClient, + ctrlURL, + connect.WithInterceptors(interceptor.NewHeaderInjector(map[string]string{ + "Authorization": fmt.Sprintf("Bearer %s", ctrlToken), + })), + ) + + route := &handler.Handler{ + Logger: h.Logger, + DB: h.DB, + Keys: h.Keys, + CtrlClient: ctrlClient, + } + h.Register(route) + + workspace := h.CreateWorkspace() + + projectID := uid.New(uid.ProjectPrefix) + projectName := "test-project" + + h.CreateProject(seed.CreateProjectRequest{ + WorkspaceID: workspace.ID, + Name: projectName, + ID: projectID, + Slug: "production", + }) + + t.Run("invalid authorization token", func(t *testing.T) { + headers := http.Header{ + "Content-Type": {"application/json"}, + "Authorization": {"Bearer invalid_token"}, + } + + req := handler.Request{ + ProjectId: projectID, + } + + res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) + require.Equal(t, http.StatusUnauthorized, res.Status, "expected 401, received: %s", res.RawBody) + require.NotNil(t, res.Body) + }) +} diff --git a/svc/api/routes/v2_deploy_generate_upload_url/404_test.go b/svc/api/routes/v2_deploy_generate_upload_url/404_test.go new file mode 100644 index 0000000000..48fef7f550 --- /dev/null +++ b/svc/api/routes/v2_deploy_generate_upload_url/404_test.go @@ -0,0 +1,61 @@ +package handler_test + +import ( + "fmt" + "net/http" + "testing" + + "connectrpc.com/connect" + "github.com/stretchr/testify/require" + "github.com/unkeyed/unkey/gen/proto/ctrl/v1/ctrlv1connect" + "github.com/unkeyed/unkey/pkg/rpc/interceptor" + "github.com/unkeyed/unkey/pkg/testutil" + "github.com/unkeyed/unkey/pkg/testutil/containers" + "github.com/unkeyed/unkey/pkg/uid" + "github.com/unkeyed/unkey/svc/api/openapi" + handler "github.com/unkeyed/unkey/svc/api/routes/v2_deploy_generate_upload_url" +) + +func TestNotFound(t *testing.T) { + h := testutil.NewHarness(t) + + // Get CTRL service URL and token + ctrlURL, ctrlToken := containers.ControlPlane(t) + + ctrlClient := ctrlv1connect.NewBuildServiceClient( + http.DefaultClient, + ctrlURL, + connect.WithInterceptors(interceptor.NewHeaderInjector(map[string]string{ + "Authorization": fmt.Sprintf("Bearer %s", ctrlToken), + })), + ) + + route := &handler.Handler{ + Logger: h.Logger, + DB: h.DB, + Keys: h.Keys, + CtrlClient: ctrlClient, + } + h.Register(route) + + workspace := h.CreateWorkspace() + rootKey := h.CreateRootKey(workspace.ID) + + headers := http.Header{ + "Content-Type": {"application/json"}, + "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, + } + + t.Run("project not found", func(t *testing.T) { + req := handler.Request{ + ProjectId: uid.New(uid.ProjectPrefix), // Non-existent project ID + } + + res := testutil.CallRoute[handler.Request, openapi.InternalServerErrorResponse](h, route, headers, req) + require.Equal(t, http.StatusInternalServerError, res.Status, "expected 500, received: %s", res.RawBody) + require.NotNil(t, res.Body) + require.Equal(t, "https://unkey.com/docs/errors/unkey/data/project_not_found", res.Body.Error.Type) + require.Equal(t, http.StatusInternalServerError, res.Body.Error.Status) + require.Equal(t, "Project not found.", res.Body.Error.Detail) + }) +} diff --git a/svc/api/routes/v2_deploy_generate_upload_url/BUILD.bazel b/svc/api/routes/v2_deploy_generate_upload_url/BUILD.bazel new file mode 100644 index 0000000000..47a1bdf857 --- /dev/null +++ b/svc/api/routes/v2_deploy_generate_upload_url/BUILD.bazel @@ -0,0 +1,41 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "v2_deploy_generate_upload_url", + srcs = ["handler.go"], + importpath = "github.com/unkeyed/unkey/svc/api/routes/v2_deploy_generate_upload_url", + visibility = ["//visibility:public"], + deps = [ + "//gen/proto/ctrl/v1:ctrl", + "//gen/proto/ctrl/v1/ctrlv1connect", + "//internal/services/keys", + "//pkg/db", + "//pkg/otel/logging", + "//pkg/zen", + "//svc/api/internal/ctrlclient", + "//svc/api/openapi", + "@com_connectrpc_connect//:connect", + ], +) + +go_test( + name = "v2_deploy_generate_upload_url_test", + srcs = [ + "200_test.go", + "400_test.go", + "401_test.go", + "404_test.go", + ], + deps = [ + ":v2_deploy_generate_upload_url", + "//gen/proto/ctrl/v1/ctrlv1connect", + "//pkg/rpc/interceptor", + "//pkg/testutil", + "//pkg/testutil/containers", + "//pkg/testutil/seed", + "//pkg/uid", + "//svc/api/openapi", + "@com_connectrpc_connect//:connect", + "@com_github_stretchr_testify//require", + ], +) diff --git a/svc/api/routes/v2_deploy_generate_upload_url/handler.go b/svc/api/routes/v2_deploy_generate_upload_url/handler.go new file mode 100644 index 0000000000..4cac7a2a4f --- /dev/null +++ b/svc/api/routes/v2_deploy_generate_upload_url/handler.go @@ -0,0 +1,70 @@ +package handler + +import ( + "context" + "net/http" + + "connectrpc.com/connect" + ctrlv1 "github.com/unkeyed/unkey/gen/proto/ctrl/v1" + "github.com/unkeyed/unkey/gen/proto/ctrl/v1/ctrlv1connect" + "github.com/unkeyed/unkey/internal/services/keys" + "github.com/unkeyed/unkey/pkg/db" + "github.com/unkeyed/unkey/pkg/otel/logging" + "github.com/unkeyed/unkey/pkg/zen" + "github.com/unkeyed/unkey/svc/api/internal/ctrlclient" + "github.com/unkeyed/unkey/svc/api/openapi" +) + +type ( + Request = openapi.V2DeployGenerateUploadUrlRequestBody + Response = openapi.V2DeployGenerateUploadUrlResponseBody +) + +type Handler struct { + Logger logging.Logger + DB db.Database + Keys keys.KeyService + CtrlClient ctrlv1connect.BuildServiceClient +} + +func (h *Handler) Path() string { + return "/v2/deploy.generateUploadUrl" +} + +func (h *Handler) Method() string { + return "POST" +} + +func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { + _, emit, err := h.Keys.GetRootKey(ctx, s) + defer emit() + if err != nil { + return err + } + + req, err := zen.BindBody[Request](s) + if err != nil { + return err + } + + ctrlReq := &ctrlv1.GenerateUploadURLRequest{ + UnkeyProjectId: req.ProjectId, + } + + connectReq := connect.NewRequest(ctrlReq) + + ctrlResp, err := h.CtrlClient.GenerateUploadURL(ctx, connectReq) + if err != nil { + return ctrlclient.HandleError(err, "generate upload URL") + } + + return s.JSON(http.StatusOK, Response{ + Meta: openapi.Meta{ + RequestId: s.RequestID(), + }, + Data: openapi.V2DeployGenerateUploadUrlResponseData{ + UploadUrl: ctrlResp.Msg.GetUploadUrl(), + Context: ctrlResp.Msg.GetBuildContextPath(), + }, + }) +} diff --git a/svc/api/run.go b/svc/api/run.go index 4490c30856..90fd7b427c 100644 --- a/svc/api/run.go +++ b/svc/api/run.go @@ -299,6 +299,7 @@ func Run(ctx context.Context, cfg Config) error { // Initialize CTRL deployment client using bufconnect var ctrlDeploymentClient ctrlv1connect.DeploymentServiceClient + var ctrlBuildClient ctrlv1connect.BuildServiceClient if cfg.CtrlURL != "" { ctrlDeploymentClient = ctrlv1connect.NewDeploymentServiceClient( &http.Client{}, @@ -307,9 +308,16 @@ func Run(ctx context.Context, cfg Config) error { "Authorization": fmt.Sprintf("Bearer %s", cfg.CtrlToken), })), ) - logger.Info("CTRL deployment client initialized", "url", cfg.CtrlURL) + ctrlBuildClient = ctrlv1connect.NewBuildServiceClient( + &http.Client{}, + cfg.CtrlURL, + connect.WithInterceptors(interceptor.NewHeaderInjector(map[string]string{ + "Authorization": fmt.Sprintf("Bearer %s", cfg.CtrlToken), + })), + ) + logger.Info("CTRL clients initialized", "url", cfg.CtrlURL) } else { - logger.Warn("CTRL URL not configured, deployment endpoints will be unavailable") + logger.Warn("CTRL URL not configured, deployment and build endpoints will be unavailable") } routes.Register(srv, &routes.Services{ @@ -324,6 +332,7 @@ func Run(ctx context.Context, cfg Config) error { Vault: vaultSvc, ChproxyToken: cfg.ChproxyToken, CtrlDeploymentClient: ctrlDeploymentClient, + CtrlBuildClient: ctrlBuildClient, PprofEnabled: cfg.PprofEnabled, PprofUsername: cfg.PprofUsername, PprofPassword: cfg.PprofPassword, diff --git a/svc/ctrl/services/build/backend/depot/generate_upload_url.go b/svc/ctrl/services/build/backend/depot/generate_upload_url.go index 2b246794bf..c3302ad108 100644 --- a/svc/ctrl/services/build/backend/depot/generate_upload_url.go +++ b/svc/ctrl/services/build/backend/depot/generate_upload_url.go @@ -8,6 +8,7 @@ import ( "connectrpc.com/connect" ctrlv1 "github.com/unkeyed/unkey/gen/proto/ctrl/v1" + "github.com/unkeyed/unkey/pkg/db" ) func (s *Depot) GenerateUploadURL( @@ -20,6 +21,17 @@ func (s *Depot) GenerateUploadURL( fmt.Errorf("unkeyProjectID is required")) } + // This ensures the project exists. Without this check, callers could provide + // arbitrary projectIds and generate unlimited upload URLs. + _, err := db.Query.FindProjectById(ctx, s.db.RO(), unkeyProjectID) + if err != nil { + if db.IsNotFound(err) { + return nil, connect.NewError(connect.CodeNotFound, + fmt.Errorf("project not found: %s", unkeyProjectID)) + } + return nil, connect.NewError(connect.CodeInternal, err) + } + // Generate unique S3 key for this build context buildContextPath := fmt.Sprintf("%s/%d.tar.gz", unkeyProjectID, diff --git a/svc/ctrl/services/build/backend/docker/generate_upload_url.go b/svc/ctrl/services/build/backend/docker/generate_upload_url.go index 63c16a51c8..e190c3693d 100644 --- a/svc/ctrl/services/build/backend/docker/generate_upload_url.go +++ b/svc/ctrl/services/build/backend/docker/generate_upload_url.go @@ -8,6 +8,7 @@ import ( "connectrpc.com/connect" ctrlv1 "github.com/unkeyed/unkey/gen/proto/ctrl/v1" + "github.com/unkeyed/unkey/pkg/db" ) func (s *Docker) GenerateUploadURL( @@ -20,6 +21,17 @@ func (s *Docker) GenerateUploadURL( fmt.Errorf("unkeyProjectID is required")) } + // This ensures the project exists. Without this check, callers could provide + // arbitrary projectIds and generate unlimited upload URLs. + _, err := db.Query.FindProjectById(ctx, s.db.RO(), unkeyProjectID) + if err != nil { + if db.IsNotFound(err) { + return nil, connect.NewError(connect.CodeNotFound, + fmt.Errorf("project not found: %s", unkeyProjectID)) + } + return nil, connect.NewError(connect.CodeInternal, err) + } + // Generate unique S3 key for this build context buildContextPath := fmt.Sprintf("%s/%d.tar.gz", unkeyProjectID, diff --git a/svc/ctrl/services/deployment/create_deployment.go b/svc/ctrl/services/deployment/create_deployment.go index 93bae3fed4..c98cfb5e3d 100644 --- a/svc/ctrl/services/deployment/create_deployment.go +++ b/svc/ctrl/services/deployment/create_deployment.go @@ -26,6 +26,11 @@ func (s *Service) CreateDeployment( ctx context.Context, req *connect.Request[ctrlv1.CreateDeploymentRequest], ) (*connect.Response[ctrlv1.CreateDeploymentResponse], error) { + if req.Msg.GetProjectId() == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, + fmt.Errorf("project_id is required")) + } + // Lookup project and infer workspace from it project, err := db.Query.FindProjectById(ctx, s.db.RO(), req.Msg.GetProjectId()) if err != nil {