diff --git a/docs/rest-apis/platform-api/authentication.md b/docs/rest-apis/platform-api/authentication.md index e44ccea978..0f529f38d7 100644 --- a/docs/rest-apis/platform-api/authentication.md +++ b/docs/rest-apis/platform-api/authentication.md @@ -333,6 +333,9 @@ login endpoint described under [Obtaining a token](#obtaining-a-token) |ap:rest_api:api_key:delete|Delete an API key of a REST API| |ap:rest_api:api_key:manage|Full access to a REST API's API keys| |ap:rest_api:api_key:update|Update an API key of a REST API| +|ap:rest_api:build:create|Prepare a build of a REST API| +|ap:rest_api:build:manage|Full access to a REST API's builds| +|ap:rest_api:build:read|Read a REST API's builds| |ap:rest_api:create|Create a REST API| |ap:rest_api:delete|Delete a REST API| |ap:rest_api:deployment:create|Deploy a REST API| diff --git a/platform-api/api/generated.go b/platform-api/api/generated.go index e0a141d3b8..ecca8203b7 100644 --- a/platform-api/api/generated.go +++ b/platform-api/api/generated.go @@ -643,6 +643,53 @@ type AssociatedGateway struct { Id string `binding:"required" json:"id" yaml:"id"` } +// BuildListResponse defines model for BuildListResponse. +type BuildListResponse struct { + // Count Number of builds in current response + Count int `binding:"required" json:"count" yaml:"count"` + + // List Builds, newest first + List []BuildResponse `binding:"required" json:"list" yaml:"list"` +} + +// BuildRequest Optional details to record with a build. +type BuildRequest struct { + // Description Optional note recorded with the build, to tell one snapshot from another when + // choosing what to deploy or which build to delete. + Description *string `json:"description,omitempty" yaml:"description,omitempty"` + + // Metadata Free-form metadata to store with the build, such as the commit an API kept in a + // repository was prepared from. It is returned with the build and is not + // interpreted by the platform. + Metadata *map[string]interface{} `json:"metadata,omitempty" yaml:"metadata,omitempty"` +} + +// BuildResponse An immutable, rendered snapshot of an API's definition, not bound to any gateway. +type BuildResponse struct { + // BuildId Identifier for the build, supplied as `buildId` when a deployment's `base` is + // `build`. It is the date the build was prepared followed by that day's index for + // the API, and is unique per API. + BuildId string `binding:"required" json:"buildId" yaml:"buildId"` + + // CreatedAt Timestamp when the build was prepared + CreatedAt time.Time `binding:"required" json:"createdAt" yaml:"createdAt"` + + // CreatedBy Who prepared the build + CreatedBy *string `json:"createdBy,omitempty" yaml:"createdBy,omitempty"` + + // DataVersion Platform data version the artifact was rendered at; it is translated to the gateway's version when deployed + DataVersion *string `json:"dataVersion,omitempty" yaml:"dataVersion,omitempty"` + + // Description Note recorded with the build when it was prepared + Description *string `json:"description,omitempty" yaml:"description,omitempty"` + + // Metadata Metadata recorded with the build, such as the commit it was prepared from + Metadata *map[string]interface{} `json:"metadata,omitempty" yaml:"metadata,omitempty"` + + // Uuid Globally unique identifier for the build, and what a deployment references + Uuid openapi_types.UUID `binding:"required" json:"uuid" yaml:"uuid"` +} + // Channel Defines a single channel within the Async API type Channel struct { // Description Description of the channel @@ -1026,9 +1073,23 @@ type CustomPolicyResponse struct { // DeployRequest defines model for DeployRequest. type DeployRequest struct { - // Base The source for the API definition. Can be "current" (latest working copy) or a deploymentId (existing deployment) + // Base Where the artifact comes from: + // + // - `current` — render the artifact from the definition as it stands now. + // - `build` — deploy a build prepared earlier, named by `buildId`. + // + // REST API deployments accept only these two and always run a build: `current` + // stores what it renders as one, so a running deployment is always traceable to + // a stored snapshot. MCP proxy, LLM and event API deployments accept a + // `deploymentId` here as well, to promote that deployment by reusing its + // rendered artifact. Base string `binding:"required" json:"base" yaml:"base"` + // BuildId The build to deploy, such as `2026-01-31-2`. Required when `base` is `build`, + // and rejected otherwise. Deploying a build ships that exact snapshot, so it + // cannot pick up edits made since it was prepared. + BuildId *string `json:"buildId,omitempty" yaml:"buildId,omitempty"` + // GatewayId Handle (URL-friendly slug) of the target gateway for this deployment GatewayId string `binding:"required" json:"gatewayId" yaml:"gatewayId"` @@ -1054,6 +1115,17 @@ type DeploymentResponse struct { // BaseDeploymentId UUID of the base deployment this was created from BaseDeploymentId *openapi_types.UUID `json:"baseDeploymentId" yaml:"baseDeploymentId"` + // BuildId Build this deployment runs, such as `2026-01-31-2`. Every REST API deployment + // has one: `base: build` runs the build it names, and `base: current` stores what + // it renders as a build and runs that. + // + // Null for artifact kinds that have no builds — MCP proxy, LLM and event API + // deployments — including one promoted from another deployment, which reuses that + // deployment's rendered artifact. Also null once the build it ran has been pruned. + // Null means only that no build can be named; the deployment keeps its own + // rendered artifact either way. + BuildId *string `json:"buildId" yaml:"buildId"` + // CreatedAt Timestamp when the deployment artifact was created CreatedAt time.Time `binding:"required" json:"createdAt" yaml:"createdAt"` @@ -3104,6 +3176,12 @@ type ListRESTAPIsParamsSortBy string // ListRESTAPIsParamsSortOrder defines parameters for ListRESTAPIs. type ListRESTAPIsParamsSortOrder string +// GetBuildsParams defines parameters for GetBuilds. +type GetBuildsParams struct { + // Limit Maximum number of items to return per page. + Limit *LimitQ `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` +} + // GetDeploymentsParams defines parameters for GetDeployments. type GetDeploymentsParams struct { // GatewayId **Gateway ID** consisting of the **handle** (unique slug identifier) of the Gateway to filter status by. @@ -3290,6 +3368,9 @@ type CreateAPIKeyJSONRequestBody = CreateAPIKeyRequest // UpdateAPIKeyJSONRequestBody defines body for UpdateAPIKey for application/json ContentType. type UpdateAPIKeyJSONRequestBody = UpdateAPIKeyRequest +// CreateBuildJSONRequestBody defines body for CreateBuild for application/json ContentType. +type CreateBuildJSONRequestBody = BuildRequest + // DeployAPIJSONRequestBody defines body for DeployAPI for application/json ContentType. type DeployAPIJSONRequestBody = DeployRequest diff --git a/platform-api/config/config-template.toml b/platform-api/config/config-template.toml index 6dce002ba2..df88f20404 100644 --- a/platform-api/config/config-template.toml +++ b/platform-api/config/config-template.toml @@ -374,6 +374,9 @@ enable_functionality_type_verification = false # --------------------------------------------------------------------------- [platform_api.deployments] max_per_api_gateway = 20 # maximum API deployments per gateway +max_builds_per_api = 5 # maximum stored builds per API; preparing more removes + # the oldest builds no deployment holds, and is refused when + # every build is held (0 = keep all) # Deployment timeout — mark stuck deployments as failed after timeout_duration seconds. timeout_enabled = true diff --git a/platform-api/config/config.go b/platform-api/config/config.go index db4373dd37..91fbd59e8c 100644 --- a/platform-api/config/config.go +++ b/platform-api/config/config.go @@ -554,10 +554,15 @@ type Database struct { // Deployments holds deployment-specific configuration. type Deployments struct { - MaxPerAPIGateway int `koanf:"max_per_api_gateway"` - TimeoutEnabled bool `koanf:"timeout_enabled"` - TimeoutInterval int `koanf:"timeout_interval"` - TimeoutDuration int `koanf:"timeout_duration"` + MaxPerAPIGateway int `koanf:"max_per_api_gateway"` + // MaxBuildsPerAPI caps how many builds are stored per API. Preparing another + // one at the cap first removes the API's oldest builds that no deployment + // holds; if every build is held, the prepare is refused rather than taking a + // build something can still be restored from. Zero or less keeps every build. + MaxBuildsPerAPI int `koanf:"max_builds_per_api"` + TimeoutEnabled bool `koanf:"timeout_enabled"` + TimeoutInterval int `koanf:"timeout_interval"` + TimeoutDuration int `koanf:"timeout_duration"` } // APIKey holds API key-specific configuration. diff --git a/platform-api/config/default_config.go b/platform-api/config/default_config.go index cc4f6a24c9..a122278af3 100644 --- a/platform-api/config/default_config.go +++ b/platform-api/config/default_config.go @@ -109,6 +109,7 @@ func defaultConfig() *Server { }, Deployments: Deployments{ MaxPerAPIGateway: 20, + MaxBuildsPerAPI: 5, TimeoutEnabled: true, TimeoutInterval: 20, TimeoutDuration: 60, diff --git a/platform-api/internal/apperror/catalog.go b/platform-api/internal/apperror/catalog.go index cc7ae25dae..a3e0f0c4d1 100644 --- a/platform-api/internal/apperror/catalog.go +++ b/platform-api/internal/apperror/catalog.go @@ -121,6 +121,9 @@ var ( // MCP proxy deployment operations. DeploymentNotActive's verb is the artifact // kind, e.g. "API", "LLM provider". var ( + BuildNotFound = def(CodeBuildNotFound, http.StatusNotFound, "The specified build could not be found.") + BuildLimitReached = def(CodeBuildLimitReached, http.StatusConflict, "This API already has its maximum of %d builds, and every one is in use by a deployment. Undeploy one, or delete a build you no longer need, to make room for another.") + BuildInUse = def(CodeBuildInUse, http.StatusConflict, "The build is on a gateway and cannot be deleted. Undeploy it first, then delete the build.") DeploymentBaseNotFound = def(CodeDeploymentBaseNotFound, http.StatusNotFound, "The specified base deployment could not be found.") DeploymentRestoreConflict = def(CodeDeploymentRestoreConflict, http.StatusConflict, "Cannot restore the currently deployed deployment, or the deployment is invalid.") DeploymentNotFound = def(CodeDeploymentNotFound, http.StatusNotFound, "The specified deployment could not be found.") diff --git a/platform-api/internal/apperror/catalog_test.go b/platform-api/internal/apperror/catalog_test.go index 05334ba3cb..f832734c75 100644 --- a/platform-api/internal/apperror/catalog_test.go +++ b/platform-api/internal/apperror/catalog_test.go @@ -44,6 +44,7 @@ var messageArity = map[string]int{ CodeOf(LLMProxyDeploymentValidationFailed): 1, CodeOf(MCPProxyDeploymentValidationFailed): 1, CodeOf(DeploymentNotActive): 1, + CodeOf(BuildLimitReached): 1, CodeOf(ArtifactReadOnly): 1, CodeOf(ArtifactRuntimeImmutable): 1, CodeOf(ArtifactDeployed): 1, diff --git a/platform-api/internal/apperror/codes.go b/platform-api/internal/apperror/codes.go index 776d3c9090..b703e7103c 100644 --- a/platform-api/internal/apperror/codes.go +++ b/platform-api/internal/apperror/codes.go @@ -84,6 +84,9 @@ const ( // Deployment domain codes, shared across REST API / LLM provider / LLM proxy / // MCP proxy deployment operations (identical conditions across all four). const ( + CodeBuildNotFound = "BUILD_NOT_FOUND" + CodeBuildLimitReached = "BUILD_LIMIT_REACHED" + CodeBuildInUse = "BUILD_IN_USE" CodeDeploymentBaseNotFound = "DEPLOYMENT_BASE_NOT_FOUND" CodeDeploymentRestoreConflict = "DEPLOYMENT_RESTORE_CONFLICT" CodeDeploymentNotFound = "DEPLOYMENT_NOT_FOUND" diff --git a/platform-api/internal/database/schema.postgres.sql b/platform-api/internal/database/schema.postgres.sql index a37fb640bc..277286de31 100644 --- a/platform-api/internal/database/schema.postgres.sql +++ b/platform-api/internal/database/schema.postgres.sql @@ -265,6 +265,23 @@ CREATE TABLE IF NOT EXISTS gateway_tokens ( FOREIGN KEY (gateway_uuid) REFERENCES gateways(uuid) ON DELETE CASCADE ); +-- Builds table (immutable rendered snapshots of an API's definition) +CREATE TABLE IF NOT EXISTS builds ( + uuid VARCHAR(40) PRIMARY KEY, + build_id VARCHAR(40) NOT NULL, + artifact_uuid VARCHAR(40) NOT NULL, + organization_uuid VARCHAR(40) NOT NULL, + description VARCHAR(1023), + content BYTEA NOT NULL, + data_version VARCHAR(20) NOT NULL DEFAULT '1.0', + metadata BYTEA, + created_by VARCHAR(200), + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + UNIQUE (artifact_uuid, build_id), + FOREIGN KEY (artifact_uuid) REFERENCES artifacts(uuid) ON DELETE CASCADE, + FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE +); + -- Artifact Deployments table (immutable deployment artifacts) CREATE TABLE IF NOT EXISTS deployments ( uuid VARCHAR(40) PRIMARY KEY, @@ -273,11 +290,13 @@ CREATE TABLE IF NOT EXISTS deployments ( organization_uuid VARCHAR(40) NOT NULL, gateway_uuid VARCHAR(40) NOT NULL, base_deployment_uuid VARCHAR(40), + build_uuid VARCHAR(40), content BYTEA NOT NULL, metadata BYTEA, data_version VARCHAR(20) NOT NULL DEFAULT '1.0', created_by VARCHAR(200), created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (build_uuid) REFERENCES builds(uuid) ON DELETE NO ACTION, FOREIGN KEY (artifact_uuid) REFERENCES artifacts(uuid) ON DELETE CASCADE, FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE, FOREIGN KEY (gateway_uuid) REFERENCES gateways(uuid) ON DELETE CASCADE, @@ -488,6 +507,8 @@ CREATE INDEX IF NOT EXISTS idx_subscription_plans_status ON subscription_plans(s CREATE INDEX IF NOT EXISTS idx_subscription_plan_limits_plan ON subscription_plan_limits(subscription_plan_uuid); CREATE INDEX IF NOT EXISTS idx_artifact_subscription_plans_plan ON artifact_subscription_plans(subscription_plan_uuid); +CREATE INDEX IF NOT EXISTS idx_builds_artifact ON builds(artifact_uuid, organization_uuid, created_at); +CREATE INDEX IF NOT EXISTS idx_deployments_build ON deployments(build_uuid); -- EventHub tables for multi-replica HA sync CREATE TABLE IF NOT EXISTS gateway_states ( diff --git a/platform-api/internal/database/schema.sql b/platform-api/internal/database/schema.sql index f360c3f0f3..24b7789b01 100644 --- a/platform-api/internal/database/schema.sql +++ b/platform-api/internal/database/schema.sql @@ -257,6 +257,23 @@ CREATE TABLE IF NOT EXISTS gateway_tokens ( FOREIGN KEY (gateway_uuid) REFERENCES gateways(uuid) ON DELETE CASCADE ); +-- Builds table (immutable rendered snapshots of an API's definition) +CREATE TABLE IF NOT EXISTS builds ( + uuid VARCHAR(40) PRIMARY KEY, + build_id VARCHAR(40) NOT NULL, + artifact_uuid VARCHAR(40) NOT NULL, + organization_uuid VARCHAR(40) NOT NULL, + description VARCHAR(1023), + content BLOB NOT NULL, + data_version VARCHAR(20) NOT NULL DEFAULT '1.0', + metadata BLOB, + created_by VARCHAR(200), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE (artifact_uuid, build_id), + FOREIGN KEY (artifact_uuid) REFERENCES artifacts(uuid) ON DELETE CASCADE, + FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE +); + -- Artifact Deployments table (immutable deployment artifacts) CREATE TABLE IF NOT EXISTS deployments ( uuid VARCHAR(40) PRIMARY KEY, @@ -265,11 +282,13 @@ CREATE TABLE IF NOT EXISTS deployments ( organization_uuid VARCHAR(40) NOT NULL, gateway_uuid VARCHAR(40) NOT NULL, base_deployment_uuid VARCHAR(40), + build_uuid VARCHAR(40), content BLOB NOT NULL, metadata BLOB, data_version VARCHAR(20) NOT NULL DEFAULT '1.0', created_by VARCHAR(200), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (build_uuid) REFERENCES builds(uuid) ON DELETE NO ACTION, FOREIGN KEY (artifact_uuid) REFERENCES artifacts(uuid) ON DELETE CASCADE, FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE, FOREIGN KEY (gateway_uuid) REFERENCES gateways(uuid) ON DELETE CASCADE, @@ -483,6 +502,8 @@ CREATE INDEX IF NOT EXISTS idx_subscription_plans_status ON subscription_plans(s CREATE INDEX IF NOT EXISTS idx_subscription_plan_limits_plan ON subscription_plan_limits(subscription_plan_uuid); CREATE INDEX IF NOT EXISTS idx_artifact_subscription_plans_plan ON artifact_subscription_plans(subscription_plan_uuid); +CREATE INDEX IF NOT EXISTS idx_builds_artifact ON builds(artifact_uuid, organization_uuid, created_at); +CREATE INDEX IF NOT EXISTS idx_deployments_build ON deployments(build_uuid); -- EventHub tables for multi-replica HA sync CREATE TABLE IF NOT EXISTS gateway_states ( diff --git a/platform-api/internal/database/schema.sqlite.sql b/platform-api/internal/database/schema.sqlite.sql index 9f009f6262..1e73cb455a 100644 --- a/platform-api/internal/database/schema.sqlite.sql +++ b/platform-api/internal/database/schema.sqlite.sql @@ -265,6 +265,23 @@ CREATE TABLE IF NOT EXISTS gateway_tokens ( FOREIGN KEY (gateway_uuid) REFERENCES gateways(uuid) ON DELETE CASCADE ); +-- Builds table (immutable rendered snapshots of an API's definition) +CREATE TABLE IF NOT EXISTS builds ( + uuid VARCHAR(40) PRIMARY KEY, + build_id VARCHAR(40) NOT NULL, + artifact_uuid VARCHAR(40) NOT NULL, + organization_uuid VARCHAR(40) NOT NULL, + description VARCHAR(1023), + content BLOB NOT NULL, + data_version VARCHAR(20) NOT NULL DEFAULT '1.0', + metadata BLOB, + created_by VARCHAR(200), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE (artifact_uuid, build_id), + FOREIGN KEY (artifact_uuid) REFERENCES artifacts(uuid) ON DELETE CASCADE, + FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE +); + -- Artifact Deployments table (immutable deployment artifacts) CREATE TABLE IF NOT EXISTS deployments ( uuid VARCHAR(40) PRIMARY KEY, @@ -273,11 +290,13 @@ CREATE TABLE IF NOT EXISTS deployments ( organization_uuid VARCHAR(40) NOT NULL, gateway_uuid VARCHAR(40) NOT NULL, base_deployment_uuid VARCHAR(40), + build_uuid VARCHAR(40), content BLOB NOT NULL, metadata BLOB, data_version VARCHAR(20) NOT NULL DEFAULT '1.0', created_by VARCHAR(200), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (build_uuid) REFERENCES builds(uuid) ON DELETE NO ACTION, FOREIGN KEY (artifact_uuid) REFERENCES artifacts(uuid) ON DELETE CASCADE, FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE, FOREIGN KEY (gateway_uuid) REFERENCES gateways(uuid) ON DELETE CASCADE, @@ -488,6 +507,8 @@ CREATE INDEX IF NOT EXISTS idx_subscription_plans_status ON subscription_plans(s CREATE INDEX IF NOT EXISTS idx_subscription_plan_limits_plan ON subscription_plan_limits(subscription_plan_uuid); CREATE INDEX IF NOT EXISTS idx_artifact_subscription_plans_plan ON artifact_subscription_plans(subscription_plan_uuid); +CREATE INDEX IF NOT EXISTS idx_builds_artifact ON builds(artifact_uuid, organization_uuid, created_at); +CREATE INDEX IF NOT EXISTS idx_deployments_build ON deployments(build_uuid); -- EventHub tables for multi-replica HA sync CREATE TABLE IF NOT EXISTS gateway_states ( diff --git a/platform-api/internal/database/schema.sqlserver.sql b/platform-api/internal/database/schema.sqlserver.sql index 454a229575..58d74791ac 100644 --- a/platform-api/internal/database/schema.sqlserver.sql +++ b/platform-api/internal/database/schema.sqlserver.sql @@ -293,6 +293,27 @@ CREATE TABLE dbo.gateway_tokens ( FOREIGN KEY (gateway_uuid) REFERENCES gateways(uuid) ON DELETE CASCADE ); +-- Builds table (immutable rendered snapshots of an API's definition) +IF OBJECT_ID(N'dbo.builds', N'U') IS NULL +CREATE TABLE dbo.builds ( + uuid VARCHAR(40) PRIMARY KEY, + build_id VARCHAR(40) NOT NULL, + artifact_uuid VARCHAR(40) NOT NULL, + organization_uuid VARCHAR(40) NOT NULL, + description VARCHAR(1023), + content VARBINARY(MAX) NOT NULL, + data_version VARCHAR(20) NOT NULL DEFAULT '1.0', + metadata VARBINARY(MAX), + created_by VARCHAR(200), + created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), + UNIQUE (artifact_uuid, build_id), + FOREIGN KEY (artifact_uuid) REFERENCES artifacts(uuid) ON DELETE CASCADE, + -- NO ACTION to avoid the SQL Server multiple-cascade-paths restriction + -- (error 1785); organization deletes still reach builds through + -- organizations -> artifacts -> builds. + FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION +); + -- Artifact Deployments table (immutable deployment artifacts) IF OBJECT_ID(N'dbo.deployments', N'U') IS NULL CREATE TABLE dbo.deployments ( @@ -302,11 +323,16 @@ CREATE TABLE dbo.deployments ( organization_uuid VARCHAR(40) NOT NULL, gateway_uuid VARCHAR(40) NOT NULL, base_deployment_uuid VARCHAR(40), + build_uuid VARCHAR(40), content VARBINARY(MAX) NOT NULL, metadata VARBINARY(MAX), data_version VARCHAR(20) NOT NULL DEFAULT '1.0', created_by VARCHAR(200), created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), + -- NO ACTION, with references cleared explicitly before a build is pruned: + -- cleanup here is done in code, in dependency order, rather than left to the + -- database (SQL Server also forbids further cascade paths onto this table). + FOREIGN KEY (build_uuid) REFERENCES builds(uuid) ON DELETE NO ACTION, FOREIGN KEY (artifact_uuid) REFERENCES artifacts(uuid) ON DELETE CASCADE, -- NO ACTION to avoid the SQL Server multiple-cascade-paths restriction -- (error 1785). Organization deletes still reach deployments through @@ -585,6 +611,10 @@ CREATE INDEX idx_subscription_plan_limits_plan ON dbo.subscription_plan_limits(s IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_artifact_subscription_plans_plan' AND object_id = OBJECT_ID(N'dbo.artifact_subscription_plans')) CREATE INDEX idx_artifact_subscription_plans_plan ON dbo.artifact_subscription_plans(subscription_plan_uuid); +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_builds_artifact' AND object_id = OBJECT_ID(N'dbo.builds')) +CREATE INDEX idx_builds_artifact ON dbo.builds(artifact_uuid, organization_uuid, created_at); +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_deployments_build' AND object_id = OBJECT_ID(N'dbo.deployments')) +CREATE INDEX idx_deployments_build ON dbo.deployments(build_uuid); -- EventHub tables for multi-replica HA sync and gateway event propagation. -- Keyed columns are bounded NVARCHAR to stay within SQL Server index-key limits. diff --git a/platform-api/internal/dto/api.go b/platform-api/internal/dto/api.go index 77f56366d3..c04df6474c 100644 --- a/platform-api/internal/dto/api.go +++ b/platform-api/internal/dto/api.go @@ -82,14 +82,6 @@ type Policy struct { Version string `json:"version" yaml:"version"` } -// DeployAPIRequest represents a request to deploy an API -type DeployAPIRequest struct { - Name string `json:"name" yaml:"name"` // Deployment name - Base string `json:"base" yaml:"base"` // "current" or a deploymentId - GatewayID string `json:"gatewayId" yaml:"gatewayId"` // Target gateway ID - Metadata map[string]interface{} `json:"metadata,omitempty" yaml:"metadata,omitempty"` // Flexible key-value metadata -} - // DeploymentResponse represents a deployment artifact type DeploymentResponse struct { DeploymentID string `json:"deploymentId" yaml:"deploymentId"` @@ -166,4 +158,3 @@ type APIListResponse struct { List []*API `json:"list" yaml:"list"` // Array of API objects Pagination Pagination `json:"pagination" yaml:"pagination"` // Pagination metadata } - diff --git a/platform-api/internal/handler/api_deployment.go b/platform-api/internal/handler/api_deployment.go index 8167d79b2a..bd04548bb8 100644 --- a/platform-api/internal/handler/api_deployment.go +++ b/platform-api/internal/handler/api_deployment.go @@ -19,7 +19,9 @@ package handler import ( "encoding/json" + "errors" "fmt" + "io" "log/slog" "net/http" "strings" @@ -30,6 +32,7 @@ import ( "github.com/wso2/api-platform/platform-api/internal/middleware" "github.com/wso2/api-platform/platform-api/internal/router" "github.com/wso2/api-platform/platform-api/internal/service" + "github.com/wso2/api-platform/platform-api/internal/utils" "github.com/wso2/api-platform/httpkit/httputil" ) @@ -72,7 +75,10 @@ func (h *DeploymentHandler) DeployAPI(w http.ResponseWriter, r *http.Request) er return apperror.RESTAPIDeploymentValidationFailed.New("name is required") } if req.Base == "" { - return apperror.RESTAPIDeploymentValidationFailed.New("base is required (use 'current' or a deploymentId)") + return apperror.RESTAPIDeploymentValidationFailed.New("base is required (use 'current' or 'build')") + } + if req.Base == "build" && utils.ValueOrEmpty(req.BuildId) == "" { + return apperror.RESTAPIDeploymentValidationFailed.New("buildId is required when base is 'build'") } if strings.TrimSpace(req.GatewayId) == "" { return apperror.RESTAPIDeploymentValidationFailed.New("gatewayId is required") @@ -271,6 +277,133 @@ func (h *DeploymentHandler) GetDeployments(w http.ResponseWriter, r *http.Reques return nil } +// CreateBuild handles POST /api/v0.9/rest-apis/:apiId/builds +// Renders the API's current definition into an immutable snapshot, without deploying it +func (h *DeploymentHandler) CreateBuild(w http.ResponseWriter, r *http.Request) error { + orgId, exists := middleware.GetOrganizationFromRequest(r) + if !exists { + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") + } + + apiId := r.PathValue("restApiId") + if apiId == "" { + return apperror.ValidationFailed.New("API ID is required") + } + + createdBy, err := resolveActorErr(r, h.identity, "prepare API build") + if err != nil { + return err + } + + // The body is optional: preparing a build needs nothing beyond the API, and + // metadata is there for callers that have an origin to record. + var req api.BuildRequest + if r.Body != nil && r.ContentLength != 0 { + // A chunked request carries no length, so an empty one only shows up here + // as EOF; that is still an absent body rather than a malformed one. + if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { + return apperror.ValidationFailed.New("Request body is not valid JSON") + } + } + var metadata map[string]interface{} + if req.Metadata != nil { + metadata = *req.Metadata + } + var description string + if req.Description != nil { + description = strings.TrimSpace(*req.Description) + } + + build, err := h.deploymentService.CreateBuildByHandle(apiId, orgId, createdBy, description, metadata) + if err != nil { + return serviceError(err, fmt.Sprintf("failed to prepare a build for API %s", apiId)) + } + + setLocation(w, "rest-apis", apiId, "builds", build.BuildId) + httputil.WriteJSON(w, http.StatusCreated, build) + return nil +} + +// GetBuilds handles GET /api/v0.9/rest-apis/:apiId/builds +// Lists the API's builds, newest first +func (h *DeploymentHandler) GetBuilds(w http.ResponseWriter, r *http.Request) error { + orgId, exists := middleware.GetOrganizationFromRequest(r) + if !exists { + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") + } + + apiId := r.PathValue("restApiId") + if apiId == "" { + return apperror.ValidationFailed.New("API ID is required") + } + + limit, _ := parsePagination(r) + builds, err := h.deploymentService.GetBuildsByHandle(apiId, orgId, limit) + if err != nil { + return serviceError(err, fmt.Sprintf("failed to get builds for API %s", apiId)) + } + + httputil.WriteJSON(w, http.StatusOK, builds) + return nil +} + +// GetBuild handles GET /api/v0.9/rest-apis/:apiId/builds/:buildId +// Retrieves metadata for a single build +func (h *DeploymentHandler) GetBuild(w http.ResponseWriter, r *http.Request) error { + orgId, exists := middleware.GetOrganizationFromRequest(r) + if !exists { + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") + } + + apiId := r.PathValue("restApiId") + buildId := r.PathValue("buildId") + + if apiId == "" { + return apperror.ValidationFailed.New("API ID is required") + } + if buildId == "" { + return apperror.ValidationFailed.New("Build ID is required") + } + + build, err := h.deploymentService.GetBuildByHandle(apiId, buildId, orgId) + if err != nil { + return serviceError(err, fmt.Sprintf("failed to get API %s build %s", apiId, buildId)) + } + + httputil.WriteJSON(w, http.StatusOK, build) + return nil +} + +// DeleteBuild handles DELETE /api/v0.9/rest-apis/:apiId/builds/:buildId +// Removes a build, unless a deployment still holds it +func (h *DeploymentHandler) DeleteBuild(w http.ResponseWriter, r *http.Request) error { + orgId, exists := middleware.GetOrganizationFromRequest(r) + if !exists { + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") + } + + apiId := r.PathValue("restApiId") + buildId := r.PathValue("buildId") + + if apiId == "" { + return apperror.ValidationFailed.New("API ID is required") + } + if buildId == "" { + return apperror.ValidationFailed.New("Build ID is required") + } + + if err := h.deploymentService.DeleteBuildByHandle(apiId, buildId, orgId); err != nil { + return serviceError(err, fmt.Sprintf("failed to delete API %s build %s", apiId, buildId)) + } + + w.WriteHeader(http.StatusNoContent) + return nil +} + // RegisterRoutes registers all deployment-related routes func (h *DeploymentHandler) RegisterRoutes(mux router.Router) { h.slogger.Debug("Registering deployment routes") @@ -281,4 +414,8 @@ func (h *DeploymentHandler) RegisterRoutes(mux router.Router) { mux.HandleFunc("GET "+base+"/deployments", middleware.MapErrors(h.slogger, h.GetDeployments)) mux.HandleFunc("GET "+base+"/deployments/{deploymentId}", middleware.MapErrors(h.slogger, h.GetDeployment)) mux.HandleFunc("DELETE "+base+"/deployments/{deploymentId}", middleware.MapErrors(h.slogger, h.DeleteDeployment)) + mux.HandleFunc("POST "+base+"/builds", middleware.MapErrors(h.slogger, h.CreateBuild)) + mux.HandleFunc("GET "+base+"/builds", middleware.MapErrors(h.slogger, h.GetBuilds)) + mux.HandleFunc("GET "+base+"/builds/{buildId}", middleware.MapErrors(h.slogger, h.GetBuild)) + mux.HandleFunc("DELETE "+base+"/builds/{buildId}", middleware.MapErrors(h.slogger, h.DeleteBuild)) } diff --git a/platform-api/internal/model/deployment.go b/platform-api/internal/model/deployment.go index 0c41329771..e38a9bbae9 100644 --- a/platform-api/internal/model/deployment.go +++ b/platform-api/internal/model/deployment.go @@ -31,6 +31,7 @@ type Deployment struct { OrganizationID string `json:"organizationId" db:"organization_uuid"` GatewayID string `json:"gatewayId" db:"gateway_uuid"` BaseDeploymentID *string `json:"baseDeploymentId,omitempty" db:"base_deployment_uuid"` + BuildUUID *string `json:"buildUuid,omitempty" db:"build_uuid"` Content []byte `json:"-" db:"content"` Metadata map[string]any `json:"metadata,omitempty" db:"metadata"` CreatedBy string `json:"createdBy,omitempty" db:"created_by"` @@ -41,6 +42,12 @@ type Deployment struct { Status *DeploymentStatus `json:"status,omitempty" db:"status"` UpdatedAt *time.Time `json:"updatedAt,omitempty" db:"status_updated_at"` StatusReason *string `json:"statusReason,omitempty" db:"status_reason"` + + // BuildID is the readable id of the build BuildUUID points at, joined from the + // builds table rather than stored here: one place records the origin, and the + // name for it is always read back through that, so the two cannot disagree. Nil + // whenever BuildUUID is. + BuildID *string `json:"buildId,omitempty" db:"build_id"` } // TableName returns the table name for the Deployment model @@ -48,6 +55,40 @@ func (Deployment) TableName() string { return "deployments" } +// Build is an immutable, rendered snapshot of an API's definition that is NOT +// bound to a gateway. Preparing a build and deploying it are separate steps, so +// what reaches a gateway is a snapshot taken at a known moment rather than +// whatever the definition happens to be when the deploy runs — and the same +// build can then be deployed to any number of gateways, and promoted onward, +// without being re-rendered. +// +// Content is stored at the platform's own DataVersion, untranslated: the target +// gateway's version is only known at deploy time, so translation happens there. +type Build struct { + // UUID is the build's globally unique identity, and what a deployment + // references. BuildID is the readable id people use, unique within the API. + UUID string `json:"uuid" db:"uuid"` + BuildID string `json:"buildId" db:"build_id"` + ArtifactID string `json:"artifactId" db:"artifact_uuid"` + OrganizationID string `json:"organizationId" db:"organization_uuid"` + // Description is an optional note the caller records with the build, to tell + // one snapshot from another when choosing what to deploy or what to delete. + Description string `json:"description,omitempty" db:"description"` + Content []byte `json:"-" db:"content"` + DataVersion string `json:"dataVersion" db:"data_version"` + // Metadata is a free-form bag recorded with the build. It carries where the + // build came from — a commit for an API kept in a repository, for instance — + // so a running deployment can be traced back to its origin. + Metadata map[string]any `json:"metadata,omitempty" db:"metadata"` + CreatedBy string `json:"createdBy,omitempty" db:"created_by"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` +} + +// TableName returns the table name for the Build model +func (Build) TableName() string { + return "builds" +} + // DeploymentContent holds the artifact content for a single deployment, // used internally when constructing batch archive responses. type DeploymentContent struct { diff --git a/platform-api/internal/repository/api.go b/platform-api/internal/repository/api.go index 115179d061..f715f4f537 100644 --- a/platform-api/internal/repository/api.go +++ b/platform-api/internal/repository/api.go @@ -459,14 +459,18 @@ func (r *APIRepo) DeleteAPI(apiUUID, orgUUID string) error { deleteQueries := []string{ // Delete API deployments `DELETE FROM deployments WHERE artifact_uuid = ? AND organization_uuid = ?`, + // Then the builds they were made from: deployments reference builds, so the + // referencing rows have to go first. + `DELETE FROM builds WHERE artifact_uuid = ? AND organization_uuid = ?`, // Delete from rest_apis table first, then artifacts `DELETE FROM rest_apis WHERE uuid = ?`, } - // Execute all delete statements + // Execute all delete statements. The first two are scoped by organization as + // well as artifact; the rest by artifact alone. for i, query := range deleteQueries { switch i { - case 0: + case 0, 1: if _, err := tx.Exec(r.db.Rebind(query), apiUUID, orgUUID); err != nil { return err } diff --git a/platform-api/internal/repository/build.go b/platform-api/internal/repository/build.go new file mode 100644 index 0000000000..1a6c64c92d --- /dev/null +++ b/platform-api/internal/repository/build.go @@ -0,0 +1,607 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package repository + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/wso2/api-platform/platform-api/internal/model" + "github.com/wso2/api-platform/platform-api/internal/utils" +) + +// Build persistence lives on DeploymentRepo: a build is the deploy path's own +// input, and keeping it here avoids a second repository for one table. + +// ErrBuildLimitReached is returned when an API is at its build limit and every +// stored build is held by a deployment, so preparing another would have to remove +// one that is still needed. The remedy is the caller's to choose — which +// deployment to give up — so this surfaces rather than being resolved here. +var ErrBuildLimitReached = errors.New("build limit reached and no build is free to remove") + +// ErrBuildInUse is returned when a build a caller asked to delete is held by a +// deployment. +var ErrBuildInUse = errors.New("build is in use by a deployment") + +// ErrBuildNotFound is returned when the build named for deletion is not one of +// the API's builds. +var ErrBuildNotFound = errors.New("build not found") + +// buildHoldRule says which deployments count as HOLDING a build, and so whose +// references keep it alive. The two callers differ on purpose. +type buildHoldRule int + +const ( + // heldByAnyCurrent — a build is held while any deployment the status table + // still names references it, whatever that status is. Automatic pruning uses + // this. An ARCHIVED deployment (no status row) does NOT hold its build, which is + // what keeps repeated deploys to one gateway working: each supersedes the last, + // the superseded ones stop holding anything, and their builds become reclaimable + // without anyone being asked. A pipeline deploying to a single gateway would + // otherwise wedge on the first deploy past the limit. + heldByAnyCurrent buildHoldRule = iota + // heldByGateway — only a deployment that is on its gateway, or moving on or off + // it, holds the build. Deleting a build uses this, so a user can also reclaim + // the build behind a SUSPENDED or FAILED deployment — ones pruning deliberately + // leaves alone, because suspending something is not the same as being done with + // it. + heldByGateway +) + +// gatewayStatusFilter narrows a deployment_status join to the deployments a gateway +// is involved with right now. UNDEPLOYED and FAILED are absent on purpose: neither +// is on a gateway, so neither blocks a delete — though both still block pruning, +// which does not apply this filter. +const gatewayStatusFilter = ` AND s.status IN ('DEPLOYED', 'DEPLOYING', 'UNDEPLOYING')` + +// buildIDAttempts bounds the retries when deriving a build id. Two prepares of the +// same API on the same day compete for the same index, and the primary key is what +// settles it; a handful of attempts is far more than a real race needs. +const buildIDAttempts = 5 + +// CreateBuildWithLimitEnforcement stores a rendered snapshot of an API's +// definition, first pruning that API's older builds back within hardLimit. Builds +// are immutable, so there is no update — preparing again creates another build. +// +// A build id is readable rather than random: the date and that day's index for the +// API, e.g. 2026-01-31-1 then 2026-01-31-2. It is an id people name in a support +// ticket or a log line, which a UUID is not. It is unique per API, so the artifact +// is always part of resolving one. +func (r *DeploymentRepo) CreateBuildWithLimitEnforcement(build *model.Build, hardLimit int) error { + if err := initBuild(build); err != nil { + return err + } + return createWithDerivedBuildID(build, func() error { + return r.createBuild(build, hardLimit) + }) +} + +// initBuild fills in the identity and the timestamp a build is stored with. +func initBuild(build *model.Build) error { + if build.UUID == "" { + buildUUID, err := utils.GenerateUUID() + if err != nil { + return fmt.Errorf("failed to generate build UUID: %w", err) + } + build.UUID = buildUUID + } + if build.CreatedAt.IsZero() { + build.CreatedAt = time.Now().UTC() + } else { + build.CreatedAt = build.CreatedAt.UTC() + } + return nil +} + +// createWithDerivedBuildID runs one attempt at a time until the id derived for the +// build sticks: the loser of a race for the same index simply derives the next one +// and tries again. An id the caller chose is used as given — there is no index to +// re-derive, so a failure with one is final. +func createWithDerivedBuildID(build *model.Build, attempt func() error) error { + if build.BuildID != "" { + return attempt() + } + var err error + for i := 0; i < buildIDAttempts; i++ { + derived := build.BuildID + // Cleared so the attempt derives the next free index rather than reusing an + // id that has just been taken. + build.BuildID = "" + if err = attempt(); err == nil { + return nil + } + if errors.Is(err, ErrBuildLimitReached) { + // Not a race for an id — the attempt never got as far as deriving one. + // Retrying re-runs the same prune against the same builds and refuses + // again, so this is final. + return err + } + if i > 0 && build.BuildID == derived { + // The index this attempt derived is the one the last attempt already + // tried, so nothing took it in between: the failure is not a concurrent + // prepare and retrying cannot help. + return err + } + } + return err +} + +// createBuild is one attempt at storing a build on a transaction of its own. A +// failed attempt rolls all of it back, which is what leaves the caller free to +// retry. +func (r *DeploymentRepo) createBuild(build *model.Build, hardLimit int) error { + tx, err := r.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + if err := r.storeBuild(tx, build, hardLimit); err != nil { + return err + } + return tx.Commit() +} + +// storeBuild prunes, derives the id when the build has none, and inserts — the +// whole of adding a build, on a transaction the caller owns. Deciding a build is +// expendable and referencing one are the same judgement about what is still +// needed, so they are settled together; a deploy from the API's definition runs +// this on the transaction that records the deployment, so the build and the +// deployment that names it commit as one. +func (r *DeploymentRepo) storeBuild(tx *sql.Tx, build *model.Build, hardLimit int) error { + if err := r.pruneBuilds(tx, build.ArtifactID, build.OrganizationID, hardLimit); err != nil { + return err + } + if build.BuildID == "" { + buildID, err := r.nextBuildID(tx, build.ArtifactID, build.OrganizationID, build.CreatedAt) + if err != nil { + return err + } + build.BuildID = buildID + } + return r.insertBuild(tx, build) +} + +// nextBuildID returns the next unused id for an API on the given day. Reading the +// day's ids and taking the highest index — rather than counting rows — keeps the +// sequence correct even after an API's builds are pruned. +func (r *DeploymentRepo) nextBuildID(tx *sql.Tx, artifactUUID, orgUUID string, day time.Time) (string, error) { + prefix := day.UTC().Format("2006-01-02") + "-" + const query = ` + SELECT build_id + FROM builds + WHERE artifact_uuid = ? AND organization_uuid = ? AND build_id LIKE ? + ` + rows, err := tx.Query(r.db.Rebind(query), artifactUUID, orgUUID, prefix+"%") + if err != nil { + return "", fmt.Errorf("failed to read build ids: %w", err) + } + defer rows.Close() + + highest := 0 + for rows.Next() { + var buildID string + if err := rows.Scan(&buildID); err != nil { + return "", fmt.Errorf("failed to scan build id: %w", err) + } + index, err := strconv.Atoi(strings.TrimPrefix(buildID, prefix)) + if err != nil { + continue + } + if index > highest { + highest = index + } + } + if err := rows.Err(); err != nil { + return "", fmt.Errorf("failed to read build ids: %w", err) + } + return prefix + strconv.Itoa(highest+1), nil +} + +// insertBuild writes one build row. +func (r *DeploymentRepo) insertBuild(tx *sql.Tx, build *model.Build) error { + var metadataBytes []byte + if len(build.Metadata) > 0 { + var err error + metadataBytes, err = json.Marshal(build.Metadata) + if err != nil { + return fmt.Errorf("failed to marshal build metadata: %w", err) + } + } + + const query = ` + INSERT INTO builds (uuid, build_id, artifact_uuid, organization_uuid, description, content, data_version, metadata, created_by, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ` + _, err := tx.Exec(r.db.Rebind(query), + build.UUID, build.BuildID, build.ArtifactID, build.OrganizationID, + build.Description, build.Content, build.DataVersion, metadataBytes, build.CreatedBy, build.CreatedAt, + ) + if err != nil { + return fmt.Errorf("failed to create build: %w", err) + } + return nil +} + +// applyBuildMetadata decodes the stored metadata bag onto the model. +func applyBuildMetadata(build *model.Build, metadataBytes []byte) error { + if len(metadataBytes) == 0 { + return nil + } + var metadata map[string]any + if err := json.Unmarshal(metadataBytes, &metadata); err != nil { + return fmt.Errorf("failed to unmarshal build metadata: %w", err) + } + build.Metadata = metadata + return nil +} + +// GetBuild returns one build of an API, including its content. Scoping by +// artifact and organization is what keeps a build id from another API — or +// another organization — resolving here. +func (r *DeploymentRepo) GetBuild(buildID, artifactUUID, orgUUID string) (*model.Build, error) { + const query = ` + SELECT uuid, build_id, artifact_uuid, organization_uuid, description, content, data_version, metadata, created_by, created_at + FROM builds + WHERE build_id = ? AND artifact_uuid = ? AND organization_uuid = ? + ` + var build model.Build + var createdBy, description sql.NullString + var metadataBytes []byte + err := r.db.QueryRow(r.db.Rebind(query), buildID, artifactUUID, orgUUID).Scan( + &build.UUID, &build.BuildID, &build.ArtifactID, &build.OrganizationID, + &description, &build.Content, &build.DataVersion, &metadataBytes, &createdBy, &build.CreatedAt, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to get build: %w", err) + } + if err := applyBuildMetadata(&build, metadataBytes); err != nil { + return nil, err + } + build.CreatedBy = createdBy.String + build.Description = description.String + return &build, nil +} + +// GetBuilds lists an API's builds newest first, without their content — a +// listing is for choosing a build, and the artifacts are large. +func (r *DeploymentRepo) GetBuilds(artifactUUID, orgUUID string, limit int) ([]*model.Build, error) { + if limit <= 0 { + limit = 50 + } + query := ` + SELECT uuid, build_id, artifact_uuid, organization_uuid, description, data_version, metadata, created_by, created_at + FROM builds + WHERE artifact_uuid = ? AND organization_uuid = ? + ORDER BY created_at DESC, build_id DESC + ` + pageClause, pageArgs := r.db.PaginationClause(limit, 0) + query += " " + pageClause + args := append([]any{artifactUUID, orgUUID}, pageArgs...) + + rows, err := r.db.Query(r.db.Rebind(query), args...) + if err != nil { + return nil, fmt.Errorf("failed to list builds: %w", err) + } + defer rows.Close() + + builds := make([]*model.Build, 0) + for rows.Next() { + var build model.Build + var createdBy, description sql.NullString + var metadataBytes []byte + if err := rows.Scan( + &build.UUID, &build.BuildID, &build.ArtifactID, &build.OrganizationID, + &description, &build.DataVersion, &metadataBytes, &createdBy, &build.CreatedAt, + ); err != nil { + return nil, fmt.Errorf("failed to scan build: %w", err) + } + if err := applyBuildMetadata(&build, metadataBytes); err != nil { + return nil, err + } + build.CreatedBy = createdBy.String + build.Description = description.String + builds = append(builds, &build) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("failed to read builds: %w", err) + } + return builds, nil +} + +// DeleteBuild removes one of an API's builds by its readable id. +// +// A build a gateway is involved with is not deleted (ErrBuildInUse): taking the +// snapshot out from under a DEPLOYED, DEPLOYING or UNDEPLOYING deployment would +// leave it with nothing to trace back to or promote onward, and the definition as +// it stood cannot be rendered again. +// +// Everything else releases the build — SUSPENDED, FAILED, and ARCHIVED deployments +// alike. This is where the limit is actually reclaimed, and it is a request rather +// than a cleanup because of what it costs: those deployments each keep the rendered +// artifact they were created with, so they stay REDEPLOYABLE without their build, +// but they stop naming one, and so stop being something a later environment can be +// promoted from. Giving that up is the caller's call, which is why automatic +// pruning never makes it. +// +// Resolving the build, testing it and deleting it happen on one transaction, so a +// deploy cannot claim the build between the test and the delete. +func (r *DeploymentRepo) DeleteBuild(buildID, artifactUUID, orgUUID string) error { + tx, err := r.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + const findQuery = ` + SELECT uuid + FROM builds + WHERE build_id = ? AND artifact_uuid = ? AND organization_uuid = ? + ` + var buildUUID string + if err := tx.QueryRow(r.db.Rebind(findQuery), buildID, artifactUUID, orgUUID).Scan(&buildUUID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrBuildNotFound + } + return fmt.Errorf("failed to find build %s: %w", buildID, err) + } + + live, err := r.buildsInUse(tx, artifactUUID, orgUUID, heldByGateway) + if err != nil { + return err + } + if live[buildUUID] { + return ErrBuildInUse + } + + // releaseBuild's delete is conditional on nothing referencing the build, so a + // deploy that claimed it since the test above leaves the row in place — which is + // the same conflict, reported the same way rather than passed off as a success. + removed, err := r.releaseBuild(tx, buildUUID, heldByGateway) + if err != nil { + return err + } + if !removed { + return ErrBuildInUse + } + return tx.Commit() +} + +// pruneBuilds makes room for one more build within hardLimit. The budget is per +// API — one API's history cannot be crowded out by another's, and unlike +// deployments a build belongs to no gateway, so there is nothing narrower to count +// by. +// +// Age alone does not decide what goes. A build is removed only when no CURRENT +// deployment names it: a build something is still running, still suspended and +// redeployable, or still retryable after a failure, is one the status table points +// at, and the cleanup will not cut that link. Age only orders the builds that are +// free to go. +// +// An ARCHIVED deployment does not hold its build. That is what keeps a pipeline +// working: deploying repeatedly to one gateway supersedes the previous deployment +// each time, so the builds behind those deployments become reclaimable on their own +// and the limit is never reached by ordinary redeployment. The archived deployment +// keeps its own rendered artifact and stays redeployable; it simply stops naming a +// build, so it can no longer be promoted onward. +// +// When nothing is free the prepare is REFUSED (ErrBuildLimitReached) rather than +// quietly letting the API keep more than its budget: the limit is what an +// organization is entitled to store, so exceeding it has to be someone's decision. +// That happens when the API's builds are spread across gateways that are each +// running or holding one, and the remedy is to delete a build (DeleteBuild), which +// can also reclaim the ones behind suspended and failed deployments that pruning +// leaves alone. +// +// It removes as many free builds as the limit demands, not a fixed batch, so a +// limit that has been lowered converges on the first prepare. +// +// It runs on the caller's transaction, alongside the insert it makes room for, so +// what it reads about a build being in use still holds when it deletes. +func (r *DeploymentRepo) pruneBuilds(tx *sql.Tx, artifactUUID, orgUUID string, hardLimit int) error { + // A limit of zero or less means keep everything. + if hardLimit <= 0 { + return nil + } + + const countQuery = ` + SELECT COUNT(*) + FROM builds + WHERE artifact_uuid = ? AND organization_uuid = ? + ` + var count int + if err := tx.QueryRow(r.db.Rebind(countQuery), artifactUUID, orgUUID).Scan(&count); err != nil { + return fmt.Errorf("failed to count builds: %w", err) + } + if count < hardLimit { + return nil + } + // One slot for the build being added, plus whatever the API is over by — a + // limit lowered since the last prepare leaves it over by more than one. + needed := count - hardLimit + 1 + + inUse, err := r.buildsInUse(tx, artifactUUID, orgUUID, heldByAnyCurrent) + if err != nil { + return err + } + + const oldestQuery = ` + SELECT uuid + FROM builds + WHERE artifact_uuid = ? AND organization_uuid = ? + ORDER BY created_at ASC, build_id ASC + ` + rows, err := tx.Query(r.db.Rebind(oldestQuery), artifactUUID, orgUUID) + if err != nil { + return fmt.Errorf("failed to list builds for cleanup: %w", err) + } + var expendable []string + for rows.Next() { + var buildUUID string + if err := rows.Scan(&buildUUID); err != nil { + rows.Close() + return fmt.Errorf("failed to scan build for cleanup: %w", err) + } + if inUse[buildUUID] { + continue + } + expendable = append(expendable, buildUUID) + } + rows.Close() + if err := rows.Err(); err != nil { + return fmt.Errorf("failed to read builds for cleanup: %w", err) + } + if len(expendable) < needed { + return ErrBuildLimitReached + } + + // The reference is cleared before the row goes: deployments outlive the build + // they came from, so an archived one keeps its content and simply stops naming a + // build it can no longer resolve. + // + // Both statements re-test what buildsInUse read a moment ago, because a database + // that reads committed rows per statement lets a deploy land in between. Scoping + // the clear to archived deployments means one that has just become current never + // has its origin taken away, and a delete conditional on nothing referencing the + // build means one that has just been claimed simply stays. + // + // So the rows actually deleted are counted rather than assumed, and a candidate + // lost to that race is replaced by the next one rather than failing the prepare: + // every free build was collected above, not just the first `needed` of them, so + // there is something to fall back on. Only running out of candidates altogether + // refuses. + freed := 0 + for _, buildUUID := range expendable { + if freed == needed { + break + } + removed, err := r.releaseBuild(tx, buildUUID, heldByAnyCurrent) + if err != nil { + return err + } + if removed { + freed++ + } + } + if count-freed >= hardLimit { + return ErrBuildLimitReached + } + return nil +} + +// releaseBuild deletes a build, first clearing the references held by deployments +// that do not stand in its way. +// +// Deployments outlive the build they came from: an archived one — and, for a +// delete, a suspended or failed one — keeps the rendered artifact it was created +// with, so it stays redeployable and simply stops naming a build it can no longer +// resolve. The scope of what gets cleared is exactly the complement of the caller's +// hold rule, so pruning never takes a build from a deployment the status table +// still names. +// +// The DELETE re-tests that nothing references the build, because a database that +// reads committed rows per statement lets a deploy land between the caller's check +// and this one. A build claimed in that window simply stays, and the caller is told +// it was not freed rather than having the claim silently broken. +func (r *DeploymentRepo) releaseBuild(tx *sql.Tx, buildUUID string, rule buildHoldRule) (bool, error) { + clearQuery := ` + UPDATE deployments SET build_uuid = NULL + WHERE build_uuid = ? + AND NOT EXISTS ( + SELECT 1 FROM deployment_status s + WHERE s.deployment_uuid = deployments.uuid + AND s.artifact_uuid = deployments.artifact_uuid + AND s.organization_uuid = deployments.organization_uuid + AND s.gateway_uuid = deployments.gateway_uuid` + if rule == heldByGateway { + clearQuery += gatewayStatusFilter + } + clearQuery += ` + ) + ` + if _, err := tx.Exec(r.db.Rebind(clearQuery), buildUUID); err != nil { + return false, fmt.Errorf("failed to clear references to build %s: %w", buildUUID, err) + } + + const deleteQuery = ` + DELETE FROM builds + WHERE uuid = ? + AND NOT EXISTS (SELECT 1 FROM deployments d WHERE d.build_uuid = builds.uuid) + ` + res, err := tx.Exec(r.db.Rebind(deleteQuery), buildUUID) + if err != nil { + return false, fmt.Errorf("failed to delete build %s: %w", buildUUID, err) + } + affected, err := res.RowsAffected() + if err != nil { + // A driver that cannot report the count cannot be asked again; treating the + // delete as a no-op keeps the caller's accounting conservative, so the worst + // case is refusing a prepare that would have fit. + return false, nil + } + return affected > 0, nil +} + +// buildsInUse returns the builds of an API that are held under the given rule, by +// uuid. +// +// Both rules join the status table, so an archived deployment never holds a build +// either way. heldByGateway narrows further to the deployments a gateway is +// involved with, which is what lets a delete reclaim a suspended or failed +// deployment's build while pruning leaves it alone. +func (r *DeploymentRepo) buildsInUse(tx *sql.Tx, artifactUUID, orgUUID string, + rule buildHoldRule) (map[string]bool, error) { + query := ` + SELECT DISTINCT d.build_uuid + FROM deployments d + JOIN deployment_status s ON d.uuid = s.deployment_uuid + AND d.artifact_uuid = s.artifact_uuid + AND d.organization_uuid = s.organization_uuid + AND d.gateway_uuid = s.gateway_uuid + WHERE d.artifact_uuid = ? AND d.organization_uuid = ? AND d.build_uuid IS NOT NULL + ` + if rule == heldByGateway { + query += gatewayStatusFilter + } + rows, err := tx.Query(r.db.Rebind(query), artifactUUID, orgUUID) + if err != nil { + return nil, fmt.Errorf("failed to read deployed builds: %w", err) + } + defer rows.Close() + + inUse := map[string]bool{} + for rows.Next() { + var buildUUID string + if err := rows.Scan(&buildUUID); err != nil { + return nil, fmt.Errorf("failed to scan deployed build: %w", err) + } + inUse[buildUUID] = true + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("failed to read deployed builds: %w", err) + } + return inUse, nil +} diff --git a/platform-api/internal/repository/build_test.go b/platform-api/internal/repository/build_test.go new file mode 100644 index 0000000000..a2e04c40d0 --- /dev/null +++ b/platform-api/internal/repository/build_test.go @@ -0,0 +1,1084 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package repository + +import ( + "database/sql" + "errors" + "fmt" + "reflect" + "testing" + "time" + + "github.com/wso2/api-platform/platform-api/internal/apperror" + "github.com/wso2/api-platform/platform-api/internal/database" + "github.com/wso2/api-platform/platform-api/internal/model" +) + +const ( + buildRepoAPIUUID = "aaaaaaaa-0000-0000-0000-00000000000b" + buildRepoOrgUUID = "aaaaaaaa-0000-0000-0000-00000000000c" +) + +// buildOn returns a build of the test API prepared at the given instant. +func buildOn(day time.Time) *model.Build { + return &model.Build{ + ArtifactID: buildRepoAPIUUID, + OrganizationID: buildRepoOrgUUID, + Content: []byte("apiVersion: gateway.wso2.com/v1\nkind: RestApi\n"), + DataVersion: "1.0", + CreatedBy: "tester", + CreatedAt: day, + } +} + +// A build id is meant to be readable and said out loud: the day it was prepared +// and that day's index for the API. The index restarts with each date. +func TestCreateBuild_IDIsTheDateAndThatDaysIndex(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + createTestAPI(t, db, buildRepoAPIUUID, buildRepoOrgUUID) + repo := NewDeploymentRepo(db, NewArtifactTableRegistry()) + + first := time.Date(2026, 1, 31, 9, 0, 0, 0, time.UTC) + for _, want := range []string{"2026-01-31-1", "2026-01-31-2", "2026-01-31-3"} { + build := buildOn(first) + if err := repo.CreateBuildWithLimitEnforcement(build, 0); err != nil { + t.Fatalf("CreateBuild: %v", err) + } + if build.BuildID != want { + t.Fatalf("build id = %q, want %q", build.BuildID, want) + } + } + + nextDay := buildOn(time.Date(2026, 2, 1, 9, 0, 0, 0, time.UTC)) + if err := repo.CreateBuildWithLimitEnforcement(nextDay, 0); err != nil { + t.Fatalf("CreateBuild: %v", err) + } + if nextDay.BuildID != "2026-02-01-1" { + t.Errorf("build id = %q, want the index to restart on a new date", nextDay.BuildID) + } +} + +// The id is unique per API, not globally, so two APIs prepared on the same day +// both start at index 1 — which is what keeps the id short enough to be readable. +func TestCreateBuild_IndexIsPerAPI(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + createTestAPI(t, db, buildRepoAPIUUID, buildRepoOrgUUID) + const otherAPIUUID = "aaaaaaaa-0000-0000-0000-00000000000d" + insertBuildTestArtifact(t, db, otherAPIUUID, buildRepoOrgUUID) + repo := NewDeploymentRepo(db, NewArtifactTableRegistry()) + + day := time.Date(2026, 1, 31, 9, 0, 0, 0, time.UTC) + mine := buildOn(day) + if err := repo.CreateBuildWithLimitEnforcement(mine, 0); err != nil { + t.Fatalf("CreateBuild: %v", err) + } + theirs := buildOn(day) + theirs.ArtifactID = otherAPIUUID + if err := repo.CreateBuildWithLimitEnforcement(theirs, 0); err != nil { + t.Fatalf("CreateBuild: %v", err) + } + if mine.BuildID != "2026-01-31-1" || theirs.BuildID != "2026-01-31-1" { + t.Errorf("ids = %q and %q, want each API to start at index 1", + mine.BuildID, theirs.BuildID) + } +} + +// The snapshot and its metadata come back exactly as stored: a build is what a +// deploy sends, so anything lost here would silently change what runs. +func TestGetBuild_ReturnsTheSnapshotAndItsMetadata(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + createTestAPI(t, db, buildRepoAPIUUID, buildRepoOrgUUID) + repo := NewDeploymentRepo(db, NewArtifactTableRegistry()) + + stored := buildOn(time.Date(2026, 1, 31, 9, 0, 0, 0, time.UTC)) + stored.Metadata = map[string]any{"commitId": "9f1c2ab"} + if err := repo.CreateBuildWithLimitEnforcement(stored, 0); err != nil { + t.Fatalf("CreateBuild: %v", err) + } + + read, err := repo.GetBuild(stored.BuildID, buildRepoAPIUUID, buildRepoOrgUUID) + if err != nil { + t.Fatalf("GetBuild: %v", err) + } + if read == nil { + t.Fatal("the build was not found") + } + if string(read.Content) != string(stored.Content) { + t.Error("the stored snapshot did not come back unchanged") + } + if read.Metadata["commitId"] != "9f1c2ab" { + t.Errorf("metadata = %v, want the commit that was recorded", read.Metadata) + } + if read.DataVersion != "1.0" || read.CreatedBy != "tester" { + t.Errorf("build = %+v, want its data version and author preserved", read) + } +} + +// A build id belongs to one API. Resolving it under another API must miss, because +// that scoping is what stops one API's build being deployed as another's. +func TestGetBuild_IsScopedToItsAPI(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + createTestAPI(t, db, buildRepoAPIUUID, buildRepoOrgUUID) + const otherAPIUUID = "aaaaaaaa-0000-0000-0000-00000000000d" + insertBuildTestArtifact(t, db, otherAPIUUID, buildRepoOrgUUID) + repo := NewDeploymentRepo(db, NewArtifactTableRegistry()) + + stored := buildOn(time.Date(2026, 1, 31, 9, 0, 0, 0, time.UTC)) + if err := repo.CreateBuildWithLimitEnforcement(stored, 0); err != nil { + t.Fatalf("CreateBuild: %v", err) + } + + read, err := repo.GetBuild(stored.BuildID, otherAPIUUID, buildRepoOrgUUID) + if err != nil { + t.Fatalf("GetBuild: %v", err) + } + if read != nil { + t.Error("a build resolved under an API it does not belong to") + } +} + +// A listing is for choosing what to deploy, so it is newest first and carries no +// artifacts. +func TestGetBuilds_NewestFirstWithoutContent(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + createTestAPI(t, db, buildRepoAPIUUID, buildRepoOrgUUID) + repo := NewDeploymentRepo(db, NewArtifactTableRegistry()) + + day := time.Date(2026, 1, 31, 9, 0, 0, 0, time.UTC) + for i := 0; i < 3; i++ { + if err := repo.CreateBuildWithLimitEnforcement(buildOn(day.Add(time.Duration(i)*time.Hour)), 0); err != nil { + t.Fatalf("CreateBuild: %v", err) + } + } + + builds, err := repo.GetBuilds(buildRepoAPIUUID, buildRepoOrgUUID, 0) + if err != nil { + t.Fatalf("GetBuilds: %v", err) + } + if len(builds) != 3 { + t.Fatalf("got %d builds, want 3", len(builds)) + } + if builds[0].BuildID != "2026-01-31-3" { + t.Errorf("first build = %q, want the newest", builds[0].BuildID) + } + if len(builds[0].Content) != 0 { + t.Error("a listing should not carry the rendered artifact") + } +} + +// insertBuildTestArtifact adds a second artifact under the test organization, so per-API +// scoping can be asserted without a full API row. +func insertBuildTestArtifact(t *testing.T, db *database.DB, artifactUUID, orgUUID string) { + t.Helper() + _, err := db.Exec(`INSERT INTO artifacts (uuid, type, organization_uuid) VALUES (?, ?, ?)`, + artifactUUID, "RestApi", orgUUID) + if err != nil { + t.Fatalf("Failed to create artifact: %v", err) + } +} + +// deployFromBuild makes one gateway's CURRENT deployment come from a build, which +// is what makes that build in use: a status row is what marks a deployment as the +// one a gateway is serving, and build_uuid is what says where it came from. +func deployFromBuild(t *testing.T, db *database.DB, gatewayUUID, deploymentID string, build *model.Build) { + t.Helper() + _, err := db.Exec(` + INSERT INTO deployments (uuid, display_name, artifact_uuid, organization_uuid, gateway_uuid, build_uuid, content, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + deploymentID, deploymentID, buildRepoAPIUUID, buildRepoOrgUUID, gatewayUUID, + build.UUID, []byte("content"), time.Now().UTC()) + if err != nil { + t.Fatalf("Failed to insert deployment: %v", err) + } + _, err = db.Exec(` + REPLACE INTO deployment_status (artifact_uuid, organization_uuid, gateway_uuid, deployment_uuid, status, updated_at) + VALUES (?, ?, ?, ?, ?, ?)`, + buildRepoAPIUUID, buildRepoOrgUUID, gatewayUUID, deploymentID, "DEPLOYED", time.Now().UTC()) + if err != nil { + t.Fatalf("Failed to set deployment status: %v", err) + } +} + +// deployFromBuildWithStatus is deployFromBuild for a gateway whose current +// deployment is in some state other than DEPLOYED — a suspended one, for the tests +// that separate "held by a live deployment" from "named by a current one". +func deployFromBuildWithStatus(t *testing.T, db *database.DB, gatewayUUID, deploymentID string, + build *model.Build, status string) { + t.Helper() + deployFromBuild(t, db, gatewayUUID, deploymentID, build) + _, err := db.Exec(` + UPDATE deployment_status SET status = ? + WHERE artifact_uuid = ? AND organization_uuid = ? AND gateway_uuid = ? AND deployment_uuid = ?`, + status, buildRepoAPIUUID, buildRepoOrgUUID, gatewayUUID, deploymentID) + if err != nil { + t.Fatalf("Failed to set deployment status to %s: %v", status, err) + } +} + +// buildUUIDOfDeployment reads back the build a deployment names, and whether it +// names one at all. +func buildUUIDOfDeployment(t *testing.T, db *database.DB, deploymentID string) (string, bool) { + t.Helper() + var buildUUID sql.NullString + err := db.QueryRow(`SELECT build_uuid FROM deployments WHERE uuid = ?`, deploymentID).Scan(&buildUUID) + if err != nil { + t.Fatalf("Failed to read deployment %s: %v", deploymentID, err) + } + return buildUUID.String, buildUUID.Valid +} + +// storedBuildIDs lists what the API has kept, oldest first. +func storedBuildIDs(t *testing.T, repo DeploymentRepository) []string { + t.Helper() + builds, err := repo.GetBuilds(buildRepoAPIUUID, buildRepoOrgUUID, 0) + if err != nil { + t.Fatalf("GetBuilds: %v", err) + } + out := make([]string, 0, len(builds)) + for i := len(builds) - 1; i >= 0; i-- { + out = append(out, builds[i].BuildID) + } + return out +} + +// prepareBuilds adds n builds an hour apart, oldest first. +func prepareBuilds(t *testing.T, repo DeploymentRepository, n, hardLimit int) []*model.Build { + t.Helper() + day := time.Date(2026, 1, 31, 0, 0, 0, 0, time.UTC) + builds := make([]*model.Build, 0, n) + for i := 0; i < n; i++ { + build := buildOn(day.Add(time.Duration(i) * time.Hour)) + if err := repo.CreateBuildWithLimitEnforcement(build, hardLimit); err != nil { + t.Fatalf("CreateBuildWithLimitEnforcement: %v", err) + } + builds = append(builds, build) + } + return builds +} + +// A deploy from the API's definition renders the build and stores it with the +// deployment that runs it, on one transaction. Nothing can prune a build the +// deployment naming it does not yet exist to protect, and the deployment cannot end +// up naming a build that was never recorded. +func TestCreateDeployment_StoresTheBuildItRunsAlongsideIt(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + createTestAPI(t, db, buildRepoAPIUUID, buildRepoOrgUUID) + createTestGateway(t, db, "gw-1", buildRepoOrgUUID) + repo := NewDeploymentRepo(db, NewArtifactTableRegistry()) + + build := buildOn(time.Date(2026, 1, 31, 0, 0, 0, 0, time.UTC)) + build.BuildID = "" + deployed := model.DeploymentStatusDeployed + deployment := &model.Deployment{ + DeploymentID: "dep-1", + Name: "dep-1", + ArtifactID: buildRepoAPIUUID, + GatewayID: "gw-1", + OrganizationID: buildRepoOrgUUID, + Content: []byte("content"), + Status: &deployed, + } + if err := repo.CreateWithBuild(deployment, build, 0, 100); err != nil { + t.Fatalf("CreateWithBuild: %v", err) + } + + // The build was given an id of its own and the deployment names it. + if build.BuildID != "2026-01-31-1" { + t.Errorf("buildId = %q, want %q", build.BuildID, "2026-01-31-1") + } + if deployment.BuildUUID == nil || *deployment.BuildUUID != build.UUID { + t.Errorf("deployment buildUuid = %v, want %q", deployment.BuildUUID, build.UUID) + } + stored, err := repo.GetBuild(build.BuildID, buildRepoAPIUUID, buildRepoOrgUUID) + if err != nil || stored == nil { + t.Fatalf("the build was not stored: %v", err) + } + dep, err := repo.GetWithContent("dep-1", buildRepoAPIUUID, buildRepoOrgUUID) + if err != nil { + t.Fatalf("GetWithContent: %v", err) + } + if dep.BuildID == nil || *dep.BuildID != build.BuildID { + t.Errorf("buildId = %v, want %q", dep.BuildID, build.BuildID) + } +} + +// The other half of committing them together: a deploy that cannot be recorded +// leaves no build behind either. A build nothing deployed would otherwise sit in the +// API's budget and be offered as something to deploy. +func TestCreateDeployment_AFailedDeployStoresNoBuild(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + createTestAPI(t, db, buildRepoAPIUUID, buildRepoOrgUUID) + repo := NewDeploymentRepo(db, NewArtifactTableRegistry()) + + build := buildOn(time.Date(2026, 1, 31, 0, 0, 0, 0, time.UTC)) + build.BuildID = "" + deployed := model.DeploymentStatusDeployed + // The gateway does not exist, so recording the deployment fails. + err := repo.CreateWithBuild(&model.Deployment{ + DeploymentID: "dep-1", + Name: "dep-1", + ArtifactID: buildRepoAPIUUID, + GatewayID: "gw-that-was-never-created", + OrganizationID: buildRepoOrgUUID, + Content: []byte("content"), + Status: &deployed, + }, build, 0, 100) + if err == nil { + t.Fatal("CreateWithBuild succeeded against a gateway that does not exist") + } + + if kept := storedBuildIDs(t, repo); len(kept) != 0 { + t.Errorf("builds = %v, want none stored by a deploy that failed", kept) + } +} + +// A deploy of a build prepared earlier resolves it before the transaction that +// records the deployment opens, so a prepare running alongside can prune it in +// between. The build is read again inside that transaction, so the deploy is refused +// rather than committed with an origin it has lost — the next stage promotes what +// this one is running, and a deployment that cannot name its build ends the pipeline. +func TestCreateDeployment_RefusesABuildPrunedMidDeploy(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + createTestAPI(t, db, buildRepoAPIUUID, buildRepoOrgUUID) + createTestGateway(t, db, "gw-1", buildRepoOrgUUID) + repo := NewDeploymentRepo(db, NewArtifactTableRegistry()) + + build := buildOn(time.Date(2026, 1, 31, 0, 0, 0, 0, time.UTC)) + if err := repo.CreateBuildWithLimitEnforcement(build, 0); err != nil { + t.Fatalf("CreateBuildWithLimitEnforcement: %v", err) + } + // The prepare that raced this deploy, pruning the build it had already resolved. + if _, err := db.Exec(`DELETE FROM builds WHERE uuid = ?`, build.UUID); err != nil { + t.Fatalf("prune the build: %v", err) + } + + deployed := model.DeploymentStatusDeployed + err := repo.CreateWithLimitEnforcement(&model.Deployment{ + DeploymentID: "dep-1", + Name: "dep-1", + ArtifactID: buildRepoAPIUUID, + GatewayID: "gw-1", + OrganizationID: buildRepoOrgUUID, + Content: []byte("content"), + Status: &deployed, + BuildUUID: &build.UUID, + }, 100) + if err == nil { + t.Fatal("expected a deploy of a pruned build to be refused") + } + if !apperror.BuildNotFound.Is(err) { + t.Errorf("error = %v, want BuildNotFound", err) + } + if dep, err := repo.GetWithContent("dep-1", buildRepoAPIUUID, buildRepoOrgUUID); err == nil && dep != nil { + t.Error("the deployment was recorded anyway") + } +} + +// A deploy that fails for a reason of its own must not be reported as a lost +// build: only the build being gone means that. Here the build is intact and the +// gateway does not exist, so the foreign-key failure belongs to the gateway and is +// surfaced as itself. +func TestCreateDeployment_KeepsAnUnrelatedFailureAsItself(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + createTestAPI(t, db, buildRepoAPIUUID, buildRepoOrgUUID) + repo := NewDeploymentRepo(db, NewArtifactTableRegistry()) + + build := buildOn(time.Date(2026, 1, 31, 0, 0, 0, 0, time.UTC)) + if err := repo.CreateBuildWithLimitEnforcement(build, 0); err != nil { + t.Fatalf("CreateBuildWithLimitEnforcement: %v", err) + } + + deployed := model.DeploymentStatusDeployed + err := repo.CreateWithLimitEnforcement(&model.Deployment{ + DeploymentID: "dep-1", + Name: "dep-1", + ArtifactID: buildRepoAPIUUID, + GatewayID: "gw-that-was-never-created", + OrganizationID: buildRepoOrgUUID, + Content: []byte("content"), + Status: &deployed, + BuildUUID: &build.UUID, + }, 100) + if err == nil { + t.Fatal("CreateWithLimitEnforcement succeeded against a gateway that does not exist") + } + if apperror.BuildNotFound.Is(err) { + t.Errorf("err = %v, want the underlying failure rather than a lost build", err) + } +} + +// A reference that cannot be resolved at all is refused, not quietly cleared: +// reaching this means an invariant broke upstream, which is worth failing over +// rather than hiding behind a deployment with no origin. +func TestCreateDeployment_RefusesAnUnresolvableBuildReference(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + createTestAPI(t, db, buildRepoAPIUUID, buildRepoOrgUUID) + createTestGateway(t, db, "gw-1", buildRepoOrgUUID) + repo := NewDeploymentRepo(db, NewArtifactTableRegistry()) + + deployed := model.DeploymentStatusDeployed + missing := "99999999-9999-9999-9999-999999999999" + err := repo.CreateWithLimitEnforcement(&model.Deployment{ + DeploymentID: "dep-1", + Name: "dep-1", + ArtifactID: buildRepoAPIUUID, + GatewayID: "gw-1", + OrganizationID: buildRepoOrgUUID, + Content: []byte("content"), + Status: &deployed, + BuildUUID: &missing, + }, 100) + if err == nil { + t.Fatal("expected a reference that cannot be resolved to be refused") + } + if !apperror.BuildNotFound.Is(err) { + t.Errorf("error = %v, want BuildNotFound", err) + } +} + +// The foreign key alone would accept any build. A deployment carrying another +// API's build would report that build's id as its own origin, so the reference is +// checked against the deployment's own API and organization, not just for existence. +func TestCreateDeployment_RefusesABuildFromAnotherAPI(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + createTestAPI(t, db, buildRepoAPIUUID, buildRepoOrgUUID) + createTestGateway(t, db, "gw-1", buildRepoOrgUUID) + otherAPIUUID := "aaaaaaaa-0000-0000-0000-00000000000e" + insertBuildTestArtifact(t, db, otherAPIUUID, buildRepoOrgUUID) + repo := NewDeploymentRepo(db, NewArtifactTableRegistry()) + + // A real build, but prepared for a different API. + foreign := buildOn(time.Date(2026, 1, 31, 0, 0, 0, 0, time.UTC)) + foreign.ArtifactID = otherAPIUUID + if err := repo.CreateBuildWithLimitEnforcement(foreign, 0); err != nil { + t.Fatalf("CreateBuildWithLimitEnforcement: %v", err) + } + + deployed := model.DeploymentStatusDeployed + err := repo.CreateWithLimitEnforcement(&model.Deployment{ + DeploymentID: "dep-1", + Name: "dep-1", + ArtifactID: buildRepoAPIUUID, + GatewayID: "gw-1", + OrganizationID: buildRepoOrgUUID, + Content: []byte("content"), + Status: &deployed, + BuildUUID: &foreign.UUID, + }, 100) + if err == nil { + t.Fatal("expected a build belonging to another API to be refused") + } + if !apperror.BuildNotFound.Is(err) { + t.Errorf("error = %v, want BuildNotFound", err) + } +} + +// Pruning and adding are one transaction, so an attempt that cannot finish takes +// nothing with it. Without that, a failed prepare would still have spent five of +// the API's builds — and worse, could delete a build a concurrent deploy had just +// resolved and was about to reference. +func TestCreateBuild_AFailedAttemptPrunesNothing(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + createTestAPI(t, db, buildRepoAPIUUID, buildRepoOrgUUID) + repo := NewDeploymentRepo(db, NewArtifactTableRegistry()) + + builds := prepareBuilds(t, repo, 10, 0) + before := storedBuildIDs(t, repo) + + // At the limit, so this prepare prunes first — and then fails, because the id + // it was given belongs to the newest build, which pruning does not reach. + doomed := buildOn(time.Date(2026, 1, 31, 10, 0, 0, 0, time.UTC)) + doomed.BuildID = builds[len(builds)-1].BuildID + if err := repo.CreateBuildWithLimitEnforcement(doomed, 10); err == nil { + t.Fatal("expected the duplicate build id to be rejected") + } + + after := storedBuildIDs(t, repo) + if !reflect.DeepEqual(before, after) { + t.Errorf("builds after the failed attempt = %v, want them untouched: %v", after, before) + } +} + +// Reaching the limit removes the API's oldest free build — exactly the one slot the +// new build needs — so the table stays at the limit rather than sawing down to well +// under it every time a prepare finds it full. +func TestCreateBuild_PrunesTheOldestBuildAtTheLimit(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + createTestAPI(t, db, buildRepoAPIUUID, buildRepoOrgUUID) + repo := NewDeploymentRepo(db, NewArtifactTableRegistry()) + + // The eleventh build is the one that finds the limit already reached: the single + // oldest build goes, and the new one takes its place. + prepareBuilds(t, repo, 11, 10) + + kept := storedBuildIDs(t, repo) + want := []string{ + "2026-01-31-2", "2026-01-31-3", "2026-01-31-4", "2026-01-31-5", "2026-01-31-6", + "2026-01-31-7", "2026-01-31-8", "2026-01-31-9", "2026-01-31-10", "2026-01-31-11", + } + if len(kept) != len(want) { + t.Fatalf("kept %v, want %v", kept, want) + } + for i := range want { + if kept[i] != want[i] { + t.Fatalf("kept %v, want %v", kept, want) + } + } +} + +// The rule that matters: a build any deployment names survives, however old it is, +// and a newer build that nothing points at goes instead. Age only orders the builds +// that are free to go. +func TestCreateBuild_KeepsBuildsAGatewayIsDeployedFrom(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + createTestAPI(t, db, buildRepoAPIUUID, buildRepoOrgUUID) + createTestGateway(t, db, "gw-1", buildRepoOrgUUID) + createTestGateway(t, db, "gw-2", buildRepoOrgUUID) + repo := NewDeploymentRepo(db, NewArtifactTableRegistry()) + + builds := prepareBuilds(t, repo, 10, 0) + // The two oldest builds are what the gateways are serving. + deployFromBuild(t, db, "gw-1", "dep-1", builds[0]) + deployFromBuild(t, db, "gw-2", "dep-2", builds[1]) + + eleventh := buildOn(time.Date(2026, 1, 31, 10, 0, 0, 0, time.UTC)) + if err := repo.CreateBuildWithLimitEnforcement(eleventh, 10); err != nil { + t.Fatalf("CreateBuildWithLimitEnforcement: %v", err) + } + + kept := map[string]bool{} + for _, buildID := range storedBuildIDs(t, repo) { + kept[buildID] = true + } + for _, inUse := range []string{"2026-01-31-1", "2026-01-31-2"} { + if !kept[inUse] { + t.Errorf("build %s is deployed on a gateway but was pruned", inUse) + } + } + // The oldest build that is free to go went instead — the third, since the two + // older ones are being served. + if kept["2026-01-31-3"] { + t.Errorf("unused build 2026-01-31-3 should have been pruned, kept %v", kept) + } + if !kept["2026-01-31-9"] || !kept["2026-01-31-10"] || !kept[eleventh.BuildID] { + t.Errorf("the newest builds should have been kept, got %v", kept) + } +} + +// An archived deployment does not hold its build: it carries its own rendered +// content, so redeploying it never needs the build back. This is what keeps the +// limit from being reached by ordinary redeployment. +func TestCreateBuild_AnArchivedDeploymentDoesNotHoldABuild(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + createTestAPI(t, db, buildRepoAPIUUID, buildRepoOrgUUID) + createTestGateway(t, db, "gw-1", buildRepoOrgUUID) + repo := NewDeploymentRepo(db, NewArtifactTableRegistry()) + + builds := prepareBuilds(t, repo, 10, 0) + // Deployed from the oldest build, then superseded: the status row moves to the + // newer deployment, leaving the first one archived. + deployFromBuild(t, db, "gw-1", "dep-old", builds[0]) + deployFromBuild(t, db, "gw-1", "dep-new", builds[9]) + + eleventh := buildOn(time.Date(2026, 1, 31, 10, 0, 0, 0, time.UTC)) + if err := repo.CreateBuildWithLimitEnforcement(eleventh, 10); err != nil { + t.Fatalf("CreateBuildWithLimitEnforcement: %v", err) + } + + kept := map[string]bool{} + for _, buildID := range storedBuildIDs(t, repo) { + kept[buildID] = true + } + if kept["2026-01-31-1"] { + t.Error("a build held only by an archived deployment should have been pruned") + } + if !kept["2026-01-31-10"] { + t.Error("the build the gateway is now serving was pruned") + } + // The archived deployment stays redeployable from its own artifact, but stops + // naming a build it can no longer resolve. + if _, named := buildUUIDOfDeployment(t, db, "dep-old"); named { + t.Error("the archived deployment still names a build that was pruned") + } +} + +// With every build in use there is nothing safe to remove, so the prepare is +// REFUSED. Neither alternative is acceptable: deleting a build a gateway is serving +// takes away what a promotion out of that environment carries, and quietly storing +// one more puts the API over the limit it is entitled to. Which deployment to give +// up is the caller's decision, so they are told. +func TestCreateBuild_RefusesWhenNothingIsFreeToGo(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + createTestAPI(t, db, buildRepoAPIUUID, buildRepoOrgUUID) + repo := NewDeploymentRepo(db, NewArtifactTableRegistry()) + + builds := prepareBuilds(t, repo, 3, 0) + for i, build := range builds { + gatewayID := fmt.Sprintf("gw-%d", i+1) + createTestGateway(t, db, gatewayID, buildRepoOrgUUID) + deployFromBuild(t, db, gatewayID, fmt.Sprintf("dep-%d", i+1), build) + } + + fourth := buildOn(time.Date(2026, 1, 31, 3, 0, 0, 0, time.UTC)) + err := repo.CreateBuildWithLimitEnforcement(fourth, 3) + if !errors.Is(err, ErrBuildLimitReached) { + t.Fatalf("error = %v, want ErrBuildLimitReached", err) + } + // And the refusal took nothing with it: the three in-use builds are all still + // there, and the one that was refused was not stored. + if kept := storedBuildIDs(t, repo); len(kept) != 3 { + t.Errorf("kept %v, want the three in-use builds and nothing more", kept) + } +} + +// A limit lowered since the last prepare leaves the API over it by more than one. +// Pruning removes as many free builds as the new limit demands, so the API +// converges on the first prepare instead of drifting down one build at a time. +func TestCreateBuild_ConvergesOnALoweredLimit(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + createTestAPI(t, db, buildRepoAPIUUID, buildRepoOrgUUID) + repo := NewDeploymentRepo(db, NewArtifactTableRegistry()) + + prepareBuilds(t, repo, 10, 0) + + // Ten stored, and now a limit of three: the seven oldest go, leaving room for + // the new build to make three. + eleventh := buildOn(time.Date(2026, 1, 31, 10, 0, 0, 0, time.UTC)) + if err := repo.CreateBuildWithLimitEnforcement(eleventh, 3); err != nil { + t.Fatalf("CreateBuildWithLimitEnforcement: %v", err) + } + + kept := storedBuildIDs(t, repo) + want := []string{"2026-01-31-9", "2026-01-31-10", "2026-01-31-11"} + if !reflect.DeepEqual(kept, want) { + t.Errorf("kept %v, want %v", kept, want) + } +} + +// Deleting a build is what makes room when the limit refuses another prepare, so +// the two have to fit together: a build no deployment holds goes, and preparing +// then succeeds where it had just been refused. +func TestDeleteBuild_FreesRoomForAnotherPrepare(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + createTestAPI(t, db, buildRepoAPIUUID, buildRepoOrgUUID) + repo := NewDeploymentRepo(db, NewArtifactTableRegistry()) + + builds := prepareBuilds(t, repo, 3, 0) + // Two of the three are being served, so only the middle one is free. + createTestGateway(t, db, "gw-1", buildRepoOrgUUID) + createTestGateway(t, db, "gw-2", buildRepoOrgUUID) + deployFromBuild(t, db, "gw-1", "dep-1", builds[0]) + deployFromBuild(t, db, "gw-2", "dep-2", builds[2]) + + if err := repo.DeleteBuild(builds[1].BuildID, buildRepoAPIUUID, buildRepoOrgUUID); err != nil { + t.Fatalf("DeleteBuild: %v", err) + } + kept := storedBuildIDs(t, repo) + if len(kept) != 2 || kept[0] != builds[0].BuildID || kept[1] != builds[2].BuildID { + t.Fatalf("kept %v, want the two builds that are deployed", kept) + } + + fourth := buildOn(time.Date(2026, 1, 31, 3, 0, 0, 0, time.UTC)) + if err := repo.CreateBuildWithLimitEnforcement(fourth, 3); err != nil { + t.Fatalf("preparing after the delete freed a slot: %v", err) + } +} + +// A build a gateway is SERVING is not deletable. Removing it would leave that +// deployment with no snapshot to promote onward, and the definition as it stood +// cannot be rendered again — so the caller has to undeploy first, and is told so +// rather than having the build taken out from under a running gateway. +func TestDeleteBuild_RefusesABuildALiveDeploymentHolds(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + createTestAPI(t, db, buildRepoAPIUUID, buildRepoOrgUUID) + createTestGateway(t, db, "gw-1", buildRepoOrgUUID) + repo := NewDeploymentRepo(db, NewArtifactTableRegistry()) + + builds := prepareBuilds(t, repo, 2, 0) + deployFromBuild(t, db, "gw-1", "dep-1", builds[0]) + + err := repo.DeleteBuild(builds[0].BuildID, buildRepoAPIUUID, buildRepoOrgUUID) + if !errors.Is(err, ErrBuildInUse) { + t.Fatalf("error = %v, want ErrBuildInUse", err) + } + if kept := storedBuildIDs(t, repo); len(kept) != 2 { + t.Errorf("kept %v, want both builds still stored", kept) + } +} + +// An archived deployment does not hold a build: it carries its own rendered +// content, so it never needs the build back. Deleting the build only clears the +// reference the archived deployment no longer needs. +func TestDeleteBuild_AnArchivedDeploymentDoesNotHoldIt(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + createTestAPI(t, db, buildRepoAPIUUID, buildRepoOrgUUID) + createTestGateway(t, db, "gw-1", buildRepoOrgUUID) + repo := NewDeploymentRepo(db, NewArtifactTableRegistry()) + + builds := prepareBuilds(t, repo, 2, 0) + // The first deployment is superseded by the second, so its status row moves on + // and it is left archived. + deployFromBuild(t, db, "gw-1", "dep-old", builds[0]) + deployFromBuild(t, db, "gw-1", "dep-new", builds[1]) + + if err := repo.DeleteBuild(builds[0].BuildID, buildRepoAPIUUID, buildRepoOrgUUID); err != nil { + t.Fatalf("DeleteBuild: %v", err) + } + kept := storedBuildIDs(t, repo) + if len(kept) != 1 || kept[0] != builds[1].BuildID { + t.Errorf("kept %v, want only the build the gateway is serving", kept) + } +} + +// Undeploying is what releases a build for deletion. The deployment stays — it +// keeps its own rendered content and can still be restored — but it stops naming a +// build, and so stops being something a later environment can promote from. That +// consequence is the user's to accept, which is why this is a request and not +// something the cleanup does on its own. +func TestDeleteBuild_AllowsABuildOnlyASuspendedDeploymentHolds(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + createTestAPI(t, db, buildRepoAPIUUID, buildRepoOrgUUID) + createTestGateway(t, db, "gw-1", buildRepoOrgUUID) + repo := NewDeploymentRepo(db, NewArtifactTableRegistry()) + + builds := prepareBuilds(t, repo, 2, 0) + deployFromBuildWithStatus(t, db, "gw-1", "dep-1", builds[0], "UNDEPLOYED") + + if err := repo.DeleteBuild(builds[0].BuildID, buildRepoAPIUUID, buildRepoOrgUUID); err != nil { + t.Fatalf("DeleteBuild on a suspended deployment's build: %v", err) + } + kept := storedBuildIDs(t, repo) + if len(kept) != 1 || kept[0] != builds[1].BuildID { + t.Errorf("kept %v, want only the build that was not deleted", kept) + } + // The deployment survives and is still restorable; it just no longer names a build. + if _, named := buildUUIDOfDeployment(t, db, "dep-1"); named { + t.Error("the suspended deployment still names a build that was deleted") + } +} + +// The asymmetry that matters: automatic pruning does NOT take a build a suspended +// deployment names, even though deleting it on request is allowed. A suspended +// deployment is one someone may still restore, so the cleanup refuses at the limit +// rather than making that call for them — the user undeploys and deletes the build +// themselves, which is the same two steps the limit error asks for. +func TestCreateBuild_PruningSparesASuspendedDeploymentsBuild(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + createTestAPI(t, db, buildRepoAPIUUID, buildRepoOrgUUID) + repo := NewDeploymentRepo(db, NewArtifactTableRegistry()) + + builds := prepareBuilds(t, repo, 2, 0) + for i, build := range builds { + gatewayID := fmt.Sprintf("gw-%d", i+1) + createTestGateway(t, db, gatewayID, buildRepoOrgUUID) + deployFromBuildWithStatus(t, db, gatewayID, fmt.Sprintf("dep-%d", i+1), build, "UNDEPLOYED") + } + + third := buildOn(time.Date(2026, 1, 31, 2, 0, 0, 0, time.UTC)) + if err := repo.CreateBuildWithLimitEnforcement(third, 2); !errors.Is(err, ErrBuildLimitReached) { + t.Fatalf("error = %v, want ErrBuildLimitReached — pruning must not take a suspended deployment's build", err) + } + if kept := storedBuildIDs(t, repo); len(kept) != 2 { + t.Errorf("kept %v, want both suspended deployments' builds untouched", kept) + } + // Both still name their build, so both are still restorable AND promotable. + for _, deploymentID := range []string{"dep-1", "dep-2"} { + if _, named := buildUUIDOfDeployment(t, db, deploymentID); !named { + t.Errorf("%s lost its build to pruning", deploymentID) + } + } +} + +// A FAILED deployment never reached its gateway, so it does not stand in the way of +// reclaiming its build. +func TestDeleteBuild_AllowsABuildAFailedDeploymentHolds(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + createTestAPI(t, db, buildRepoAPIUUID, buildRepoOrgUUID) + createTestGateway(t, db, "gw-1", buildRepoOrgUUID) + repo := NewDeploymentRepo(db, NewArtifactTableRegistry()) + + builds := prepareBuilds(t, repo, 2, 0) + deployFromBuildWithStatus(t, db, "gw-1", "dep-1", builds[0], "FAILED") + + if err := repo.DeleteBuild(builds[0].BuildID, buildRepoAPIUUID, buildRepoOrgUUID); err != nil { + t.Fatalf("DeleteBuild on a failed deployment's build: %v", err) + } + if _, named := buildUUIDOfDeployment(t, db, "dep-1"); named { + t.Error("the failed deployment still names a build that was deleted") + } +} + +// UNDEPLOYING is still on its gateway, on the way off, so it holds its build like a +// live deployment does. Undeploying has to finish before the build can go. +func TestDeleteBuild_RefusesWhileUndeploying(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + createTestAPI(t, db, buildRepoAPIUUID, buildRepoOrgUUID) + createTestGateway(t, db, "gw-1", buildRepoOrgUUID) + repo := NewDeploymentRepo(db, NewArtifactTableRegistry()) + + builds := prepareBuilds(t, repo, 2, 0) + deployFromBuildWithStatus(t, db, "gw-1", "dep-1", builds[0], "UNDEPLOYING") + + if err := repo.DeleteBuild(builds[0].BuildID, buildRepoAPIUUID, buildRepoOrgUUID); !errors.Is(err, ErrBuildInUse) { + t.Fatalf("error = %v, want ErrBuildInUse while the gateway is still letting go", err) + } +} + +// The regression this rule exists for: a pipeline that deploys the same API to one +// gateway over and over must never hit the build limit. Each deploy supersedes the +// last, so the deployments behind it are archived and stop holding their builds, +// and pruning reclaims them without anyone being asked. If archived deployments +// held their builds instead, the first deploy past the limit would fail and every +// one after it would too. +func TestCreateBuild_RepeatedDeploysToOneGatewayNeverHitTheLimit(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + createTestAPI(t, db, buildRepoAPIUUID, buildRepoOrgUUID) + createTestGateway(t, db, "gw-1", buildRepoOrgUUID) + repo := NewDeploymentRepo(db, NewArtifactTableRegistry()) + + // Twelve deploys against a limit of 5 — well past the point where a stricter + // rule would wedge. + day := time.Date(2026, 1, 31, 0, 0, 0, 0, time.UTC) + for i := 0; i < 12; i++ { + build := buildOn(day.Add(time.Duration(i) * time.Hour)) + if err := repo.CreateBuildWithLimitEnforcement(build, 5); err != nil { + t.Fatalf("deploy %d of 12 was refused: %v", i+1, err) + } + deployFromBuild(t, db, "gw-1", fmt.Sprintf("dep-%d", i+1), build) + } + + // The table stayed within budget rather than growing with every deploy. + if kept := storedBuildIDs(t, repo); len(kept) > 5 { + t.Errorf("kept %d builds (%v), want no more than the limit of 5", len(kept), kept) + } + // And the gateway's current deployment still names the build it runs. + if _, named := buildUUIDOfDeployment(t, db, "dep-12"); !named { + t.Error("the live deployment lost the build it runs") + } +} + +// The limit is still real: it is reached when the API's builds are held by +// deployments the status table names — spread across gateways that are each running +// one, or left suspended — and then deleting a build is what clears it. This is the +// flow the deploy page has to offer a way out of. +func TestCreateBuild_LimitIsReachedAcrossGatewaysAndClearedByDeleting(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + createTestAPI(t, db, buildRepoAPIUUID, buildRepoOrgUUID) + repo := NewDeploymentRepo(db, NewArtifactTableRegistry()) + + // Three builds, each the current deployment of its own gateway: two live, one + // suspended. Pruning may take none of them. + builds := prepareBuilds(t, repo, 3, 0) + for i, build := range builds { + gatewayID := fmt.Sprintf("gw-%d", i+1) + createTestGateway(t, db, gatewayID, buildRepoOrgUUID) + deploymentID := fmt.Sprintf("dep-%d", i+1) + if i == 2 { + // The third gateway's deployment is suspended, not live. + deployFromBuildWithStatus(t, db, gatewayID, deploymentID, build, "UNDEPLOYED") + continue + } + deployFromBuild(t, db, gatewayID, deploymentID, build) + } + + fourth := buildOn(time.Date(2026, 1, 31, 3, 0, 0, 0, time.UTC)) + if err := repo.CreateBuildWithLimitEnforcement(fourth, 3); !errors.Is(err, ErrBuildLimitReached) { + t.Fatalf("error = %v, want ErrBuildLimitReached", err) + } + + // The suspended one is the build a user can give up — pruning would not have. + if err := repo.DeleteBuild(builds[2].BuildID, buildRepoAPIUUID, buildRepoOrgUUID); err != nil { + t.Fatalf("DeleteBuild on the suspended deployment's build: %v", err) + } + if err := repo.CreateBuildWithLimitEnforcement(fourth, 3); err != nil { + t.Fatalf("preparing after the delete freed a slot: %v", err) + } +} + +// A build id that is not one of this API's is a not-found, not a silent success — +// and, since build ids are unique only per API, not another API's build either. +func TestDeleteBuild_UnknownBuildIsNotFound(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + createTestAPI(t, db, buildRepoAPIUUID, buildRepoOrgUUID) + const otherAPIUUID = "aaaaaaaa-0000-0000-0000-00000000000e" + insertBuildTestArtifact(t, db, otherAPIUUID, buildRepoOrgUUID) + repo := NewDeploymentRepo(db, NewArtifactTableRegistry()) + + builds := prepareBuilds(t, repo, 1, 0) + + if err := repo.DeleteBuild("2026-01-31-99", buildRepoAPIUUID, buildRepoOrgUUID); !errors.Is(err, ErrBuildNotFound) { + t.Errorf("error = %v, want ErrBuildNotFound", err) + } + // The id exists, but under a different API. + if err := repo.DeleteBuild(builds[0].BuildID, otherAPIUUID, buildRepoOrgUUID); !errors.Is(err, ErrBuildNotFound) { + t.Errorf("error for another API's build = %v, want ErrBuildNotFound", err) + } + if kept := storedBuildIDs(t, repo); len(kept) != 1 { + t.Errorf("kept %v, want the build untouched", kept) + } +} + +// The description is what tells one snapshot from another when choosing which to +// deploy or which to delete, so it has to survive the round trip — on the single +// read and in the listing, which are separate queries. +func TestCreateBuild_DescriptionIsStoredAndReadBack(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + createTestAPI(t, db, buildRepoAPIUUID, buildRepoOrgUUID) + repo := NewDeploymentRepo(db, NewArtifactTableRegistry()) + + build := buildOn(time.Date(2026, 1, 31, 0, 0, 0, 0, time.UTC)) + build.Description = "Adds the /reports endpoint" + if err := repo.CreateBuildWithLimitEnforcement(build, 0); err != nil { + t.Fatalf("CreateBuildWithLimitEnforcement: %v", err) + } + // A build prepared without one reads back empty rather than failing to scan. + plain := buildOn(time.Date(2026, 1, 31, 1, 0, 0, 0, time.UTC)) + if err := repo.CreateBuildWithLimitEnforcement(plain, 0); err != nil { + t.Fatalf("CreateBuildWithLimitEnforcement: %v", err) + } + + got, err := repo.GetBuild(build.BuildID, buildRepoAPIUUID, buildRepoOrgUUID) + if err != nil { + t.Fatalf("GetBuild: %v", err) + } + if got.Description != "Adds the /reports endpoint" { + t.Errorf("description = %q, want the note it was prepared with", got.Description) + } + + listed, err := repo.GetBuilds(buildRepoAPIUUID, buildRepoOrgUUID, 0) + if err != nil { + t.Fatalf("GetBuilds: %v", err) + } + descriptions := map[string]string{} + for _, b := range listed { + descriptions[b.BuildID] = b.Description + } + if descriptions[build.BuildID] != "Adds the /reports endpoint" { + t.Errorf("listed description = %q, want the note", descriptions[build.BuildID]) + } + if descriptions[plain.BuildID] != "" { + t.Errorf("a build prepared without a description listed %q", descriptions[plain.BuildID]) + } +} + +// The budget is per API: one API reaching its limit must not prune another's +// builds, which is why the count and the cleanup are both scoped to the artifact. +func TestCreateBuild_PruningIsScopedToOneAPI(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + createTestAPI(t, db, buildRepoAPIUUID, buildRepoOrgUUID) + const otherAPIUUID = "aaaaaaaa-0000-0000-0000-00000000000d" + insertBuildTestArtifact(t, db, otherAPIUUID, buildRepoOrgUUID) + repo := NewDeploymentRepo(db, NewArtifactTableRegistry()) + + day := time.Date(2026, 1, 31, 0, 0, 0, 0, time.UTC) + for i := 0; i < 2; i++ { + other := buildOn(day.Add(time.Duration(i) * time.Hour)) + other.ArtifactID = otherAPIUUID + if err := repo.CreateBuildWithLimitEnforcement(other, 2); err != nil { + t.Fatalf("CreateBuildWithLimitEnforcement: %v", err) + } + } + prepareBuilds(t, repo, 3, 2) + + otherBuilds, err := repo.GetBuilds(otherAPIUUID, buildRepoOrgUUID, 0) + if err != nil { + t.Fatalf("GetBuilds: %v", err) + } + if len(otherBuilds) != 2 { + t.Errorf("the other API kept %d builds, want its own 2 untouched", len(otherBuilds)) + } +} + +// Pruning a build clears the references to it rather than leaving them dangling, +// and the deployment keeps the readable build id in its metadata — so the origin +// stays legible after the snapshot itself is gone. +func TestDeleteBuild_ClearsTheReferenceOnArchivedDeployments(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + createTestAPI(t, db, buildRepoAPIUUID, buildRepoOrgUUID) + createTestGateway(t, db, "gw-1", buildRepoOrgUUID) + repo := NewDeploymentRepo(db, NewArtifactTableRegistry()) + + builds := prepareBuilds(t, repo, 10, 0) + // Deployed from the oldest build, then superseded, so a deployment still points + // at that build while no gateway is serving it. + deployFromBuild(t, db, "gw-1", "dep-old", builds[0]) + deployFromBuild(t, db, "gw-1", "dep-new", builds[9]) + + if err := repo.DeleteBuild(builds[0].BuildID, buildRepoAPIUUID, buildRepoOrgUUID); err != nil { + t.Fatalf("DeleteBuild: %v", err) + } + + var buildUUID sql.NullString + if err := db.QueryRow(`SELECT build_uuid FROM deployments WHERE uuid = ?`, "dep-old"). + Scan(&buildUUID); err != nil { + t.Fatalf("read deployment: %v", err) + } + if buildUUID.Valid { + t.Errorf("build_uuid = %q, want NULL once the build is deleted", buildUUID.String) + } + // The deployment can still be redeployed from its own artifact, but it now + // reports no build — the honest answer, since the snapshot it came from is gone + // and there is nothing left to promote. + dep, err := repo.GetWithContent("dep-old", buildRepoAPIUUID, buildRepoOrgUUID) + if err != nil { + t.Fatalf("GetWithContent: %v", err) + } + if dep.BuildID != nil { + t.Errorf("buildId = %q, want none once the build is deleted", *dep.BuildID) + } + + // The build the other gateway is still serving keeps both. + current, err := repo.GetWithContent("dep-new", buildRepoAPIUUID, buildRepoOrgUUID) + if err != nil { + t.Fatalf("GetWithContent: %v", err) + } + if current.BuildID == nil || *current.BuildID != builds[9].BuildID { + t.Errorf("buildId = %v, want %q for the build still in use", current.BuildID, builds[9].BuildID) + } +} diff --git a/platform-api/internal/repository/deployment.go b/platform-api/internal/repository/deployment.go index dc1018b9cc..720f1415b1 100644 --- a/platform-api/internal/repository/deployment.go +++ b/platform-api/internal/repository/deployment.go @@ -49,7 +49,42 @@ func NewDeploymentRepo(db *database.DB, reg *ArtifactTableRegistry) DeploymentRe // If deployment count >= hardLimit, deletes oldest 5 ARCHIVED deployments before inserting new one // This entire operation is wrapped in a single transaction to ensure atomicity // and to leverage row-level locks during deletion to reduce race conditions. +// +// A deployment that names a build it runs has that build re-read inside the +// transaction, so a deploy that lost its build to a prune between resolving it and +// recording it is refused rather than committed with an origin it no longer has. func (r *DeploymentRepo) CreateWithLimitEnforcement(deployment *model.Deployment, hardLimit int) error { + return r.createOnce(deployment, nil, 0, hardLimit) +} + +// CreateWithBuild records a deployment together with the build it runs, storing +// the build here rather than before: a committed deployment therefore always names +// a build that exists, a failed deploy leaves no build behind, and no prune can get +// between the two writes. The build takes its id and its place in the API's budget +// exactly as one prepared on its own does, so buildHardLimit is enforced here too. +// The deployment's build reference is filled in from the stored build. +func (r *DeploymentRepo) CreateWithBuild(deployment *model.Deployment, build *model.Build, + buildHardLimit, hardLimit int) error { + // The build belongs to the deployment being recorded, so it takes that + // deployment's API and organization rather than carrying its own copy of them. + // There is then nothing for the two to disagree about, and no way to store a + // deployment whose recorded origin is another API's build — which the foreign + // key alone would accept. + build.ArtifactID = deployment.ArtifactID + build.OrganizationID = deployment.OrganizationID + if err := initBuild(build); err != nil { + return err + } + return createWithDerivedBuildID(build, func() error { + return r.createOnce(deployment, build, buildHardLimit, hardLimit) + }) +} + +// createOnce is a single attempt at recording a deployment: it either commits the +// deployment — and the build it carries, when it carries one — or leaves the +// database untouched. +func (r *DeploymentRepo) createOnce(deployment *model.Deployment, + build *model.Build, buildHardLimit, hardLimit int) error { tx, err := r.db.Begin() if err != nil { return err @@ -140,10 +175,34 @@ func (r *DeploymentRepo) CreateWithLimitEnforcement(deployment *model.Deployment } } + // The build this deployment runs. One rendered for it is stored here, on the + // transaction that records the deployment, so the two cannot come apart. One + // prepared earlier was resolved before this transaction opened, so it is read + // again: a prepare running alongside this one may have pruned it since, and it + // must belong to the same API and organization as the deployment — the foreign + // key alone would accept any build, and a deployment carrying another API's + // build would report that build's id as its own origin. + switch { + case build != nil: + if err := r.storeBuild(tx, build, buildHardLimit); err != nil { + return err + } + deployment.BuildUUID = &build.UUID + deployment.BuildID = &build.BuildID + case deployment.BuildUUID != nil: + owned, err := r.buildBelongsTo(tx, *deployment.BuildUUID, deployment.ArtifactID, deployment.OrganizationID) + if err != nil { + return err + } + if !owned { + return apperror.BuildNotFound.New() + } + } + // 3. Insert new deployment artifact deploymentQuery := ` - INSERT INTO deployments (uuid, display_name, artifact_uuid, organization_uuid, gateway_uuid, base_deployment_uuid, content, metadata, created_by, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO deployments (uuid, display_name, artifact_uuid, organization_uuid, gateway_uuid, base_deployment_uuid, build_uuid, content, metadata, created_by, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` var baseDeploymentID interface{} @@ -151,6 +210,11 @@ func (r *DeploymentRepo) CreateWithLimitEnforcement(deployment *model.Deployment baseDeploymentID = *deployment.BaseDeploymentID } + var buildUUID interface{} + if deployment.BuildUUID != nil { + buildUUID = *deployment.BuildUUID + } + var metadataBytes []byte if len(deployment.Metadata) > 0 { var err error @@ -161,7 +225,7 @@ func (r *DeploymentRepo) CreateWithLimitEnforcement(deployment *model.Deployment } _, err = tx.Exec(r.db.Rebind(deploymentQuery), deployment.DeploymentID, deployment.Name, deployment.ArtifactID, deployment.OrganizationID, - deployment.GatewayID, baseDeploymentID, deployment.Content, metadataBytes, deployment.CreatedBy, deployment.CreatedAt) + deployment.GatewayID, baseDeploymentID, buildUUID, deployment.Content, metadataBytes, deployment.CreatedBy, deployment.CreatedAt) if err != nil { return err } @@ -194,11 +258,31 @@ func (r *DeploymentRepo) CreateWithLimitEnforcement(deployment *model.Deployment return tx.Commit() } +// buildBelongsTo reports whether the build exists under that API and organization. +func (r *DeploymentRepo) buildBelongsTo(tx *sql.Tx, buildUUID, artifactUUID, orgUUID string) (bool, error) { + const query = `SELECT 1 FROM builds WHERE uuid = ? AND artifact_uuid = ? AND organization_uuid = ?` + var found int + err := tx.QueryRow(r.db.Rebind(query), buildUUID, artifactUUID, orgUUID).Scan(&found) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("failed to check the build this deployment comes from: %w", err) + } + return true, nil +} + // applyDeploymentBase populates the nullable base fields shared by all deployment scan paths. -func applyDeploymentBase(d *model.Deployment, baseID sql.NullString, createdBy sql.NullString, metadataBytes []byte) error { +func applyDeploymentBase(d *model.Deployment, baseID, buildUUID, buildID sql.NullString, createdBy sql.NullString, metadataBytes []byte) error { if baseID.Valid { d.BaseDeploymentID = &baseID.String } + if buildUUID.Valid { + d.BuildUUID = &buildUUID.String + } + if buildID.Valid { + d.BuildID = &buildID.String + } if createdBy.Valid { d.CreatedBy = createdBy.String } @@ -235,18 +319,20 @@ func (r *DeploymentRepo) GetWithContent(deploymentID, artifactUUID, orgUUID stri deployment := &model.Deployment{} query := ` - SELECT uuid, display_name, artifact_uuid, organization_uuid, gateway_uuid, base_deployment_uuid, content, metadata, created_by, created_at - FROM deployments - WHERE uuid = ? AND artifact_uuid = ? AND organization_uuid = ? + SELECT d.uuid, d.display_name, d.artifact_uuid, d.organization_uuid, d.gateway_uuid, + d.base_deployment_uuid, d.build_uuid, b.build_id, d.content, d.metadata, d.created_by, d.created_at + FROM deployments d + LEFT JOIN builds b ON d.build_uuid = b.uuid + WHERE d.uuid = ? AND d.artifact_uuid = ? AND d.organization_uuid = ? ` - var baseDeploymentID sql.NullString + var baseDeploymentID, buildUUID, buildID sql.NullString var metadataBytes []byte var createdBy sql.NullString err := r.db.QueryRow(r.db.Rebind(query), deploymentID, artifactUUID, orgUUID).Scan( &deployment.DeploymentID, &deployment.Name, &deployment.ArtifactID, &deployment.OrganizationID, - &deployment.GatewayID, &baseDeploymentID, &deployment.Content, &metadataBytes, &createdBy, &deployment.CreatedAt) + &deployment.GatewayID, &baseDeploymentID, &buildUUID, &buildID, &deployment.Content, &metadataBytes, &createdBy, &deployment.CreatedAt) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -255,7 +341,7 @@ func (r *DeploymentRepo) GetWithContent(deploymentID, artifactUUID, orgUUID stri return nil, err } - if err := applyDeploymentBase(deployment, baseDeploymentID, createdBy, metadataBytes); err != nil { + if err := applyDeploymentBase(deployment, baseDeploymentID, buildUUID, buildID, createdBy, metadataBytes); err != nil { return nil, err } return deployment, nil @@ -290,9 +376,10 @@ func (r *DeploymentRepo) GetCurrentByGateway(artifactUUID, gatewayID, orgUUID st query := ` SELECT d.uuid, d.display_name, d.artifact_uuid, d.organization_uuid, d.gateway_uuid, - d.base_deployment_uuid, d.content, d.metadata, d.created_by, d.created_at, + d.base_deployment_uuid, d.build_uuid, b.build_id, d.content, d.metadata, d.created_by, d.created_at, s.status, s.updated_at AS status_updated_at FROM deployments d + LEFT JOIN builds b ON d.build_uuid = b.uuid INNER JOIN deployment_status s ON d.uuid = s.deployment_uuid AND d.artifact_uuid = s.artifact_uuid @@ -304,7 +391,7 @@ func (r *DeploymentRepo) GetCurrentByGateway(artifactUUID, gatewayID, orgUUID st ` + r.db.FetchFirstClause(1) + ` ` - var baseDeploymentID sql.NullString + var baseDeploymentID, buildUUID, buildID sql.NullString var metadataBytes []byte var createdBy sql.NullString var statusStr string @@ -312,7 +399,7 @@ func (r *DeploymentRepo) GetCurrentByGateway(artifactUUID, gatewayID, orgUUID st err := r.db.QueryRow(r.db.Rebind(query), artifactUUID, gatewayID, orgUUID).Scan( &deployment.DeploymentID, &deployment.Name, &deployment.ArtifactID, &deployment.OrganizationID, - &deployment.GatewayID, &baseDeploymentID, &deployment.Content, &metadataBytes, &createdBy, &deployment.CreatedAt, + &deployment.GatewayID, &baseDeploymentID, &buildUUID, &buildID, &deployment.Content, &metadataBytes, &createdBy, &deployment.CreatedAt, &statusStr, &updatedAt) if err != nil { @@ -322,7 +409,7 @@ func (r *DeploymentRepo) GetCurrentByGateway(artifactUUID, gatewayID, orgUUID st return nil, err } - if err := applyDeploymentBase(deployment, baseDeploymentID, createdBy, metadataBytes); err != nil { + if err := applyDeploymentBase(deployment, baseDeploymentID, buildUUID, buildID, createdBy, metadataBytes); err != nil { return nil, err } status := model.DeploymentStatus(statusStr) @@ -575,9 +662,10 @@ func (r *DeploymentRepo) GetWithState(deploymentID, artifactUUID, orgUUID string query := ` SELECT d.uuid, d.display_name, d.artifact_uuid, d.organization_uuid, d.gateway_uuid, - d.base_deployment_uuid, d.metadata, d.created_by, d.created_at, + d.base_deployment_uuid, d.build_uuid, b.build_id, d.metadata, d.created_by, d.created_at, s.status, s.updated_at AS status_updated_at, s.status_reason FROM deployments d + LEFT JOIN builds b ON d.build_uuid = b.uuid LEFT JOIN deployment_status s ON d.uuid = s.deployment_uuid AND d.artifact_uuid = s.artifact_uuid @@ -586,7 +674,7 @@ func (r *DeploymentRepo) GetWithState(deploymentID, artifactUUID, orgUUID string WHERE d.uuid = ? AND d.artifact_uuid = ? AND d.organization_uuid = ? ` - var baseDeploymentID sql.NullString + var baseDeploymentID, buildUUID, buildID sql.NullString var metadataBytes []byte var createdBy sql.NullString var statusStr sql.NullString @@ -595,7 +683,7 @@ func (r *DeploymentRepo) GetWithState(deploymentID, artifactUUID, orgUUID string err := r.db.QueryRow(r.db.Rebind(query), deploymentID, artifactUUID, orgUUID).Scan( &deployment.DeploymentID, &deployment.Name, &deployment.ArtifactID, &deployment.OrganizationID, &deployment.GatewayID, - &baseDeploymentID, &metadataBytes, &createdBy, &deployment.CreatedAt, + &baseDeploymentID, &buildUUID, &buildID, &metadataBytes, &createdBy, &deployment.CreatedAt, &statusStr, &updatedAtVal, &statusReasonStr) if err != nil { @@ -605,7 +693,7 @@ func (r *DeploymentRepo) GetWithState(deploymentID, artifactUUID, orgUUID string return nil, err } - if err := applyDeploymentBase(deployment, baseDeploymentID, createdBy, metadataBytes); err != nil { + if err := applyDeploymentBase(deployment, baseDeploymentID, buildUUID, buildID, createdBy, metadataBytes); err != nil { return nil, err } applyDeploymentStatus(deployment, statusStr, updatedAtVal, statusReasonStr) @@ -643,7 +731,7 @@ func (r *DeploymentRepo) GetDeploymentsWithState(artifactUUID, orgUUID string, g WITH AnnotatedDeployments AS ( SELECT d.uuid, d.display_name, d.artifact_uuid, d.organization_uuid, d.gateway_uuid, - d.base_deployment_uuid, d.metadata, d.created_by, d.created_at, + d.base_deployment_uuid, d.build_uuid, b.build_id, d.metadata, d.created_by, d.created_at, s.status as current_status, s.updated_at as status_updated_at, s.status_reason, @@ -654,6 +742,7 @@ func (r *DeploymentRepo) GetDeploymentsWithState(artifactUUID, orgUUID string, g d.created_at DESC ) as rank_idx FROM deployments d + LEFT JOIN builds b ON d.build_uuid = b.uuid LEFT JOIN deployment_status s ON d.uuid = s.deployment_uuid AND d.gateway_uuid = s.gateway_uuid @@ -673,7 +762,7 @@ func (r *DeploymentRepo) GetDeploymentsWithState(artifactUUID, orgUUID string, g ) SELECT uuid, display_name, artifact_uuid, organization_uuid, gateway_uuid, - base_deployment_uuid, metadata, created_by, created_at, + base_deployment_uuid, build_uuid, build_id, metadata, created_by, created_at, current_status, status_updated_at, status_reason FROM AnnotatedDeployments WHERE rank_idx <= ? @@ -705,7 +794,7 @@ func (r *DeploymentRepo) GetDeploymentsWithState(artifactUUID, orgUUID string, g var deployments []*model.Deployment for rows.Next() { deployment := &model.Deployment{} - var baseDeploymentID sql.NullString + var baseDeploymentID, buildUUID, buildID sql.NullString var metadataBytes []byte var createdBy sql.NullString var statusStr sql.NullString @@ -715,12 +804,12 @@ func (r *DeploymentRepo) GetDeploymentsWithState(artifactUUID, orgUUID string, g if err := rows.Scan( &deployment.DeploymentID, &deployment.Name, &deployment.ArtifactID, &deployment.OrganizationID, &deployment.GatewayID, - &baseDeploymentID, &metadataBytes, &createdBy, &deployment.CreatedAt, + &baseDeploymentID, &buildUUID, &buildID, &metadataBytes, &createdBy, &deployment.CreatedAt, &statusStr, &updatedAtVal, &statusReasonStr); err != nil { return nil, err } - if err := applyDeploymentBase(deployment, baseDeploymentID, createdBy, metadataBytes); err != nil { + if err := applyDeploymentBase(deployment, baseDeploymentID, buildUUID, buildID, createdBy, metadataBytes); err != nil { return nil, err } applyDeploymentStatus(deployment, statusStr, updatedAtVal, statusReasonStr) diff --git a/platform-api/internal/repository/interfaces.go b/platform-api/internal/repository/interfaces.go index 13354e371d..f89c5d5639 100644 --- a/platform-api/internal/repository/interfaces.go +++ b/platform-api/internal/repository/interfaces.go @@ -128,8 +128,21 @@ type APIRepository interface { // DeploymentRepository defines the interface for deployment data operations type DeploymentRepository interface { + // Build methods (immutable rendered snapshots, not bound to a gateway) + CreateBuildWithLimitEnforcement(build *model.Build, hardLimit int) error + GetBuild(buildID, artifactUUID, orgUUID string) (*model.Build, error) + GetBuilds(artifactUUID, orgUUID string, limit int) ([]*model.Build, error) + // Refuses with ErrBuildInUse when a deployment still holds the build + DeleteBuild(buildID, artifactUUID, orgUUID string) error + // Deployment artifact methods (immutable deployments) - CreateWithLimitEnforcement(deployment *model.Deployment, hardLimit int) error // Atomic: count, cleanup if needed, create + // Atomic: count, cleanup if needed, create. A deployment naming a build it runs + // is refused if that build has been pruned since it was resolved + CreateWithLimitEnforcement(deployment *model.Deployment, hardLimit int) error + // Atomic: stores the build this deployment runs alongside the deployment itself, + // enforcing both the build and the deployment limits. The build is stored under + // the deployment's own API and organization + CreateWithBuild(deployment *model.Deployment, build *model.Build, buildHardLimit, hardLimit int) error GetWithContent(deploymentID, artifactUUID, orgUUID string) (*model.Deployment, error) GetWithState(deploymentID, artifactUUID, orgUUID string) (*model.Deployment, error) GetDeploymentsWithState(artifactUUID, orgUUID string, gatewayID *string, status *string, maxPerAPIGW int) ([]*model.Deployment, error) diff --git a/platform-api/internal/server/server.go b/platform-api/internal/server/server.go index cbe805e6a8..e42bd82212 100644 --- a/platform-api/internal/server/server.go +++ b/platform-api/internal/server/server.go @@ -436,10 +436,11 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, // assignment itself is the compile-time contract check: if a service method // signature drifts from the pdk interface, this stops building. pdkDeps := &pdk.Deps{ - Gateways: gatewayService, - Projects: projectService, - Config: cfg, - Logger: slogger, + Gateways: gatewayService, + Projects: projectService, + Deployments: deploymentService, + Config: cfg, + Logger: slogger, } wiring, err := initPlugins(slogger, mux, scopeRegistry, pluginDeps, pdkDeps, internalPlugins, externalPlugins) diff --git a/platform-api/internal/service/build_test.go b/platform-api/internal/service/build_test.go new file mode 100644 index 0000000000..80f5567db1 --- /dev/null +++ b/platform-api/internal/service/build_test.go @@ -0,0 +1,632 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package service + +import ( + "log/slog" + "strconv" + "strings" + "testing" + "time" + + "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/apperror" + "github.com/wso2/api-platform/platform-api/internal/constants" + "github.com/wso2/api-platform/platform-api/internal/model" + "github.com/wso2/api-platform/platform-api/internal/repository" + "github.com/wso2/api-platform/platform-api/internal/utils" +) + +const ( + buildTestOrgUUID = "00000000-0000-0000-0000-0000000000aa" + buildTestAPIUUID = "11111111-1111-1111-1111-1111111111aa" + buildTestGatewayUUID = "22222222-2222-2222-2222-2222222222aa" + buildTestBuildID = "2026-01-31-2" + buildTestBuildUUID = "55555555-5555-5555-5555-5555555555aa" +) + +// buildTestAPIRepo serves one API and accepts gateway associations. +type buildTestAPIRepo struct { + repository.APIRepository + apiModel *model.API +} + +func (m *buildTestAPIRepo) GetAPIByUUID(uuid, orgUUID string) (*model.API, error) { + return m.apiModel, nil +} + +func (m *buildTestAPIRepo) GetAPIAssociations(apiUUID, associationType, orgUUID string) ([]*model.APIAssociation, error) { + return nil, nil +} + +func (m *buildTestAPIRepo) CreateAPIAssociation(association *model.APIAssociation) error { + return nil +} + +// buildTestDeploymentRepo records builds and deployments it is asked to create. +type buildTestDeploymentRepo struct { + repository.DeploymentRepository + + build *model.Build + createdBuild *model.Build + createdWithCap int + builds []*model.Build + getBuildCalls int + + // baseDeployment is what a deployment id resolves to, for the tests that prove + // naming one is no longer a way to deploy. + baseDeployment *model.Deployment + getWithContentCalls int + created *model.Deployment + + // createBuildErr is what the store refuses a build with, and deleteErr what it + // refuses a delete with — the limit and in-use conflicts are decided in the + // transaction, so the service's job is only to turn them into the right answer. + createBuildErr error + deleteErr error + deletedBuildID string +} + +func (m *buildTestDeploymentRepo) CreateBuildWithLimitEnforcement(build *model.Build, hardLimit int) error { + if m.createBuildErr != nil { + return m.createBuildErr + } + if build.BuildID == "" { + build.BuildID = buildTestBuildID + } + m.createdBuild = build + m.createdWithCap = hardLimit + return nil +} + +func (m *buildTestDeploymentRepo) DeleteBuild(buildID, artifactUUID, orgUUID string) error { + m.deletedBuildID = buildID + return m.deleteErr +} + +func (m *buildTestDeploymentRepo) GetBuild(buildID, artifactUUID, orgUUID string) (*model.Build, error) { + m.getBuildCalls++ + if m.build != nil && m.build.BuildID == buildID { + return m.build, nil + } + return nil, nil +} + +func (m *buildTestDeploymentRepo) GetBuilds(artifactUUID, orgUUID string, limit int) ([]*model.Build, error) { + return m.builds, nil +} + +func (m *buildTestDeploymentRepo) GetWithContent(deploymentID, artifactUUID, orgUUID string) (*model.Deployment, error) { + m.getWithContentCalls++ + return m.baseDeployment, nil +} + +func (m *buildTestDeploymentRepo) CreateWithBuild(deployment *model.Deployment, build *model.Build, + buildHardLimit, hardLimit int) error { + if m.createBuildErr != nil { + return m.createBuildErr + } + if build.UUID == "" { + build.UUID = buildTestBuildUUID + } + if build.BuildID == "" { + build.BuildID = buildTestBuildID + } + m.createdBuild = build + m.createdWithCap = buildHardLimit + // As the real write does: the deployment's reference comes from the build it + // has just stored. + deployment.BuildUUID = &build.UUID + deployment.BuildID = &build.BuildID + return m.CreateWithLimitEnforcement(deployment, hardLimit) +} + +func (m *buildTestDeploymentRepo) CreateWithLimitEnforcement(deployment *model.Deployment, hardLimit int) error { + m.created = deployment + return nil +} + +func (m *buildTestDeploymentRepo) SetCurrentWithDetails(artifactUUID, orgUUID, gatewayID, deploymentID string, + status model.DeploymentStatus, statusDesired string, performedAt *time.Time, statusReason string) (time.Time, error) { + return time.Time{}, nil +} + +// buildTestGatewayRepo serves one gateway by handle and by uuid. +type buildTestGatewayRepo struct { + repository.GatewayRepository + gateway *model.Gateway +} + +func (m *buildTestGatewayRepo) GetByHandleAndOrgID(handle, orgUUID string) (*model.Gateway, error) { + return m.gateway, nil +} + +func (m *buildTestGatewayRepo) GetByUUID(gatewayID string) (*model.Gateway, error) { + return m.gateway, nil +} + +func newBuildTestService(apiRepo *buildTestAPIRepo, depRepo *buildTestDeploymentRepo) *DeploymentService { + return &DeploymentService{ + apiRepo: apiRepo, + deploymentRepo: depRepo, + gatewayRepo: &buildTestGatewayRepo{gateway: &model.Gateway{ + ID: buildTestGatewayUUID, + Handle: "test-gateway", + Version: "1.0.0", + }}, + apiUtil: &utils.APIUtil{}, + cfg: &testConfig, + slogger: slog.Default(), + } +} + +func buildTestAPI() *model.API { + return &model.API{ + ID: buildTestAPIUUID, + Handle: "orders-api", + Kind: constants.RestApi, + DataVersion: "1.0", + } +} + +// A build is a snapshot of the definition as it stands now, stored at the +// platform's own data version — it is not translated, because the gateway it will +// be deployed to is not known yet. +func TestCreateBuild_StoresASnapshotAtThePlatformDataVersion(t *testing.T) { + depRepo := &buildTestDeploymentRepo{} + service := newBuildTestService(&buildTestAPIRepo{apiModel: buildTestAPI()}, depRepo) + + build, err := service.CreateBuild(buildTestAPIUUID, buildTestOrgUUID, "tester", "", nil) + if err != nil { + t.Fatalf("CreateBuild: %v", err) + } + if depRepo.createdBuild == nil { + t.Fatal("no build was stored") + } + if len(depRepo.createdBuild.Content) == 0 { + t.Error("the stored build has no rendered content") + } + if depRepo.createdBuild.DataVersion != "1.0" { + t.Errorf("data version = %q, want the API's own 1.0", depRepo.createdBuild.DataVersion) + } + if depRepo.createdBuild.ArtifactID != buildTestAPIUUID || + depRepo.createdBuild.OrganizationID != buildTestOrgUUID { + t.Error("the build is not scoped to the API and organization") + } + if depRepo.createdBuild.CreatedBy != "tester" { + t.Errorf("createdBy = %q", depRepo.createdBuild.CreatedBy) + } + if build.BuildId == "" { + t.Error("no build id was returned") + } + // The configured cap reaches the store, which is what prunes the API's older + // unused builds as this one is added. + if depRepo.createdWithCap != testConfig.Deployments.MaxBuildsPerAPI { + t.Errorf("stored with cap %d, want the configured %d", + depRepo.createdWithCap, testConfig.Deployments.MaxBuildsPerAPI) + } +} + +// The metadata bag travels with the build and is handed back untouched, which is +// what lets a caller record where a build came from — a commit, for an API kept in +// a repository — and read it off the build later. +func TestCreateBuild_RecordsTheGivenMetadata(t *testing.T) { + depRepo := &buildTestDeploymentRepo{} + service := newBuildTestService(&buildTestAPIRepo{apiModel: buildTestAPI()}, depRepo) + + build, err := service.CreateBuild(buildTestAPIUUID, buildTestOrgUUID, "tester", "", + map[string]interface{}{"commitId": "9f1c2ab"}) + if err != nil { + t.Fatalf("CreateBuild: %v", err) + } + if depRepo.createdBuild.Metadata["commitId"] != "9f1c2ab" { + t.Errorf("stored metadata = %v, want the commit recorded", + depRepo.createdBuild.Metadata) + } + if build.Metadata == nil || (*build.Metadata)["commitId"] != "9f1c2ab" { + t.Errorf("returned metadata = %v, want the commit reported back", build.Metadata) + } +} + +// The description is the caller's own note on the snapshot, so it is stored as +// given and reported back — this is what makes a list of builds something a person +// can choose from when deciding what to deploy or which one to delete. +func TestCreateBuild_RecordsTheGivenDescription(t *testing.T) { + depRepo := &buildTestDeploymentRepo{} + service := newBuildTestService(&buildTestAPIRepo{apiModel: buildTestAPI()}, depRepo) + + build, err := service.CreateBuild(buildTestAPIUUID, buildTestOrgUUID, "tester", + "Adds the /reports endpoint", nil) + if err != nil { + t.Fatalf("CreateBuild: %v", err) + } + if depRepo.createdBuild.Description != "Adds the /reports endpoint" { + t.Errorf("stored description = %q", depRepo.createdBuild.Description) + } + if build.Description == nil || *build.Description != "Adds the /reports endpoint" { + t.Errorf("returned description = %v, want the note reported back", build.Description) + } +} + +// A build prepared without a description reports none at all, rather than an empty +// string a console would have to render as a blank line. +func TestCreateBuild_NoDescriptionReportsNone(t *testing.T) { + depRepo := &buildTestDeploymentRepo{} + service := newBuildTestService(&buildTestAPIRepo{apiModel: buildTestAPI()}, depRepo) + + build, err := service.CreateBuild(buildTestAPIUUID, buildTestOrgUUID, "tester", "", nil) + if err != nil { + t.Fatalf("CreateBuild: %v", err) + } + if build.Description != nil { + t.Errorf("description = %v, want none", *build.Description) + } +} + +// Being at the limit with every build in use is a conflict the caller can act on, +// not a server fault: they are told the limit they are up against so they know how +// many deployments stand between them and another build. +func TestCreateBuild_AtTheLimitIsAConflictNamingTheLimit(t *testing.T) { + depRepo := &buildTestDeploymentRepo{createBuildErr: repository.ErrBuildLimitReached} + service := newBuildTestService(&buildTestAPIRepo{apiModel: buildTestAPI()}, depRepo) + + _, err := service.CreateBuild(buildTestAPIUUID, buildTestOrgUUID, "tester", "", nil) + if !apperror.BuildLimitReached.Is(err) { + t.Fatalf("error = %v, want BuildLimitReached", err) + } + if want := strconv.Itoa(testConfig.Deployments.MaxBuildsPerAPI); !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not name the limit %s", err.Error(), want) + } +} + +// A deploy from the API's definition stores a build too, so it hits the same limit +// — and has to say the same thing. Left unmapped this surfaced as a bare 500, which +// tells the caller nothing about what to do. +func TestDeployAPI_AtTheBuildLimitIsTheSameConflict(t *testing.T) { + depRepo := &buildTestDeploymentRepo{createBuildErr: repository.ErrBuildLimitReached} + service := newBuildTestService(&buildTestAPIRepo{apiModel: buildTestAPI()}, depRepo) + + _, err := service.DeployAPI(buildTestAPIUUID, &api.DeployRequest{ + Name: "prod", + Base: "current", + GatewayId: "test-gateway", + }, buildTestOrgUUID, "tester") + if !apperror.BuildLimitReached.Is(err) { + t.Fatalf("error = %v, want BuildLimitReached", err) + } +} + +// Deleting a build is how the caller makes room once the limit refuses another, so +// the id they name is the one that goes. +func TestDeleteBuild_DeletesTheNamedBuild(t *testing.T) { + depRepo := &buildTestDeploymentRepo{} + service := newBuildTestService(&buildTestAPIRepo{apiModel: buildTestAPI()}, depRepo) + + if err := service.DeleteBuild(buildTestAPIUUID, "2026-01-31-2", buildTestOrgUUID); err != nil { + t.Fatalf("DeleteBuild: %v", err) + } + if depRepo.deletedBuildID != "2026-01-31-2" { + t.Errorf("deleted %q, want the build that was named", depRepo.deletedBuildID) + } +} + +// A build a deployment still holds is refused, and the caller is told which step +// comes first — undeploying is their decision to make, not the platform's. +func TestDeleteBuild_HeldByADeploymentIsAConflict(t *testing.T) { + depRepo := &buildTestDeploymentRepo{deleteErr: repository.ErrBuildInUse} + service := newBuildTestService(&buildTestAPIRepo{apiModel: buildTestAPI()}, depRepo) + + err := service.DeleteBuild(buildTestAPIUUID, "2026-01-31-2", buildTestOrgUUID) + if !apperror.BuildInUse.Is(err) { + t.Fatalf("error = %v, want BuildInUse", err) + } +} + +func TestDeleteBuild_UnknownBuildIsNotFound(t *testing.T) { + depRepo := &buildTestDeploymentRepo{deleteErr: repository.ErrBuildNotFound} + service := newBuildTestService(&buildTestAPIRepo{apiModel: buildTestAPI()}, depRepo) + + err := service.DeleteBuild(buildTestAPIUUID, "2026-01-31-99", buildTestOrgUUID) + if !apperror.BuildNotFound.Is(err) { + t.Fatalf("error = %v, want BuildNotFound", err) + } +} + +func TestDeleteBuild_APINotFound(t *testing.T) { + service := newBuildTestService(&buildTestAPIRepo{apiModel: nil}, &buildTestDeploymentRepo{}) + + if err := service.DeleteBuild(buildTestAPIUUID, "2026-01-31-1", buildTestOrgUUID); err == nil { + t.Fatal("expected an error for an API that does not exist") + } +} + +func TestCreateBuild_APINotFound(t *testing.T) { + service := newBuildTestService(&buildTestAPIRepo{apiModel: nil}, &buildTestDeploymentRepo{}) + + if _, err := service.CreateBuild(buildTestAPIUUID, buildTestOrgUUID, "tester", "", nil); err == nil { + t.Fatal("expected an error for an API that does not exist") + } +} + +func TestGetBuild_NotFound(t *testing.T) { + service := newBuildTestService(&buildTestAPIRepo{apiModel: buildTestAPI()}, &buildTestDeploymentRepo{}) + + _, err := service.GetBuild(buildTestAPIUUID, buildTestBuildID, buildTestOrgUUID) + if err == nil || !apperror.BuildNotFound.Is(err) { + t.Fatalf("expected BuildNotFound, got %v", err) + } +} + +// The point of preparing: deploying a build sends THAT snapshot, not a fresh +// rendering of whatever the API's definition has become since. +func TestDeployAPI_FromABuild_SendsTheStoredSnapshot(t *testing.T) { + const snapshot = "apiVersion: gateway.wso2.com/v1\nkind: RestApi\nmetadata:\n name: orders-api\nspec:\n context: /orders\n" + depRepo := &buildTestDeploymentRepo{ + build: &model.Build{ + UUID: buildTestBuildUUID, + BuildID: buildTestBuildID, + ArtifactID: buildTestAPIUUID, + Content: []byte(snapshot), + DataVersion: "1.0", + }, + } + service := newBuildTestService(&buildTestAPIRepo{apiModel: buildTestAPI()}, depRepo) + + deployment, err := service.DeployAPI(buildTestAPIUUID, &api.DeployRequest{ + Name: "orders-dev", + Base: "build", + BuildId: ptr(buildTestBuildID), + GatewayId: "test-gateway", + }, buildTestOrgUUID, "tester") + if err != nil { + t.Fatalf("DeployAPI: %v", err) + } + if depRepo.created == nil { + t.Fatal("no deployment was created") + } + if !strings.Contains(string(depRepo.created.Content), "/orders") { + t.Errorf("the deployment does not carry the build's artifact: %s", depRepo.created.Content) + } + // A deployment made from a build references the build row, which is the only + // record of where it came from — and what makes "which deployments came from + // this build" answerable, and pruning able to tell what is still in use. + if depRepo.created.BuildUUID == nil || *depRepo.created.BuildUUID != buildTestBuildUUID { + t.Errorf("buildUuid = %v, want %q", depRepo.created.BuildUUID, buildTestBuildUUID) + } + if depRepo.created.BuildID == nil || *depRepo.created.BuildID != buildTestBuildID { + t.Errorf("buildId = %v, want %q", depRepo.created.BuildID, buildTestBuildID) + } + // The readable id lives with the build, never copied into the deployment's + // metadata, so the two can never drift apart. + if _, ok := depRepo.created.Metadata["buildId"]; ok { + t.Error("the build id was copied into deployment metadata") + } + // A build is not a deployment, so it is not recorded as the base deployment. + if depRepo.created.BaseDeploymentID != nil { + t.Errorf("baseDeploymentId = %v, want nil for a build base", *depRepo.created.BaseDeploymentID) + } + if deployment == nil { + t.Fatal("no deployment was returned") + } +} + +// Deploying from the definition is not deploying WITHOUT a build: the render is +// stored as one and the deployment runs that, so what a gateway is serving is +// always traceable to a snapshot, and the next environment has something to +// promote. The build is written with the deployment, not before it. +func TestDeployAPI_FromTheDefinitionStoresTheBuildItRuns(t *testing.T) { + depRepo := &buildTestDeploymentRepo{} + service := newBuildTestService(&buildTestAPIRepo{apiModel: buildTestAPI()}, depRepo) + + deployment, err := service.DeployAPI(buildTestAPIUUID, &api.DeployRequest{ + Name: "orders-dev", + Base: "current", + GatewayId: "test-gateway", + }, buildTestOrgUUID, "tester") + if err != nil { + t.Fatalf("DeployAPI: %v", err) + } + if deployment == nil || depRepo.created == nil { + t.Fatal("no deployment was created") + } + if len(depRepo.created.Content) == 0 { + t.Error("the deployment carries no artifact") + } + if depRepo.createdBuild == nil { + t.Fatal("no build was stored for a deployment rendered from the definition") + } + if depRepo.created.BuildUUID == nil || *depRepo.created.BuildUUID != depRepo.createdBuild.UUID { + t.Errorf("buildUuid = %v, want the stored build %q", + depRepo.created.BuildUUID, depRepo.createdBuild.UUID) + } + if deployment.BuildId == nil || *deployment.BuildId != depRepo.createdBuild.BuildID { + t.Errorf("response buildId = %v, want %q", deployment.BuildId, depRepo.createdBuild.BuildID) + } + // The build takes its place in the API's budget exactly as a prepared one does. + if depRepo.createdWithCap != testConfig.Deployments.MaxBuildsPerAPI { + t.Errorf("build limit = %d, want the configured %d", + depRepo.createdWithCap, testConfig.Deployments.MaxBuildsPerAPI) + } + // Nothing to look up: the build is the one this deploy just rendered. + if depRepo.getBuildCalls != 0 { + t.Errorf("builds were read %d time(s); deploying from the definition must not need them", + depRepo.getBuildCalls) + } +} + +// The build is the definition as it stood, not one deployment's customization of +// it: a deployment's overrides belong to that deployment, so the next environment +// promoting this build is not silently given this gateway's endpoint. +func TestDeployAPI_OverridesDoNotReachTheBuild(t *testing.T) { + apiModel := buildTestAPI() + apiModel.Configuration.Upstream.Main = &model.UpstreamEndpoint{URL: "http://orders.internal:8080"} + depRepo := &buildTestDeploymentRepo{} + service := newBuildTestService(&buildTestAPIRepo{apiModel: apiModel}, depRepo) + + _, err := service.DeployAPI(buildTestAPIUUID, &api.DeployRequest{ + Name: "orders-dev", + Base: "current", + GatewayId: "test-gateway", + Metadata: &map[string]interface{}{"endpointUrl": "https://orders-dev.example.com"}, + }, buildTestOrgUUID, "tester") + if err != nil { + t.Fatalf("DeployAPI: %v", err) + } + if depRepo.createdBuild == nil || depRepo.created == nil { + t.Fatal("the deploy stored no build") + } + if !strings.Contains(string(depRepo.created.Content), "orders-dev.example.com") { + t.Errorf("the deployment did not take the override: %s", depRepo.created.Content) + } + if strings.Contains(string(depRepo.createdBuild.Content), "orders-dev.example.com") { + t.Errorf("the override reached the build: %s", depRepo.createdBuild.Content) + } + if !strings.Contains(string(depRepo.createdBuild.Content), "orders.internal:8080") { + t.Errorf("the build is not the definition as it stood: %s", depRepo.createdBuild.Content) + } +} + +// The explicit field says what the value is, instead of leaving the server to +// guess whether an id names a deployment or a build. +func TestDeployAPI_BuildIdNamesTheBuildDirectly(t *testing.T) { + const snapshot = "apiVersion: gateway.wso2.com/v1\nkind: RestApi\nmetadata:\n name: orders-api\nspec:\n context: /orders\n" + depRepo := &buildTestDeploymentRepo{ + build: &model.Build{ + UUID: buildTestBuildUUID, + BuildID: buildTestBuildID, + ArtifactID: buildTestAPIUUID, + Content: []byte(snapshot), + DataVersion: "1.0", + }, + } + service := newBuildTestService(&buildTestAPIRepo{apiModel: buildTestAPI()}, depRepo) + + _, err := service.DeployAPI(buildTestAPIUUID, &api.DeployRequest{ + Name: "orders-prod", + Base: "build", + BuildId: ptr(buildTestBuildID), + GatewayId: "test-gateway", + }, buildTestOrgUUID, "tester") + if err != nil { + t.Fatalf("DeployAPI: %v", err) + } + // Named the build, so it ships that snapshot rather than rendering the + // definition again. + if !strings.Contains(string(depRepo.created.Content), "/orders") { + t.Errorf("the deployment does not carry the build's artifact: %s", depRepo.created.Content) + } + if depRepo.created.BuildUUID == nil || *depRepo.created.BuildUUID != buildTestBuildUUID { + t.Errorf("buildUuid = %v, want %q", depRepo.created.BuildUUID, buildTestBuildUUID) + } +} + +// base and buildId have to agree: "build" without one leaves nothing to resolve. +func TestDeployAPI_BuildBaseRequiresABuildId(t *testing.T) { + service := newBuildTestService(&buildTestAPIRepo{apiModel: buildTestAPI()}, &buildTestDeploymentRepo{}) + + _, err := service.DeployAPI(buildTestAPIUUID, &api.DeployRequest{ + Name: "orders-prod", + Base: "build", + GatewayId: "test-gateway", + }, buildTestOrgUUID, "tester") + if err == nil { + t.Fatal("expected base 'build' without a buildId to be rejected") + } +} + +// And the other way: a buildId sent with base "current" would be silently ignored +// — the deploy renders its own build — so the request is refused rather than +// quietly deploying something else. +func TestDeployAPI_BuildIdIsRejectedWithBaseCurrent(t *testing.T) { + depRepo := &buildTestDeploymentRepo{} + service := newBuildTestService(&buildTestAPIRepo{apiModel: buildTestAPI()}, depRepo) + + _, err := service.DeployAPI(buildTestAPIUUID, &api.DeployRequest{ + Name: "orders-prod", + Base: "current", + BuildId: ptr(buildTestBuildID), + GatewayId: "test-gateway", + }, buildTestOrgUUID, "tester") + if err == nil { + t.Fatal("expected a buildId alongside base 'current' to be rejected") + } + if depRepo.created != nil { + t.Error("a deployment was created from a request that should not have been accepted") + } +} + +// base is what says where the artifact comes from, so it is always required. +func TestDeployAPI_BaseIsRequired(t *testing.T) { + service := newBuildTestService(&buildTestAPIRepo{apiModel: buildTestAPI()}, &buildTestDeploymentRepo{}) + + _, err := service.DeployAPI(buildTestAPIUUID, &api.DeployRequest{ + Name: "orders-prod", + GatewayId: "test-gateway", + }, buildTestOrgUUID, "tester") + if err == nil { + t.Fatal("expected a request without a base to be rejected") + } +} + +// Naming a build that does not exist is a build error, not a base error: the +// caller said what it was passing, so the answer can say so too. +func TestDeployAPI_UnknownBuildIdIsRejected(t *testing.T) { + service := newBuildTestService(&buildTestAPIRepo{apiModel: buildTestAPI()}, &buildTestDeploymentRepo{}) + + _, err := service.DeployAPI(buildTestAPIUUID, &api.DeployRequest{ + Name: "orders-prod", + Base: "build", + BuildId: ptr("2099-01-01-9"), + GatewayId: "test-gateway", + }, buildTestOrgUUID, "tester") + if err == nil || !apperror.BuildNotFound.Is(err) { + t.Fatalf("expected BuildNotFound, got %v", err) + } +} + +// base names one of two sources and nothing else. An id here used to mean "promote +// that deployment"; it is now refused, so a caller that has not been updated is +// told rather than quietly given a rendering of the current definition. +func TestDeployAPI_ADeploymentIdIsNotABase(t *testing.T) { + depRepo := &buildTestDeploymentRepo{ + // A real deployment, to show that even a resolvable id is not a base. + baseDeployment: &model.Deployment{ + DeploymentID: "33333333-3333-3333-3333-3333333333aa", + ArtifactID: buildTestAPIUUID, + GatewayID: buildTestGatewayUUID, + Content: []byte("apiVersion: gateway.wso2.com/v1\nkind: RestApi\n"), + }, + } + service := newBuildTestService(&buildTestAPIRepo{apiModel: buildTestAPI()}, depRepo) + + _, err := service.DeployAPI(buildTestAPIUUID, &api.DeployRequest{ + Name: "orders-prod", + Base: "33333333-3333-3333-3333-3333333333aa", + GatewayId: "test-gateway", + }, buildTestOrgUUID, "tester") + if err == nil || !apperror.RESTAPIDeploymentValidationFailed.Is(err) { + t.Fatalf("expected a validation failure, got %v", err) + } + if depRepo.created != nil { + t.Error("a deployment was created from a base that is no longer accepted") + } + if depRepo.getWithContentCalls != 0 { + t.Errorf("deployments were read %d time(s); a deploymentId base is refused outright", + depRepo.getWithContentCalls) + } +} diff --git a/platform-api/internal/service/deployment.go b/platform-api/internal/service/deployment.go index acd50a3d62..79c972dea8 100644 --- a/platform-api/internal/service/deployment.go +++ b/platform-api/internal/service/deployment.go @@ -18,6 +18,7 @@ package service import ( + "errors" "fmt" "log/slog" "net/url" @@ -38,6 +39,14 @@ import ( "gopkg.in/yaml.v3" ) +// The two sources a deployment can come from. Every deployment runs a build +// either way: `build` names one prepared earlier, and `current` renders one from +// the API's definition as part of the deploy. +const ( + deployBaseCurrent = "current" + deployBaseBuild = "build" +) + // vhostLabelRe matches a single valid DNS label per RFC 1035. var vhostLabelRe = regexp.MustCompile(`^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`) @@ -85,14 +94,183 @@ func NewDeploymentService( } } -// DeployAPI creates a new immutable deployment artifact and deploys it to a gateway +// CreateBuild renders the API's current definition into an immutable snapshot and +// stores it, without deploying it anywhere. +// +// Preparing and deploying are separate on purpose: a build fixes WHAT will be +// deployed at a known moment, so a later deploy cannot silently pick up edits made +// since, and the same snapshot can be deployed to any number of gateways and +// promoted onward without being re-rendered. The artifact is stored at the +// platform's own data version — the target gateway is not known yet, so +// translation happens at deploy time. +func (s *DeploymentService) CreateBuild(apiUUID, orgUUID, createdBy, description string, + metadata map[string]interface{}) (*api.BuildResponse, error) { + apiModel, err := s.apiRepo.GetAPIByUUID(apiUUID, orgUUID) + if err != nil { + return nil, err + } + if apiModel == nil { + return nil, apperror.RESTAPINotFound.New() + } + // DP-originated artifacts are read-only in the control plane, so there is + // nothing here to snapshot and deploy. + if err := ensureOriginMutable(apiModel.Origin); err != nil { + return nil, err + } + + build, _, err := s.renderBuild(apiModel, apiUUID, orgUUID, createdBy, metadata) + if err != nil { + return nil, err + } + build.Description = description + if err := s.deploymentRepo.CreateBuildWithLimitEnforcement(build, s.cfg.Deployments.MaxBuildsPerAPI); err != nil { + return nil, s.buildLimitError(err) + } + s.slogger.Debug("Build created", "buildID", build.BuildID, "apiUUID", apiUUID) + return toAPIBuildResponse(build), nil +} + +// renderBuild renders an API's current definition into a build that has not been +// stored yet, and hands back the struct it was rendered from alongside it. +// Preparing a build stores it on its own; deploying from `current` stores it on the +// transaction that records the deployment. The struct is returned so that path can +// apply its overrides and translate for the target gateway without re-parsing what +// it has just written — and those overrides never reach the build, whose content is +// marshalled here: a build is the definition as it stood, not one deployment's +// customization of it. +func (s *DeploymentService) renderBuild(apiModel *model.API, apiUUID, orgUUID, createdBy string, + metadata map[string]interface{}) (*model.Build, *dto.APIDeploymentYAML, error) { + apiDeployment, err := s.apiUtil.BuildAPIDeploymentYAML(apiModel) + if err != nil { + return nil, nil, fmt.Errorf("failed to build API deployment YAML: %w", err) + } + contentBytes, err := yaml.Marshal(apiDeployment) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal API deployment YAML: %w", err) + } + return &model.Build{ + ArtifactID: apiUUID, + OrganizationID: orgUUID, + Content: contentBytes, + DataVersion: apiModel.DataVersion, + Metadata: metadata, + CreatedBy: createdBy, + }, apiDeployment, nil +} + +// GetBuild returns one build of an API. +func (s *DeploymentService) GetBuild(apiUUID, buildID, orgUUID string) (*api.BuildResponse, error) { + build, err := s.deploymentRepo.GetBuild(buildID, apiUUID, orgUUID) + if err != nil { + return nil, err + } + if build == nil { + return nil, apperror.BuildNotFound.New() + } + return toAPIBuildResponse(build), nil +} + +// GetBuilds lists an API's builds, newest first. +func (s *DeploymentService) GetBuilds(apiUUID, orgUUID string, limit int) (*api.BuildListResponse, error) { + apiModel, err := s.apiRepo.GetAPIByUUID(apiUUID, orgUUID) + if err != nil { + return nil, err + } + if apiModel == nil { + return nil, apperror.RESTAPINotFound.New() + } + builds, err := s.deploymentRepo.GetBuilds(apiUUID, orgUUID, limit) + if err != nil { + return nil, err + } + list := make([]api.BuildResponse, 0, len(builds)) + for _, build := range builds { + list = append(list, *toAPIBuildResponse(build)) + } + return &api.BuildListResponse{Count: len(list), List: list}, nil +} + +// DeleteBuild removes one of an API's builds. +// +// A build a deployment holds is not deleted: the deployment — running, or suspended +// and still restorable — would be left with no snapshot to trace back to or promote +// onward, and the definition as it stood cannot be rendered again. So the conflict +// is reported and the caller chooses which deployment to give up, which is the same +// judgement that preparing a build at the limit asks of them. +func (s *DeploymentService) DeleteBuild(apiUUID, buildID, orgUUID string) error { + apiModel, err := s.apiRepo.GetAPIByUUID(apiUUID, orgUUID) + if err != nil { + return err + } + if apiModel == nil { + return apperror.RESTAPINotFound.New() + } + if err := s.deploymentRepo.DeleteBuild(buildID, apiUUID, orgUUID); err != nil { + switch { + case errors.Is(err, repository.ErrBuildNotFound): + return apperror.BuildNotFound.New() + case errors.Is(err, repository.ErrBuildInUse): + return apperror.BuildInUse.New() + } + return err + } + s.slogger.Debug("Build deleted", "buildID", buildID, "apiUUID", apiUUID) + return nil +} + +// buildLimitError turns the repository's "nothing free to remove" signal into the +// conflict a caller can act on, naming the limit they are up against. Any other +// error is passed through untouched. +func (s *DeploymentService) buildLimitError(err error) error { + if errors.Is(err, repository.ErrBuildLimitReached) { + return apperror.BuildLimitReached.New(s.cfg.Deployments.MaxBuildsPerAPI) + } + return err +} + +// toAPIBuildResponse projects a stored build onto the API response. +func toAPIBuildResponse(build *model.Build) *api.BuildResponse { + out := &api.BuildResponse{ + BuildId: build.BuildID, + Uuid: utils.ParseOpenAPIUUIDOrZero(build.UUID), + Description: utils.StringPtrIfNotEmpty(build.Description), + DataVersion: utils.StringPtrIfNotEmpty(build.DataVersion), + CreatedBy: utils.StringPtrIfNotEmpty(build.CreatedBy), + CreatedAt: build.CreatedAt, + } + if len(build.Metadata) > 0 { + metadata := build.Metadata + out.Metadata = &metadata + } + return out +} + +// DeployAPI creates a new immutable deployment artifact and deploys it to a +// gateway. Every deployment runs a build: base "build" deploys one prepared +// earlier, and base "current" renders one from the API's definition and stores it +// with the deployment, so what a gateway is serving is always traceable to a +// snapshot and the next environment always has something to promote. func (s *DeploymentService) DeployAPI(apiUUID string, req *api.DeployRequest, orgUUID, createdBy string) (*api.DeploymentResponse, error) { // Validate request if req == nil { return nil, apperror.RESTAPIDeploymentValidationFailed.New("A request body is required.") } - if req.Base == "" { - return nil, apperror.RESTAPIDeploymentValidationFailed.New("Base is required (use 'current' or a deploymentId).") + base := strings.TrimSpace(req.Base) + if base == "" { + return nil, apperror.RESTAPIDeploymentValidationFailed.New("Base is required (use 'current' or 'build').") + } + if base != deployBaseCurrent && base != deployBaseBuild { + return nil, apperror.RESTAPIDeploymentValidationFailed.New("Base must be 'current' or 'build'.") + } + // base says which of the two this is, so buildId is expected with one and + // meaningless with the other. Rejecting it where it cannot apply keeps a request + // from looking like it asked for something it did not get. + requestedBuild := strings.TrimSpace(utils.ValueOrEmpty(req.BuildId)) + if base == deployBaseBuild && requestedBuild == "" { + return nil, apperror.RESTAPIDeploymentValidationFailed.New("A buildId is required when base is 'build'.") + } + if base == deployBaseCurrent && requestedBuild != "" { + return nil, apperror.RESTAPIDeploymentValidationFailed.New("A buildId applies only when base is 'build'.") } gatewayHandle := strings.TrimSpace(req.GatewayId) if gatewayHandle == "" { @@ -130,22 +308,45 @@ func (s *DeploymentService) DeployAPI(apiUUID string, req *api.DeployRequest, or return nil, apperror.RESTAPIDeploymentValidationFailed.New("Deployment name is required.") } - var baseDeploymentID *string - var contentBytes []byte - var baseDeployment *model.Deployment - - // Determine the source: "current" or existing deployment - if req.Base != "current" { - // Use existing deployment as base + // The artifact this deployment runs comes from a build either way, so each base + // resolves to one: `build` to the snapshot it names, `current` to a snapshot of + // the definition taken now, which is stored along with the deployment. + // + // apiDeployment is that build's artifact as a struct, ready for this + // deployment's overrides and for translation to the target gateway below. + var apiDeployment *dto.APIDeploymentYAML + var sourceDataVersion gatewaytranslator.PlatformDataVersion + // newBuild is the build this deploy renders and stores; buildUUID/buildReadableID + // name a build prepared earlier. Exactly one of the two is set. + var newBuild *model.Build + var buildUUID *string + var buildReadableID *string + + switch base { + case deployBaseBuild: + baseBuild, err := s.deploymentRepo.GetBuild(requestedBuild, apiUUID, orgUUID) + if err != nil { + return nil, fmt.Errorf("failed to get build: %w", err) + } + if baseBuild == nil { + return nil, apperror.BuildNotFound.New() + } + apiDeployment = &dto.APIDeploymentYAML{} + if err := yaml.Unmarshal(baseBuild.Content, apiDeployment); err != nil { + return nil, fmt.Errorf("failed to parse build YAML: %w", err) + } + sourceDataVersion = gatewaytranslator.PlatformDataVersion(baseBuild.DataVersion) + // Record which build this deployment runs, so it can be traced back to the + // snapshot it came from. + buildUUID = &baseBuild.UUID + buildReadableID = &baseBuild.BuildID + case deployBaseCurrent: var err error - baseDeployment, err = s.deploymentRepo.GetWithContent(req.Base, apiUUID, orgUUID) + newBuild, apiDeployment, err = s.renderBuild(apiModel, apiUUID, orgUUID, createdBy, nil) if err != nil { - if apperror.DeploymentNotFound.Is(err) { - return nil, apperror.DeploymentBaseNotFound.Wrap(err) - } - return nil, fmt.Errorf("failed to get base deployment: %w", err) + return nil, err } - baseDeploymentID = &req.Base + sourceDataVersion = gatewaytranslator.PlatformDataVersion(apiModel.DataVersion) } // Generate deployment ID @@ -156,40 +357,17 @@ func (s *DeploymentService) DeployAPI(apiUUID string, req *api.DeployRequest, or // Declare override variables var endpointURL *string - var needsOverride bool var vhostMainOverridden bool var vhostSandboxOverridden bool - // Determine vhost values. - // For "current" base: default to sentinel so the gateway resolves and persists its defaults. - // For an existing deployment base: start from the base's stored vhosts, then apply any overrides. - var vhostMain *string + // A build carries no vhost, so default to the sentinel and let the gateway + // resolve and persist its own. + mainSentinel := constants.VhostGatewayDefault + vhostMain := &mainSentinel var vhostSandbox *string - - if req.Base == "current" { - // Fresh deployment: default to sentinel so the gateway resolves and persists its defaults. - mainSentinel := constants.VhostGatewayDefault - vhostMain = &mainSentinel - if apiModel.Configuration.Upstream.Sandbox != nil { - sandboxSentinel := constants.VhostGatewayDefault - vhostSandbox = &sandboxSentinel - } - } else { - // Base deployment: start from the base's stored vhosts. - if baseDeployment != nil && baseDeployment.Metadata != nil { - if m, ok := baseDeployment.Metadata[constants.MetadataKeyVhostMain]; ok { - if ms, ok := m.(string); ok && ms != "" { - val := ms - vhostMain = &val - } - } - if m, ok := baseDeployment.Metadata[constants.MetadataKeyVhostSandbox]; ok { - if ms, ok := m.(string); ok && ms != "" { - val := ms - vhostSandbox = &val - } - } - } + if apiModel.Configuration.Upstream.Sandbox != nil { + sandboxSentinel := constants.VhostGatewayDefault + vhostSandbox = &sandboxSentinel } // Apply overrides from metadata (endpointUrl, vhostMain, vhostSandbox) @@ -204,7 +382,6 @@ func (s *DeploymentService) DeployAPI(apiUUID string, req *api.DeployRequest, or return nil, fmt.Errorf("invalid endpoint URL in metadata: %w", err) } endpointURL = &eu - needsOverride = true } } @@ -220,7 +397,6 @@ func (s *DeploymentService) DeployAPI(apiUUID string, req *api.DeployRequest, or val := vm vhostMain = &val vhostMainOverridden = true - needsOverride = true } } @@ -236,58 +412,34 @@ func (s *DeploymentService) DeployAPI(apiUUID string, req *api.DeployRequest, or val := vs vhostSandbox = &val vhostSandboxOverridden = true - needsOverride = true } } } - // Build content bytes with minimal marshal/unmarshal - if req.Base == "current" { - // Build struct directly, apply overrides on struct, marshal once - apiDeployment, err := s.apiUtil.BuildAPIDeploymentYAML(apiModel) - if err != nil { - return nil, fmt.Errorf("failed to build API deployment YAML: %w", err) - } - applyStructOverrides(apiDeployment, endpointURL, vhostMain, vhostSandbox) - sourceDataVersion := gatewaytranslator.PlatformDataVersion(apiModel.DataVersion) - targetDataVersion := gatewaytranslator.GatewayDataVersionForGateway(gateway.Version) - if err := gatewaytranslator.Translate(apiModel.Kind, sourceDataVersion, targetDataVersion, apiDeployment); err != nil { - return nil, fmt.Errorf("failed to transform API deployment for gateway %s: %w", gateway.Version, err) - } - contentBytes, err = yaml.Marshal(apiDeployment) - if err != nil { - return nil, fmt.Errorf("failed to marshal API deployment YAML: %w", err) - } - if endpointURL != nil { - s.slogger.Debug("Endpoint URL overridden", "endpointURL", *endpointURL, "deploymentID", deploymentID) - } - if vhostMainOverridden { - s.slogger.Debug("Vhost main overridden", "vhostMain", *vhostMain, "deploymentID", deploymentID) - } - if vhostSandboxOverridden { - s.slogger.Debug("Vhost sandbox overridden", "vhostSandbox", *vhostSandbox, "deploymentID", deploymentID) - } - } else { - // Start from base deployment bytes - contentBytes = baseDeployment.Content - if needsOverride { - // Single unmarshal -> apply overrides -> single marshal - contentBytes, err = applyDeploymentOverrides(contentBytes, endpointURL, vhostMain, vhostSandbox, vhostMainOverridden, vhostSandboxOverridden) - if err != nil { - return nil, fmt.Errorf("failed to apply deployment overrides: %w", err) - } - if endpointURL != nil { - s.slogger.Debug("Endpoint URL overridden", "endpointURL", *endpointURL, "deploymentID", deploymentID) - } - if vhostMainOverridden { - s.slogger.Debug("Vhost main overridden", "vhostMain", *vhostMain, "deploymentID", deploymentID) - } - if vhostSandboxOverridden { - s.slogger.Debug("Vhost sandbox overridden", "vhostSandbox", *vhostSandbox, "deploymentID", deploymentID) - } - } + // The build's artifact, customized for this deployment and translated to the + // target gateway's data version. Builds are stored at a platform data version, + // so a build prepared before a gateway upgrade still deploys onto it. + applyStructOverrides(apiDeployment, endpointURL, vhostMain, vhostSandbox) + targetDataVersion := gatewaytranslator.GatewayDataVersionForGateway(gateway.Version) + if err := gatewaytranslator.Translate(apiModel.Kind, sourceDataVersion, targetDataVersion, apiDeployment); err != nil { + return nil, fmt.Errorf("failed to transform API deployment for gateway %s: %w", gateway.Version, err) + } + contentBytes, err := yaml.Marshal(apiDeployment) + if err != nil { + return nil, fmt.Errorf("failed to marshal API deployment YAML: %w", err) + } + if endpointURL != nil { + // The URL itself is not logged: it comes from the request and is validated + // only for scheme and host, so it can carry userinfo or a credential in its + // query. It is stored on the deployment, which is where to read it back from. + s.slogger.Debug("Endpoint URL overridden", "deploymentID", deploymentID) + } + if vhostMainOverridden { + s.slogger.Debug("Vhost main overridden", "vhostMain", *vhostMain, "deploymentID", deploymentID) + } + if vhostSandboxOverridden { + s.slogger.Debug("Vhost sandbox overridden", "vhostSandbox", *vhostSandbox, "deploymentID", deploymentID) } - // If base: and no overrides, contentBytes passes through unchanged. // Store vhost in metadata so it is returned in the deployment response. if vhostMain != nil { @@ -300,23 +452,40 @@ func (s *DeploymentService) DeployAPI(apiUUID string, req *api.DeployRequest, or // Create new deployment record with limit enforcement. // Hard limit = soft limit (configured) + 5 buffer for concurrent deployments deployment := &model.Deployment{ - DeploymentID: deploymentID, - Name: req.Name, - ArtifactID: apiUUID, - OrganizationID: orgUUID, - GatewayID: gatewayID, - BaseDeploymentID: baseDeploymentID, - Content: contentBytes, - Metadata: metadata, - CreatedBy: createdBy, - } - - // Use CreateDeploymentWithLimitEnforcement - handles count, cleanup, insert, and status update atomically + DeploymentID: deploymentID, + Name: req.Name, + ArtifactID: apiUUID, + OrganizationID: orgUUID, + GatewayID: gatewayID, + BuildUUID: buildUUID, + BuildID: buildReadableID, + Content: contentBytes, + Metadata: metadata, + CreatedBy: createdBy, + } + + // Both writes handle count, cleanup, insert and status update atomically. if s.cfg.Deployments.MaxPerAPIGateway < 1 { return nil, fmt.Errorf("MaxPerAPIGateway limit config must be at least 1, got %d", s.cfg.Deployments.MaxPerAPIGateway) } hardLimit := s.cfg.Deployments.MaxPerAPIGateway + constants.DeploymentLimitBuffer - if err := s.deploymentRepo.CreateWithLimitEnforcement(deployment, hardLimit); err != nil { + // A build rendered for this deploy is stored with the deployment, in one + // transaction: a deployment that is recorded always has the build it runs, and a + // deploy that fails leaves no build behind. One prepared earlier is already + // stored, so recording the deployment only has to confirm it is still there. + if newBuild != nil { + err = s.deploymentRepo.CreateWithBuild(deployment, newBuild, + s.cfg.Deployments.MaxBuildsPerAPI, hardLimit) + } else { + err = s.deploymentRepo.CreateWithLimitEnforcement(deployment, hardLimit) + } + if err != nil { + // A deploy from the API's definition stores its build, so it is refused at + // the build limit exactly as preparing one is — and for the same reason, + // which the caller has to be told rather than shown a bare 500. + if limitErr := s.buildLimitError(err); limitErr != err { + return nil, limitErr + } return nil, fmt.Errorf("failed to create deployment: %w", err) } @@ -354,7 +523,7 @@ func (s *DeploymentService) DeployAPI(apiUUID string, req *api.DeployRequest, or s.backfillAPIKeysToGateway(apiUUID, gatewayID, createdBy) } - return toAPIDeploymentResponse( + resp, err := toAPIDeploymentResponse( s.gatewayRepo, deployment.DeploymentID, deployment.Name, @@ -366,6 +535,11 @@ func (s *DeploymentService) DeployAPI(apiUUID string, req *api.DeployRequest, or deployment.UpdatedAt, nil, ) + if err != nil { + return nil, err + } + resp.BuildId = deployment.BuildID + return resp, nil } // RestoreDeployment restores a previous deployment (can be ARCHIVED or UNDEPLOYED) @@ -441,7 +615,7 @@ func (s *DeploymentService) RestoreDeployment(apiUUID, deploymentID, gatewayID, _ = s.auditRepo.Record("RESTORE", deploymentID, "deployment", orgUUID, actor) } - return toAPIDeploymentResponse( + resp, err := toAPIDeploymentResponse( s.gatewayRepo, targetDeployment.DeploymentID, targetDeployment.Name, @@ -453,6 +627,11 @@ func (s *DeploymentService) RestoreDeployment(apiUUID, deploymentID, gatewayID, &updatedAt, nil, ) + if err != nil { + return nil, err + } + resp.BuildId = targetDeployment.BuildID + return resp, nil } // UndeployDeployment undeploys an active deployment @@ -521,7 +700,7 @@ func (s *DeploymentService) UndeployDeployment(apiUUID, deploymentID, gatewayID, _ = s.auditRepo.Record("UNDEPLOY", deploymentID, "deployment", orgUUID, actor) } - return toAPIDeploymentResponse( + resp, err := toAPIDeploymentResponse( s.gatewayRepo, deployment.DeploymentID, deployment.Name, @@ -533,6 +712,11 @@ func (s *DeploymentService) UndeployDeployment(apiUUID, deploymentID, gatewayID, &newUpdatedAt, nil, ) + if err != nil { + return nil, err + } + resp.BuildId = deployment.BuildID + return resp, nil } // DeleteDeployment permanently deletes an undeployed deployment artifact @@ -709,45 +893,6 @@ func applyStructOverrides(d *dto.APIDeploymentYAML, endpointURL *string, vhostMa } } -// applyBaseStructOverrides mutates the deployment YAML struct for base-deployment flow. -// It applies endpoint override and selectively updates only overridden vhost fields. -func applyBaseStructOverrides(d *dto.APIDeploymentYAML, endpointURL *string, vhostMain *string, vhostSandbox *string, vhostMainOverridden bool, vhostSandboxOverridden bool) { - applyEndpointOverride(d, endpointURL) - - if !vhostMainOverridden && !vhostSandboxOverridden { - return - } - - if d.Spec.Vhosts == nil { - d.Spec.Vhosts = &dto.Vhosts{} - if vhostMain != nil { - d.Spec.Vhosts.Main = vhostMain - } - } - - if vhostMainOverridden && vhostMain != nil { - d.Spec.Vhosts.Main = vhostMain - } - if vhostSandboxOverridden { - d.Spec.Vhosts.Sandbox = vhostSandbox - } -} - -// applyDeploymentOverrides unmarshals deployment YAML bytes, applies endpoint URL and/or vhost -// overrides, and marshals back. Used for the base-deployment path when overrides are needed. -func applyDeploymentOverrides(contentBytes []byte, endpointURL *string, vhostMain *string, vhostSandbox *string, vhostMainOverridden bool, vhostSandboxOverridden bool) ([]byte, error) { - var apiDeployment dto.APIDeploymentYAML - if err := yaml.Unmarshal(contentBytes, &apiDeployment); err != nil { - return nil, fmt.Errorf("failed to parse deployment YAML: %w", err) - } - applyBaseStructOverrides(&apiDeployment, endpointURL, vhostMain, vhostSandbox, vhostMainOverridden, vhostSandboxOverridden) - modifiedBytes, err := yaml.Marshal(&apiDeployment) - if err != nil { - return nil, fmt.Errorf("failed to marshal modified deployment YAML: %w", err) - } - return modifiedBytes, nil -} - // GetDeployments retrieves all deployments for an API with optional filters func (s *DeploymentService) GetDeployments(apiUUID, orgUUID string, gatewayID *string, status *string) (*api.DeploymentListResponse, error) { // Verify API exists @@ -800,6 +945,7 @@ func (s *DeploymentService) GetDeployments(apiUUID, orgUUID string, gatewayID *s if err != nil { return nil, err } + mapped.BuildId = d.BuildID items = append(items, *mapped) } @@ -829,7 +975,7 @@ func (s *DeploymentService) GetDeployment(apiUUID, deploymentID, orgUUID string) return nil, apperror.DeploymentNotFound.New() } - return toAPIDeploymentResponse( + resp, err := toAPIDeploymentResponse( s.gatewayRepo, deployment.DeploymentID, deployment.Name, @@ -841,6 +987,11 @@ func (s *DeploymentService) GetDeployment(apiUUID, deploymentID, orgUUID string) deployment.UpdatedAt, deployment.StatusReason, ) + if err != nil { + return nil, err + } + resp.BuildId = deployment.BuildID + return resp, nil } // GetDeploymentContent retrieves the immutable content of a deployment @@ -890,6 +1041,44 @@ func (s *DeploymentService) backfillAPIKeysToGateway(apiUUID, gatewayID, actor s BackfillAPIKeysToGateway(s.apiKeyRepo, s.gatewayRepo, s.gatewayEventsService, s.slogger, apiUUID, gatewayID, actor) } +// CreateBuildByHandle prepares a build of an API identified by its handle. +func (s *DeploymentService) CreateBuildByHandle(apiHandle, orgUUID, createdBy, description string, + metadata map[string]interface{}) (*api.BuildResponse, error) { + + apiUUID, err := s.getUUIDByHandle(apiHandle, orgUUID) + if err != nil { + return nil, err + } + return s.CreateBuild(apiUUID, orgUUID, createdBy, description, metadata) +} + +// GetBuildByHandle returns one build of an API identified by its handle. +func (s *DeploymentService) GetBuildByHandle(apiHandle, buildID, orgUUID string) (*api.BuildResponse, error) { + apiUUID, err := s.getUUIDByHandle(apiHandle, orgUUID) + if err != nil { + return nil, err + } + return s.GetBuild(apiUUID, buildID, orgUUID) +} + +// GetBuildsByHandle lists the builds of an API identified by its handle. +func (s *DeploymentService) GetBuildsByHandle(apiHandle, orgUUID string, limit int) (*api.BuildListResponse, error) { + apiUUID, err := s.getUUIDByHandle(apiHandle, orgUUID) + if err != nil { + return nil, err + } + return s.GetBuilds(apiUUID, orgUUID, limit) +} + +// DeleteBuildByHandle deletes one build of an API identified by its handle. +func (s *DeploymentService) DeleteBuildByHandle(apiHandle, buildID, orgUUID string) error { + apiUUID, err := s.getUUIDByHandle(apiHandle, orgUUID) + if err != nil { + return err + } + return s.DeleteBuild(apiUUID, buildID, orgUUID) +} + // DeployAPIByHandle creates a new immutable deployment artifact using API handle func (s *DeploymentService) DeployAPIByHandle(apiHandle string, req *api.DeployRequest, orgUUID, createdBy string) (*api.DeploymentResponse, error) { // Convert API handle to UUID diff --git a/platform-api/internal/service/deployment_test.go b/platform-api/internal/service/deployment_test.go index 3df5c45381..603e25c00a 100644 --- a/platform-api/internal/service/deployment_test.go +++ b/platform-api/internal/service/deployment_test.go @@ -31,7 +31,6 @@ import ( "github.com/wso2/api-platform/platform-api/internal/repository" "github.com/google/uuid" - "gopkg.in/yaml.v3" ) func isValidUUIDString(id string) bool { @@ -235,6 +234,11 @@ func (m *mockDeploymentAPIRepository) Delete(deploymentID, artifactUUID, orgUUID return m.deleteError } +func (m *mockDeploymentAPIRepository) CreateFromBuildWithLimitEnforcement(deployment *model.Deployment, + _ *model.Build, hardLimit int) error { + return m.CreateWithLimitEnforcement(deployment, hardLimit) +} + func (m *mockDeploymentAPIRepository) CreateWithLimitEnforcement(deployment *model.Deployment, hardLimit int) error { return m.createWithLimitError } @@ -334,6 +338,11 @@ func (m *mockDeploymentRepo) Delete(deploymentID, artifactUUID, orgUUID string) return m.deleteError } +func (m *mockDeploymentRepo) CreateFromBuildWithLimitEnforcement(deployment *model.Deployment, + _ *model.Build, hardLimit int) error { + return m.CreateWithLimitEnforcement(deployment, hardLimit) +} + func (m *mockDeploymentRepo) CreateWithLimitEnforcement(deployment *model.Deployment, hardLimit int) error { return m.createWithLimitError } @@ -1344,6 +1353,7 @@ func strPtr(s string) *string { var testConfig = config.Server{ Deployments: config.Deployments{ MaxPerAPIGateway: 20, + MaxBuildsPerAPI: 50, }, } @@ -1929,138 +1939,3 @@ func TestApplyStructOverrides(t *testing.T) { } }) } - -func TestApplyDeploymentOverrides(t *testing.T) { - baseYAML := `apiVersion: gateway.api-platform.wso2.com/v1 -kind: RestApi -metadata: - name: test-api -spec: - displayName: Test API - version: v1.0 - context: /test - upstream: - main: - url: http://backend:8080 - vhosts: - main: old-main.example.com - sandbox: old-sandbox.example.com -` - - t.Run("endpoint only preserves vhosts", func(t *testing.T) { - eu := "https://new.example.com/api" - result, err := applyDeploymentOverrides([]byte(baseYAML), &eu, nil, nil, false, false) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - var parsed dto.APIDeploymentYAML - if err := yaml.Unmarshal(result, &parsed); err != nil { - t.Fatalf("failed to parse result: %v", err) - } - if parsed.Spec.Upstream.Main.URL != "https://new.example.com/api" { - t.Errorf("URL = %q, want %q", parsed.Spec.Upstream.Main.URL, "https://new.example.com/api") - } - if parsed.Spec.Vhosts == nil { - t.Fatal("expected vhosts to remain set") - } - if parsed.Spec.Vhosts.Main == nil || *parsed.Spec.Vhosts.Main != "old-main.example.com" { - t.Errorf("main = %v, want %q", parsed.Spec.Vhosts.Main, "old-main.example.com") - } - if parsed.Spec.Vhosts.Sandbox == nil || *parsed.Spec.Vhosts.Sandbox != "old-sandbox.example.com" { - t.Errorf("sandbox = %v, want %q", parsed.Spec.Vhosts.Sandbox, "old-sandbox.example.com") - } - }) - - t.Run("vhost main only preserves sandbox", func(t *testing.T) { - main := "api.example.com" - result, err := applyDeploymentOverrides([]byte(baseYAML), nil, &main, nil, true, false) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - var parsed dto.APIDeploymentYAML - if err := yaml.Unmarshal(result, &parsed); err != nil { - t.Fatalf("failed to parse result: %v", err) - } - if parsed.Spec.Vhosts == nil || parsed.Spec.Vhosts.Main == nil || *parsed.Spec.Vhosts.Main != "api.example.com" { - t.Errorf("expected vhost main, got %v", parsed.Spec.Vhosts) - } - if parsed.Spec.Vhosts.Sandbox == nil || *parsed.Spec.Vhosts.Sandbox != "old-sandbox.example.com" { - t.Errorf("expected sandbox to be preserved, got %v", parsed.Spec.Vhosts.Sandbox) - } - if parsed.Spec.Upstream.Main.URL != "http://backend:8080" { - t.Errorf("upstream URL should be unchanged, got %q", parsed.Spec.Upstream.Main.URL) - } - }) - - t.Run("vhost sandbox only preserves main", func(t *testing.T) { - sandbox := "sandbox.example.com" - result, err := applyDeploymentOverrides([]byte(baseYAML), nil, nil, &sandbox, false, true) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - var parsed dto.APIDeploymentYAML - if err := yaml.Unmarshal(result, &parsed); err != nil { - t.Fatalf("failed to parse result: %v", err) - } - if parsed.Spec.Vhosts == nil { - t.Fatal("expected vhosts to remain set") - } - if parsed.Spec.Vhosts.Main == nil || *parsed.Spec.Vhosts.Main != "old-main.example.com" { - t.Errorf("main should be preserved, got %v", parsed.Spec.Vhosts.Main) - } - if parsed.Spec.Vhosts.Sandbox == nil || *parsed.Spec.Vhosts.Sandbox != "sandbox.example.com" { - t.Errorf("expected sandbox override, got %v", parsed.Spec.Vhosts.Sandbox) - } - }) - - t.Run("both endpoint and vhosts", func(t *testing.T) { - eu := "https://new.example.com/api" - main := "api.example.com" - sandbox := "sandbox.example.com" - result, err := applyDeploymentOverrides([]byte(baseYAML), &eu, &main, &sandbox, true, true) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - var parsed dto.APIDeploymentYAML - if err := yaml.Unmarshal(result, &parsed); err != nil { - t.Fatalf("failed to parse result: %v", err) - } - if parsed.Spec.Upstream.Main.URL != "https://new.example.com/api" { - t.Errorf("URL = %q, want %q", parsed.Spec.Upstream.Main.URL, "https://new.example.com/api") - } - if parsed.Spec.Vhosts == nil || parsed.Spec.Vhosts.Main == nil || *parsed.Spec.Vhosts.Main != "api.example.com" { - t.Errorf("expected vhost main, got %v", parsed.Spec.Vhosts) - } - if parsed.Spec.Vhosts.Sandbox == nil || *parsed.Spec.Vhosts.Sandbox != "sandbox.example.com" { - t.Errorf("expected sandbox vhost, got %v", parsed.Spec.Vhosts.Sandbox) - } - }) - - t.Run("neither override is no-op", func(t *testing.T) { - result, err := applyDeploymentOverrides([]byte(baseYAML), nil, nil, nil, false, false) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - var parsed dto.APIDeploymentYAML - if err := yaml.Unmarshal(result, &parsed); err != nil { - t.Fatalf("failed to parse result: %v", err) - } - if parsed.Spec.Upstream.Main.URL != "http://backend:8080" { - t.Errorf("upstream URL should be unchanged, got %q", parsed.Spec.Upstream.Main.URL) - } - if parsed.Spec.Vhosts == nil || parsed.Spec.Vhosts.Main == nil || *parsed.Spec.Vhosts.Main != "old-main.example.com" { - t.Errorf("vhost main should remain unchanged, got %v", parsed.Spec.Vhosts) - } - if parsed.Spec.Vhosts.Sandbox == nil || *parsed.Spec.Vhosts.Sandbox != "old-sandbox.example.com" { - t.Errorf("vhost sandbox should remain unchanged, got %v", parsed.Spec.Vhosts.Sandbox) - } - }) - - t.Run("invalid YAML returns error", func(t *testing.T) { - eu := "https://new.example.com/api" - _, err := applyDeploymentOverrides([]byte("not: valid: yaml: :::"), &eu, nil, nil, false, false) - if err == nil { - t.Fatal("expected error for invalid YAML") - } - }) -} diff --git a/platform-api/pdk/deps.go b/platform-api/pdk/deps.go index bfb0a0d8b9..e7a14894ec 100644 --- a/platform-api/pdk/deps.go +++ b/platform-api/pdk/deps.go @@ -36,8 +36,9 @@ import ( // adapter code. The assignment itself is the compile-time contract check: if a // signature drifts, the server stops building. type Deps struct { - Gateways Gateways - Projects Projects + Gateways Gateways + Projects Projects + Deployments Deployments // add more capability groups as external plugins need them // (APIs, Subscriptions, Applications, Organizations, LLM, MCP, …) @@ -79,3 +80,62 @@ type Projects interface { // DeleteProject removes a project within an organization (Delete). DeleteProject(handle, orgID, actor string) error } + +// Deployments exposes build/deploy/read/undeploy access to an API's gateway +// deployments, scoped by organization and addressed by handle. Every method +// mirrors an existing DeploymentService method verbatim and takes the +// organization id explicitly — handlers MUST pass the org resolved from the +// request context, never one from request input (GO-AUTH-005). +// +// A deployment always runs a build: base "current" renders one from the API's +// definition as part of the deploy, and base "build" deploys one prepared earlier, +// named by buildId. That lets a caller fix WHAT will be deployed at a known moment +// — so a deploy cannot silently pick up edits made since — and deploy that same +// snapshot to any number of gateways, or onward to the next environment. +type Deployments interface { + // CreateBuildByHandle renders the API's current definition into an immutable + // snapshot without deploying it, so a later deploy can name that snapshot + // instead of re-rendering whatever the definition has become (Prepare). + // Description is an optional note recorded with the build; metadata is stored + // with it and returned uninterpreted. Refused when the API is at its build + // limit and every stored build is in use by a current deployment; redeploying to + // the same gateway does not run the limit down, since a superseded deployment + // stops holding its build. + CreateBuildByHandle(apiHandle, orgID, actor, description string, metadata map[string]interface{}) (*api.BuildResponse, error) + + // GetBuildByHandle returns one of an API's builds — its id, metadata and when + // it was prepared, not the rendered artifact itself (Read). + GetBuildByHandle(apiHandle, buildID, orgID string) (*api.BuildResponse, error) + + // GetBuildsByHandle lists an API's builds, newest first (Read). + GetBuildsByHandle(apiHandle, orgID string, limit int) (*api.BuildListResponse, error) + + // DeleteBuildByHandle removes one of an API's builds, and is how room is made + // once the limit refuses another (Delete). Refused only while the build is on a + // gateway — DEPLOYED, DEPLOYING or UNDEPLOYING; undeployed, failed and archived + // deployments all release it, so this reaches the builds automatic cleanup will + // not take. Those deployments stay redeployable from their own artifact but stop + // naming a build, so they can no longer be promoted onward — which is why + // reclaiming them is a request rather than something cleanup decides. + DeleteBuildByHandle(apiHandle, buildID, orgID string) error + + // DeployAPIByHandle creates a new immutable deployment of an API onto one + // gateway, from a build (Create). + DeployAPIByHandle(apiHandle string, req *api.DeployRequest, orgID, actor string) (*api.DeploymentResponse, error) + + // GetDeploymentsByHandle lists an API's deployments, optionally filtered by + // gateway handle and status (Read). + GetDeploymentsByHandle(apiHandle, gatewayID, status, orgID string) (*api.DeploymentListResponse, error) + + // GetDeploymentByHandle returns a single deployment of an API, including its + // persisted metadata (Read). + GetDeploymentByHandle(apiHandle, deploymentID, orgID string) (*api.DeploymentResponse, error) + + // UndeployDeploymentByHandle undeploys a deployment from its gateway (Delete). + UndeployDeploymentByHandle(apiHandle, deploymentID, gatewayHandle, orgID, actor string) (*api.DeploymentResponse, error) + + // RestoreDeploymentByHandle puts an UNDEPLOYED or ARCHIVED deployment back on + // its gateway, serving the artifact it already holds rather than rendering or + // building anything new (Update). The deployment must not already be DEPLOYED. + RestoreDeploymentByHandle(apiHandle, deploymentID, gatewayHandle, orgID, actor string) (*api.DeploymentResponse, error) +} diff --git a/platform-api/resources/openapi.yaml b/platform-api/resources/openapi.yaml index 4c10d603bf..131a5ee89e 100644 --- a/platform-api/resources/openapi.yaml +++ b/platform-api/resources/openapi.yaml @@ -772,6 +772,182 @@ paths: '500': $ref: '#/components/responses/InternalServerError' + /rest-apis/{restApiId}/builds: + post: + summary: Prepare a build of a REST API + description: | + Renders the API's current definition into an immutable snapshot and stores it, + without deploying it anywhere. + + Preparing and deploying are separate steps so that what reaches a gateway is a + snapshot taken at a known moment: a deploy that names a build cannot silently + pick up edits made to the API since, and the same build can be deployed to any + number of gateways, and promoted onward, without being re-rendered. + + The artifact is stored at the platform's own data version; it is translated to + the target gateway's version when it is deployed. + + An API keeps at most `deployments.max_builds_per_api` builds. Preparing another + first removes the oldest builds no current deployment is using; if every one is + in use, the request is refused with a `409` and a build has to be deleted to + make room. + + Access is validated against the organization in the JWT token. + operationId: CreateBuild + security: + - OAuth2Security: + - ap:rest_api:build:create + - ap:rest_api:build:manage + - ap:rest_api:manage + tags: + - REST API Deployments + - Deployments + parameters: + - $ref: '#/components/parameters/apiId' + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/BuildRequest' + responses: + '201': + description: Build prepared successfully + headers: + Location: + $ref: '#/components/headers/Location' + content: + application/json: + schema: + $ref: '#/components/schemas/BuildResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '500': + $ref: '#/components/responses/InternalServerError' + + get: + summary: Get builds for a REST API + description: | + Lists the API's builds, newest first. The rendered artifact itself is not + included; a listing is for choosing which build to deploy. + Access is validated against the organization in the JWT token. + operationId: GetBuilds + security: + - OAuth2Security: + - ap:rest_api:build:read + - ap:rest_api:build:manage + - ap:rest_api:manage + tags: + - REST API Deployments + - Deployments + parameters: + - $ref: '#/components/parameters/apiId' + - $ref: '#/components/parameters/limit-Q' + responses: + '200': + description: Builds retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/BuildListResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + /rest-apis/{restApiId}/builds/{buildId}: + get: + summary: Get build by ID + description: | + Retrieves metadata for a single build. + Access is validated against the organization in the JWT token. + operationId: GetBuild + security: + - OAuth2Security: + - ap:rest_api:build:read + - ap:rest_api:build:manage + - ap:rest_api:manage + tags: + - REST API Deployments + - Deployments + parameters: + - $ref: '#/components/parameters/apiId' + - name: buildId + in: path + required: true + schema: + type: string + description: Identifier of the build + responses: + '200': + description: Build metadata retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/BuildResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + delete: + summary: Delete a build + description: | + Deletes one of the API's builds, freeing a slot when the API is at its build + limit. + + Refused with a conflict while a gateway is serving the build — that is, while + any `DEPLOYED`, `DEPLOYING` or `UNDEPLOYING` deployment runs it. Undeploy it + first. + + Undeployed, failed and superseded deployments release the build. They keep the + artifact they were created with, so they can still be redeployed, but they stop + reporting a `buildId` and can no longer be promoted to a later environment. + + Access is validated against the organization in the JWT token. + operationId: DeleteBuild + security: + - OAuth2Security: + - ap:rest_api:build:delete + - ap:rest_api:build:manage + - ap:rest_api:manage + tags: + - REST API Deployments + - Deployments + parameters: + - $ref: '#/components/parameters/apiId' + - name: buildId + in: path + required: true + schema: + type: string + description: Identifier of the build + responses: + '204': + description: Build deleted successfully + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '500': + $ref: '#/components/responses/InternalServerError' + /rest-apis/{restApiId}/deployments: post: summary: Create and deploy a new deployment @@ -780,6 +956,10 @@ paths: Each deployment targets a single gateway. The apiId parameter is the API handle (identifier), not the UUID. The operation returns a transitional DEPLOYING status. Final success or failure will be reported asynchronously via the deployment's status and statusReason once the gateway acknowledges. Access is validated against the organization in the JWT token. + + Every deployment runs a build: `base: build` deploys one prepared earlier, and + `base: current` renders the API's definition into a build and deploys that, both in + one atomic operation. The deployment reports the build it runs as `buildId`. operationId: DeployAPI security: - OAuth2Security: @@ -819,6 +999,8 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' '500': $ref: '#/components/responses/InternalServerError' @@ -4846,6 +5028,10 @@ components: ap:rest_api:api_key:delete: Delete an API key of a REST API ap:rest_api:api_key:manage: Full access to a REST API's API keys ap:rest_api:api_key:update: Update an API key of a REST API + ap:rest_api:build:create: Prepare a build of a REST API + ap:rest_api:build:delete: Delete a build of a REST API + ap:rest_api:build:manage: Full access to a REST API's builds + ap:rest_api:build:read: Read builds of a REST API ap:rest_api:create: Create a REST API ap:rest_api:delete: Delete a REST API ap:rest_api:deployment:create: Deploy a REST API @@ -6699,8 +6885,25 @@ components: example: "v1.0-production" base: type: string - description: The source for the API definition. Can be "current" (latest working copy) or a deploymentId (existing deployment) + description: | + Where the artifact comes from: + + - `current` — render the artifact from the definition as it stands now. + - `build` — deploy a build prepared earlier, named by `buildId`. + + REST API deployments accept only these two and always run a build: `current` + stores what it renders as one, so a running deployment is always traceable to + a stored snapshot. MCP proxy, LLM and event API deployments accept a + `deploymentId` here as well, to promote that deployment by reusing its + rendered artifact. example: "current" + buildId: + type: string + description: | + The build to deploy, such as `2026-01-31-2`. Required when `base` is `build`, + and rejected otherwise. Deploying a build ships that exact snapshot, so it + cannot pick up edits made since it was prepared. + example: "2026-01-31-2" gatewayId: type: string pattern: '^[a-z0-9-]+$' @@ -6713,6 +6916,79 @@ components: additionalProperties: true description: Optional metadata for the deployment. Supported keys include `endpointUrl`, `vhostMain`, and `vhostSandbox`. + BuildRequest: + type: object + description: Optional details to record with a build. + properties: + description: + type: string + maxLength: 1023 + description: | + Optional note recorded with the build, to tell one snapshot from another when + choosing what to deploy or which build to delete. + example: "Adds the /reports endpoint" + metadata: + type: object + additionalProperties: true + description: | + Free-form metadata to store with the build, such as the commit an API kept in a + repository was prepared from. It is returned with the build and is not + interpreted by the platform. + example: + commitId: "9f1c2ab" + + BuildResponse: + type: object + description: An immutable, rendered snapshot of an API's definition, not bound to any gateway. + required: + - buildId + - uuid + - createdAt + properties: + buildId: + type: string + description: | + Identifier for the build, supplied as `buildId` when a deployment's `base` is + `build`. It is the date the build was prepared followed by that day's index for + the API, and is unique per API. + example: "2026-01-31-2" + uuid: + type: string + format: uuid + description: Globally unique identifier for the build, and what a deployment references + description: + type: string + description: Note recorded with the build when it was prepared + dataVersion: + type: string + description: Platform data version the artifact was rendered at; it is translated to the gateway's version when deployed + metadata: + type: object + additionalProperties: true + description: Metadata recorded with the build, such as the commit it was prepared from + createdBy: + type: string + description: Who prepared the build + createdAt: + type: string + format: date-time + description: Timestamp when the build was prepared + + BuildListResponse: + type: object + required: + - count + - list + properties: + count: + type: integer + description: Number of builds in current response + list: + type: array + items: + $ref: '#/components/schemas/BuildResponse' + description: Builds, newest first + DeploymentResponse: type: object required: @@ -6759,6 +7035,20 @@ components: format: uuid nullable: true description: UUID of the base deployment this was created from + buildId: + type: string + nullable: true + description: | + Build this deployment runs, such as `2026-01-31-2`. Every REST API deployment + has one: `base: build` runs the build it names, and `base: current` stores what + it renders as a build and runs that. + + Null for artifact kinds that have no builds — MCP proxy, LLM and event API + deployments — including one promoted from another deployment, which reuses that + deployment's rendered artifact. Also null once the build it ran has been pruned. + Null means only that no build can be named; the deployment keeps its own + rendered artifact either way. + example: "2026-01-31-2" metadata: type: object additionalProperties: true diff --git a/portals/ai-workspace/production/scripts/register_asgardeo_scopes.sh b/portals/ai-workspace/production/scripts/register_asgardeo_scopes.sh index 176f22757b..4996def30a 100755 --- a/portals/ai-workspace/production/scripts/register_asgardeo_scopes.sh +++ b/portals/ai-workspace/production/scripts/register_asgardeo_scopes.sh @@ -109,6 +109,9 @@ SCOPES=( "ap:rest_api:deployment:delete" "ap:rest_api:deployment:restore" "ap:rest_api:deployment:undeploy" + "ap:rest_api:build:create" + "ap:rest_api:build:read" + "ap:rest_api:build:manage" "ap:rest_api:api_key:create" "ap:rest_api:api_key:read" "ap:rest_api:api_key:update" diff --git a/portals/api-control-plane/bff/internal/config/config.go b/portals/api-control-plane/bff/internal/config/config.go index 68a129e11f..ff47ac027e 100644 --- a/portals/api-control-plane/bff/internal/config/config.go +++ b/portals/api-control-plane/bff/internal/config/config.go @@ -252,6 +252,7 @@ const defaultOIDCScopes = "openid profile email offline_access" + " ap:gateway:token:read ap:gateway:token:create ap:gateway:token:delete ap:gateway:token:manage" + " ap:rest_api:read ap:rest_api:create ap:rest_api:update ap:rest_api:delete ap:rest_api:manage ap:rest_api:import" + " ap:rest_api:deployment:read ap:rest_api:deployment:create ap:rest_api:deployment:delete ap:rest_api:deployment:manage" + + " ap:rest_api:build:read ap:rest_api:build:create ap:rest_api:build:delete ap:rest_api:build:manage" + " ap:rest_api:api_key:read ap:rest_api:api_key:create ap:rest_api:api_key:update ap:rest_api:api_key:delete ap:rest_api:api_key:manage" + " ap:subscription:read ap:subscription:create ap:subscription:update ap:subscription:delete ap:subscription:manage" + " ap:subscription_plan:read ap:subscription_plan:create ap:subscription_plan:update ap:subscription_plan:delete ap:subscription_plan:manage" +