diff --git a/platform-api/api/generated.go b/platform-api/api/generated.go index 0d714db06a..a42d0420ec 100644 --- a/platform-api/api/generated.go +++ b/platform-api/api/generated.go @@ -643,6 +643,45 @@ 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 { + // 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, used as a deployment's `base`. 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"` + + // 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 @@ -1020,9 +1059,18 @@ 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 latest working copy now. + // - `build` — deploy a prepared build, named by `buildId`. + // - a `deploymentId` — promote that deployment, 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"` @@ -1048,6 +1096,14 @@ 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 was made from, such as `2026-01-31-2`. Null unless the + // deploy named a build: a deployment rendered from the API definition has none, + // and so does one promoted from another deployment, which reuses that + // deployment's rendered artifact rather than a build. Also null once the build + // it came from has been pruned. Null means only that no build can be named — + // the deployment is still promotable by `deploymentId`. + BuildId *string `json:"buildId" yaml:"buildId"` + // CreatedAt Timestamp when the deployment artifact was created CreatedAt time.Time `binding:"required" json:"createdAt" yaml:"createdAt"` @@ -3098,6 +3154,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. @@ -3284,6 +3346,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..565064a172 100644 --- a/platform-api/config/config-template.toml +++ b/platform-api/config/config-template.toml @@ -374,6 +374,8 @@ enable_functionality_type_verification = false # --------------------------------------------------------------------------- [platform_api.deployments] max_per_api_gateway = 20 # maximum API deployments per gateway +max_builds_per_api = 50 # maximum stored builds per API; preparing more prunes + # the oldest builds no gateway is deployed from (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..f33896351d 100644 --- a/platform-api/config/config.go +++ b/platform-api/config/config.go @@ -554,10 +554,14 @@ 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 past this prunes the API's oldest builds that no gateway is deployed + // 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..c0dbb9ed55 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: 50, TimeoutEnabled: true, TimeoutInterval: 20, TimeoutDuration: 60, diff --git a/platform-api/internal/apperror/catalog.go b/platform-api/internal/apperror/catalog.go index cc7ae25dae..0ba3f7aecb 100644 --- a/platform-api/internal/apperror/catalog.go +++ b/platform-api/internal/apperror/catalog.go @@ -121,6 +121,7 @@ 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.") 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/codes.go b/platform-api/internal/apperror/codes.go index 776d3c9090..dfbf03ca36 100644 --- a/platform-api/internal/apperror/codes.go +++ b/platform-api/internal/apperror/codes.go @@ -84,6 +84,7 @@ 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" CodeDeploymentBaseNotFound = "DEPLOYMENT_BASE_NOT_FOUND" CodeDeploymentRestoreConflict = "DEPLOYMENT_RESTORE_CONFLICT" CodeDeploymentNotFound = "DEPLOYMENT_NOT_FOUND" diff --git a/platform-api/internal/constants/constants.go b/platform-api/internal/constants/constants.go index 00330f75ae..a108560305 100644 --- a/platform-api/internal/constants/constants.go +++ b/platform-api/internal/constants/constants.go @@ -135,6 +135,11 @@ const DeletedUser = "deleted_user" const ( // DeploymentLimitBuffer is the buffer added to MaxPerAPIGateway for hard limit enforcement DeploymentLimitBuffer = 100 + + // BuildCleanupBatch is how many of an API's unused builds are removed when + // preparing another one reaches the limit. A batch rather than one keeps the + // cleanup from running on every single prepare. + BuildCleanupBatch = 5 ) // Gateway artifact apiVersion (the `apiVersion:` field on deployment artifacts). diff --git a/platform-api/internal/database/schema.postgres.sql b/platform-api/internal/database/schema.postgres.sql index a37fb640bc..157316181c 100644 --- a/platform-api/internal/database/schema.postgres.sql +++ b/platform-api/internal/database/schema.postgres.sql @@ -265,6 +265,22 @@ 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, + 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 +289,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 +506,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..33b54aa0ab 100644 --- a/platform-api/internal/database/schema.sql +++ b/platform-api/internal/database/schema.sql @@ -257,6 +257,22 @@ 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, + 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 +281,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 +501,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..f19b77fd21 100644 --- a/platform-api/internal/database/schema.sqlite.sql +++ b/platform-api/internal/database/schema.sqlite.sql @@ -265,6 +265,22 @@ 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, + 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 +289,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 +506,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..1b268ad02f 100644 --- a/platform-api/internal/database/schema.sqlserver.sql +++ b/platform-api/internal/database/schema.sqlserver.sql @@ -293,6 +293,26 @@ 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, + 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 +322,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 +610,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/handler/api_deployment.go b/platform-api/internal/handler/api_deployment.go index 8167d79b2a..335f5d5a8b 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', 'build', or a deploymentId)") + } + 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,102 @@ 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 + } + + build, err := h.deploymentService.CreateBuildByHandle(apiId, orgId, createdBy, 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 +} + // RegisterRoutes registers all deployment-related routes func (h *DeploymentHandler) RegisterRoutes(mux router.Router) { h.slogger.Debug("Registering deployment routes") @@ -281,4 +383,7 @@ 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)) } diff --git a/platform-api/internal/model/deployment.go b/platform-api/internal/model/deployment.go index 0c41329771..82e078b14d 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,37 @@ 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"` + 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..89566566fb --- /dev/null +++ b/platform-api/internal/repository/build.go @@ -0,0 +1,403 @@ +/* + * 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" + "fmt" + "strconv" + "strings" + "time" + + "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/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. + +// 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 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() + } + // An id the caller chose is used as given, so there is no race to settle. + if build.BuildID != "" { + return r.createBuild(build, hardLimit, false) + } + + var err error + for attempt := 0; attempt < buildIDAttempts; attempt++ { + attempted := build.BuildID + if err = r.createBuild(build, hardLimit, true); err == nil { + return nil + } + if attempt > 0 && build.BuildID == attempted { + // The id we just tried is still free, so the attempt failed on something + // other than a concurrent prepare and retrying cannot help. + return err + } + } + return err +} + +// createBuild is one attempt at storing a build: the prune, the id and the insert +// all run in a single transaction. Deciding a build is expendable and referencing +// one are the same judgement about what is still needed, so they are settled +// together — otherwise a concurrent prepare could delete the build a deploy has +// just resolved, or clear the reference a deploy has just written. 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, deriveID bool) error { + tx, err := r.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + if err := r.pruneBuilds(tx, build.ArtifactID, build.OrganizationID, hardLimit); err != nil { + return err + } + if deriveID { + buildID, err := r.nextBuildID(tx, build.ArtifactID, build.OrganizationID, build.CreatedAt) + if err != nil { + return err + } + build.BuildID = buildID + } + if err := r.insertBuild(tx, build); err != nil { + return err + } + return tx.Commit() +} + +// 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, 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.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, 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 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, + &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 + 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, 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 sql.NullString + var metadataBytes []byte + if err := rows.Scan( + &build.UUID, &build.BuildID, &build.ArtifactID, &build.OrganizationID, + &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 + builds = append(builds, &build) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("failed to read builds: %w", err) + } + return builds, nil +} + +// pruneBuilds keeps an API's stored builds within hardLimit before another one is +// added. 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 deleted only when no gateway's +// CURRENT deployment came from it: an old build that something is still serving is +// exactly the one that must survive, because it is what a promotion out of that +// environment carries and what a redeploy of that gateway sends. Age only orders +// the builds that are free to go. +// +// It is deliberately best-effort in what it removes: at most a batch, and if every +// old build is still in use the API simply keeps more than the limit rather than +// failing the prepare or deleting something that is running. +// +// 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 + } + + inUse, err := r.buildsInUse(tx, artifactUUID, orgUUID) + 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) + if len(expendable) == constants.BuildCleanupBatch { + break + } + } + rows.Close() + if err := rows.Err(); err != nil { + return fmt.Errorf("failed to read builds for cleanup: %w", err) + } + + // 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 — no error to unwind in + // the transaction this shares with the build being added, and the API keeping + // more than its budget is already what happens when nothing is free to go. + const 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 + ) + ` + const deleteQuery = ` + DELETE FROM builds + WHERE uuid = ? + AND NOT EXISTS (SELECT 1 FROM deployments d WHERE d.build_uuid = builds.uuid) + ` + for _, buildUUID := range expendable { + if _, err := tx.Exec(r.db.Rebind(clearQuery), buildUUID); err != nil { + return fmt.Errorf("failed to clear references to build %s: %w", buildUUID, err) + } + if _, err := tx.Exec(r.db.Rebind(deleteQuery), buildUUID); err != nil { + return fmt.Errorf("failed to delete build %s: %w", buildUUID, err) + } + } + return nil +} + +// buildsInUse returns the builds an API's gateways are currently deployed from, +// by uuid. +// +// One deployment per gateway is current — the one deployment_status names — and its +// build_uuid says which build it came from. Only those rows count: an archived +// deployment carries its own rendered content and never needs its build back, so it +// is not a reason to keep one. +func (r *DeploymentRepo) buildsInUse(tx *sql.Tx, artifactUUID, orgUUID string) (map[string]bool, error) { + const 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 + ` + 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..95d1056004 --- /dev/null +++ b/platform-api/internal/repository/build_test.go @@ -0,0 +1,599 @@ +/* + * 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" + "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) + } +} + +// 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 resolves its build before the transaction that records the deployment +// opens, so a prepare running alongside it can prune that build in between. When the +// deploy still holds the build, the write puts it back and the deployment keeps its +// origin — pruning it was only ever right while nothing was deploying it. +func TestCreateDeployment_RestoresABuildPrunedMidDeploy(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 + if err := repo.CreateFromBuildWithLimitEnforcement(&model.Deployment{ + DeploymentID: "dep-1", + Name: "dep-1", + ArtifactID: buildRepoAPIUUID, + GatewayID: "gw-1", + OrganizationID: buildRepoOrgUUID, + Content: []byte("content"), + Status: &deployed, + BuildUUID: &build.UUID, + }, build, 100); err != nil { + t.Fatalf("CreateFromBuildWithLimitEnforcement: %v", err) + } + + // The build is back, unchanged, and the deployment names it. + restored, err := repo.GetBuild(build.BuildID, buildRepoAPIUUID, buildRepoOrgUUID) + if err != nil || restored == nil { + t.Fatalf("the build was not restored: %v", err) + } + if restored.UUID != build.UUID || string(restored.Content) != string(build.Content) { + t.Errorf("restored build = %+v, want the same row back", restored) + } + 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) + } +} + +// A deployment must never be recorded having lost the build it came from: the next +// stage promotes what the previous one is running, so a deployment that cannot name +// its build ends the pipeline. A reference that cannot be resolved or restored is +// therefore refused, not quietly cleared. Reaching this means an invariant broke +// upstream, which is worth failing over rather than hiding. +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 prunes a batch of the API's oldest builds, so preparing +// repeatedly cannot grow the table without bound. +func TestCreateBuild_PrunesTheOldestBuildsAtTheLimit(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: a batch of + // five older builds goes, and the new one is added to what remains. + prepareBuilds(t, repo, 11, 10) + + kept := storedBuildIDs(t, repo) + want := []string{ + "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 a gateway is currently deployed from survives, +// however old it is, and a newer unused build 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) + } + } + // Five of the unused builds went instead, oldest first. + for _, pruned := range []string{"2026-01-31-3", "2026-01-31-4", "2026-01-31-5", "2026-01-31-6", "2026-01-31-7"} { + if kept[pruned] { + t.Errorf("unused build %s should have been pruned, kept %v", pruned, 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 is not a reason to keep a build: it carries its own +// rendered content, so restoring it never needs the build back. +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") + } +} + +// With every old build in use there is nothing safe to remove, so the API keeps +// more than the limit rather than the prepare failing or a running build going. +func TestCreateBuild_KeepsEverythingWhenNothingIsFreeToGo(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)) + if err := repo.CreateBuildWithLimitEnforcement(fourth, 3); err != nil { + t.Fatalf("CreateBuildWithLimitEnforcement: %v", err) + } + if kept := storedBuildIDs(t, repo); len(kept) != 4 { + t.Errorf("kept %v, want all four builds retained", kept) + } +} + +// 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 TestCreateBuild_PruningClearsTheReferenceOnArchivedDeployments(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 that build is free to go + // while a deployment still points at it. + 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) + } + + 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 pruned", buildUUID.String) + } + // The deployment still runs, but it now reports no build — which is the honest + // answer, because the snapshot it came from is gone and cannot be promoted. + 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 pruned", *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..67915c1da8 100644 --- a/platform-api/internal/repository/deployment.go +++ b/platform-api/internal/repository/deployment.go @@ -50,6 +50,22 @@ func NewDeploymentRepo(db *database.DB, reg *ArtifactTableRegistry) DeploymentRe // This entire operation is wrapped in a single transaction to ensure atomicity // and to leverage row-level locks during deletion to reduce race conditions. func (r *DeploymentRepo) CreateWithLimitEnforcement(deployment *model.Deployment, hardLimit int) error { + return r.createWithLimitEnforcement(deployment, nil, hardLimit) +} + +// CreateFromBuildWithLimitEnforcement records a deployment made from a build, +// carrying the build itself so the write can put it back if it was pruned between +// being resolved and being recorded here. Restoring is not a second copy: builds +// are immutable, so the row goes back exactly as it was, id and timestamp included. +// It is also not a way around the budget — the build is about to be one a gateway +// is serving, which is the one kind pruning is never allowed to take. +func (r *DeploymentRepo) CreateFromBuildWithLimitEnforcement(deployment *model.Deployment, + build *model.Build, hardLimit int) error { + return r.createWithLimitEnforcement(deployment, build, hardLimit) +} + +func (r *DeploymentRepo) createWithLimitEnforcement(deployment *model.Deployment, + build *model.Build, hardLimit int) error { tx, err := r.db.Begin() if err != nil { return err @@ -140,10 +156,47 @@ func (r *DeploymentRepo) CreateWithLimitEnforcement(deployment *model.Deployment } } + // The build was resolved before this transaction opened, so a prepare running + // alongside this one may have pruned it since. It must also 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. + if deployment.BuildUUID != nil { + owned, err := r.buildBelongsTo(tx, *deployment.BuildUUID, deployment.ArtifactID, deployment.OrganizationID) + if err != nil { + return err + } + if !owned { + exists, err := r.buildExists(tx, *deployment.BuildUUID) + if err != nil { + return err + } + switch { + case exists: + // Present, but another API's. Nothing to do but refuse. + return apperror.BuildNotFound.New() + case build != nil: + // Pruned while this deploy was rendering, and we still hold it. Put it + // back alongside the deployment that needs it, so the two commit + // together and pruning cannot get between them again. A failure here is + // its id having been taken since, which leaves nothing to deploy from. + if err := r.insertBuild(tx, build); err != nil { + return apperror.BuildNotFound.New() + } + default: + // Gone, with no copy to put back. This should not be reachable: a + // reference is only ever set from a build the caller resolved and still + // holds, so there is always something to restore. Refuse rather than + // record a deployment that has silently lost the build it came from. + 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 +204,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,8 +219,18 @@ 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 { + // The check above does not lock the build, so a prune can still land between + // the two. Asking again — outside this transaction, which some drivers treat + // as unusable after a failed statement — tells the two apart, so losing the + // race reads the same as arriving late rather than as a database fault. + if deployment.BuildUUID != nil { + if ok, checkErr := r.buildBelongsTo(r.db, *deployment.BuildUUID, + deployment.ArtifactID, deployment.OrganizationID); checkErr == nil && !ok { + return apperror.BuildNotFound.New() + } + } return err } @@ -194,11 +262,51 @@ func (r *DeploymentRepo) CreateWithLimitEnforcement(deployment *model.Deployment return tx.Commit() } +// buildQuerier is whatever the caller has to hand — the transaction while it is +// still usable, the pool once it is not. +type buildQuerier interface { + QueryRow(query string, args ...any) *sql.Row +} + +// buildExists reports whether the build row is there at all, which is what tells a +// build that has been pruned apart from one that belongs to somebody else. +func (r *DeploymentRepo) buildExists(q buildQuerier, buildUUID string) (bool, error) { + var found int + err := q.QueryRow(r.db.Rebind(`SELECT 1 FROM builds WHERE uuid = ?`), buildUUID).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 +} + +// buildBelongsTo reports whether the build exists under that API and organization. +func (r *DeploymentRepo) buildBelongsTo(q buildQuerier, buildUUID, artifactUUID, orgUUID string) (bool, error) { + const query = `SELECT 1 FROM builds WHERE uuid = ? AND artifact_uuid = ? AND organization_uuid = ?` + var found int + err := q.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 +343,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 +365,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 +400,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 +415,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 +423,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 +433,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 +686,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 +698,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 +707,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 +717,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 +755,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 +766,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 +786,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 +818,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 +828,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..2f69c0dccd 100644 --- a/platform-api/internal/repository/interfaces.go +++ b/platform-api/internal/repository/interfaces.go @@ -128,8 +128,15 @@ 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) + // Deployment artifact methods (immutable deployments) CreateWithLimitEnforcement(deployment *model.Deployment, hardLimit int) error // Atomic: count, cleanup if needed, create + // Atomic, and restores the build if it was pruned between being resolved and recorded + CreateFromBuildWithLimitEnforcement(deployment *model.Deployment, build *model.Build, 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..650504b602 --- /dev/null +++ b/platform-api/internal/service/build_test.go @@ -0,0 +1,471 @@ +/* + * 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" + "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 base id resolves to as a DEPLOYMENT; nil means the + // id is not a deployment, which is what sends the lookup on to builds. + baseDeployment *model.Deployment + created *model.Deployment +} + +func (m *buildTestDeploymentRepo) CreateBuildWithLimitEnforcement(build *model.Build, hardLimit int) error { + if build.BuildID == "" { + build.BuildID = buildTestBuildID + } + m.createdBuild = build + m.createdWithCap = hardLimit + return nil +} + +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) { + return m.baseDeployment, nil +} + +func (m *buildTestDeploymentRepo) CreateFromBuildWithLimitEnforcement(deployment *model.Deployment, + _ *model.Build, hardLimit int) error { + 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) + } +} + +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 is not conditional on builds: an API can still be shipped straight +// from its definition, and that deployment simply has no build behind it. It must +// come out whole — content, and no build reference to a snapshot that never +// existed — without the builds table being consulted at all. +func TestDeployAPI_FromTheDefinitionHasNoBuildReference(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.created.BuildUUID != nil { + t.Errorf("buildUuid = %q, want none for a deployment rendered from the definition", + *depRepo.created.BuildUUID) + } + if depRepo.created.BuildID != nil { + t.Errorf("buildId = %q, want none for a deployment rendered from the definition", + *depRepo.created.BuildID) + } + if depRepo.getBuildCalls != 0 { + t.Errorf("builds were read %d time(s); deploying from the definition must not need them", + depRepo.getBuildCalls) + } +} + +// 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 re-rendering the + // definition, even though base still says "current". + 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 any other base would be silently ignored, +// so the request is refused rather than quietly deploying something else. +func TestDeployAPI_BuildIdIsRejectedWithAnotherBase(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) + } +} + +// A base that is neither a deployment nor a build must be rejected rather than +// silently falling back to rendering the current definition. +func TestDeployAPI_UnknownBaseIsRejected(t *testing.T) { + service := newBuildTestService(&buildTestAPIRepo{apiModel: buildTestAPI()}, &buildTestDeploymentRepo{}) + + _, err := service.DeployAPI(buildTestAPIUUID, &api.DeployRequest{ + Name: "orders-dev", + Base: "99999999-9999-9999-9999-999999999999", + GatewayId: "test-gateway", + }, buildTestOrgUUID, "tester") + if err == nil || !apperror.DeploymentBaseNotFound.Is(err) { + t.Fatalf("expected DeploymentBaseNotFound, got %v", err) + } +} + +// Promoting a deployment is bound to that deployment, not to a build: it reuses the +// artifact already rendered there and never reads the builds table. Naming a build +// is its own base, and that is the path that records one — so a promotion records +// the deployment it came from and no build reference, even when the base has one. +func TestDeployAPI_PromotionRecordsNoBuildReference(t *testing.T) { + const snapshot = "apiVersion: gateway.wso2.com/v1\nkind: RestApi\nmetadata:\n name: orders-api\nspec:\n context: /orders\n" + depRepo := &buildTestDeploymentRepo{ + baseDeployment: &model.Deployment{ + DeploymentID: "33333333-3333-3333-3333-3333333333aa", + ArtifactID: buildTestAPIUUID, + GatewayID: buildTestGatewayUUID, + Content: []byte(snapshot), + BuildUUID: ptr(buildTestBuildUUID), + BuildID: ptr(buildTestBuildID), + }, + } + 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 { + t.Fatalf("DeployAPI: %v", err) + } + if depRepo.created.BuildUUID != nil { + t.Errorf("buildUuid = %q, want none: a promotion is bound to the deployment, not a build", + *depRepo.created.BuildUUID) + } + if depRepo.created.BuildID != nil { + t.Errorf("buildId = %q, want none", *depRepo.created.BuildID) + } + if depRepo.created.BaseDeploymentID == nil { + t.Error("a promotion should record the deployment it came from") + } + if depRepo.getBuildCalls != 0 { + t.Errorf("builds were read %d time(s); promoting a deployment must not need them", + depRepo.getBuildCalls) + } +} diff --git a/platform-api/internal/service/deployment.go b/platform-api/internal/service/deployment.go index acd50a3d62..1231cfea40 100644 --- a/platform-api/internal/service/deployment.go +++ b/platform-api/internal/service/deployment.go @@ -38,6 +38,13 @@ import ( "gopkg.in/yaml.v3" ) +// The two sources base can name outright. Anything else it carries is a +// deploymentId, which is why neither of these can be one. +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 +92,121 @@ func NewDeploymentService( } } +// 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 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 + } + + apiDeployment, err := s.apiUtil.BuildAPIDeploymentYAML(apiModel) + if err != nil { + return nil, fmt.Errorf("failed to build API deployment YAML: %w", err) + } + contentBytes, err := yaml.Marshal(apiDeployment) + if err != nil { + return nil, fmt.Errorf("failed to marshal API deployment YAML: %w", err) + } + + build := &model.Build{ + ArtifactID: apiUUID, + OrganizationID: orgUUID, + Content: contentBytes, + DataVersion: apiModel.DataVersion, + Metadata: metadata, + CreatedBy: createdBy, + } + if err := s.deploymentRepo.CreateBuildWithLimitEnforcement(build, s.cfg.Deployments.MaxBuildsPerAPI); err != nil { + return nil, err + } + s.slogger.Debug("Build created", "buildID", build.BuildID, "apiUUID", apiUUID) + return toAPIBuildResponse(build), 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 +} + +// 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), + 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 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', 'build', or a deploymentId).") + } + // base says which kind of source this is, so buildId is expected with one kind + // and meaningless with the others. 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 != deployBaseBuild && requestedBuild != "" { + return nil, apperror.RESTAPIDeploymentValidationFailed.New("A buildId applies only when base is 'build'.") } gatewayHandle := strings.TrimSpace(req.GatewayId) if gatewayHandle == "" { @@ -133,19 +247,39 @@ func (s *DeploymentService) DeployAPI(apiUUID string, req *api.DeployRequest, or 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 + var baseBuild *model.Build + // The build this deployment comes from, when it comes from one; nil when the + // artifact is rendered straight from the API definition. + var buildUUID *string + var buildReadableID *string + + // base names the kind of source, so each resolves in exactly one way: no id is + // looked up twice to work out what it was meant to be. + switch base { + case deployBaseBuild: var err error - baseDeployment, err = s.deploymentRepo.GetWithContent(req.Base, apiUUID, orgUUID) + baseBuild, err = s.deploymentRepo.GetBuild(requestedBuild, apiUUID, orgUUID) if err != nil { - if apperror.DeploymentNotFound.Is(err) { - return nil, apperror.DeploymentBaseNotFound.Wrap(err) - } + return nil, fmt.Errorf("failed to get build: %w", err) + } + if baseBuild == nil { + return nil, apperror.BuildNotFound.New() + } + case deployBaseCurrent: + // Rendered from the API's definition below; nothing to resolve. + default: + // Promoting a deployment reuses that deployment's own rendered artifact, so it + // is bound to the deployment and not to any build. Naming a build is its own + // base, and that is the path that records one. + var err error + baseDeployment, err = s.deploymentRepo.GetWithContent(base, apiUUID, orgUUID) + if err != nil && !apperror.DeploymentNotFound.Is(err) { return nil, fmt.Errorf("failed to get base deployment: %w", err) } - baseDeploymentID = &req.Base + if baseDeployment == nil { + return nil, apperror.DeploymentBaseNotFound.New() + } + baseDeploymentID = &base } // Generate deployment ID @@ -166,8 +300,10 @@ func (s *DeploymentService) DeployAPI(apiUUID string, req *api.DeployRequest, or var vhostMain *string var vhostSandbox *string - if req.Base == "current" { - // Fresh deployment: default to sentinel so the gateway resolves and persists its defaults. + if baseDeployment == nil { + // Rendering from the API's definition or from a build: neither carries a + // vhost, so default to the sentinel and let the gateway resolve and persist + // its own. mainSentinel := constants.VhostGatewayDefault vhostMain = &mainSentinel if apiModel.Configuration.Upstream.Sandbox != nil { @@ -175,8 +311,8 @@ func (s *DeploymentService) DeployAPI(apiUUID string, req *api.DeployRequest, or vhostSandbox = &sandboxSentinel } } else { - // Base deployment: start from the base's stored vhosts. - if baseDeployment != nil && baseDeployment.Metadata != nil { + // Promotion: start from the base deployment's stored vhosts. + if baseDeployment.Metadata != nil { if m, ok := baseDeployment.Metadata[constants.MetadataKeyVhostMain]; ok { if ms, ok := m.(string); ok && ms != "" { val := ms @@ -242,14 +378,33 @@ func (s *DeploymentService) DeployAPI(apiUUID string, req *api.DeployRequest, or } // 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) + if baseDeployment == nil { + // Rendering a fresh artifact: either from the API's current definition, or + // from a build, which is that same rendering captured earlier. Both are + // stored at a platform data version and translated to the target gateway's + // version here, so the two paths differ only in where the artifact and its + // source version come from. + var apiDeployment *dto.APIDeploymentYAML + var sourceDataVersion gatewaytranslator.PlatformDataVersion + if baseBuild != nil { + 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 + } else { + var err error + apiDeployment, err = s.apiUtil.BuildAPIDeploymentYAML(apiModel) + if err != nil { + return nil, fmt.Errorf("failed to build API deployment YAML: %w", err) + } + sourceDataVersion = gatewaytranslator.PlatformDataVersion(apiModel.DataVersion) } 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) @@ -268,14 +423,24 @@ func (s *DeploymentService) DeployAPI(apiUUID string, req *api.DeployRequest, or s.slogger.Debug("Vhost sandbox overridden", "vhostSandbox", *vhostSandbox, "deploymentID", deploymentID) } } else { - // Start from base deployment bytes - contentBytes = baseDeployment.Content + // Promote from an existing deployment: start from that deployment's already + // rendered artifact and NEVER re-read the base API definition. Re-translate it + // to the target gateway's data version so promoting across gateways on + // different versions still yields a valid artifact — the source data version + // is computed from the base artifact's own apiVersion, and only the artifact + // Kind (an immutable classifier, unchanged by any edit to the API) is read from + // the API record, never its definition. + var apiDeployment dto.APIDeploymentYAML + if err := yaml.Unmarshal(baseDeployment.Content, &apiDeployment); err != nil { + return nil, fmt.Errorf("failed to parse base deployment YAML: %w", err) + } + sourceDataVersion := gatewaytranslator.ComputeDataVersion(apiModel.Kind, apiDeployment.ApiVersion) + targetDataVersion := gatewaytranslator.GatewayDataVersionForGateway(gateway.Version) + if err := gatewaytranslator.Translate(apiModel.Kind, sourceDataVersion, targetDataVersion, &apiDeployment); err != nil { + return nil, fmt.Errorf("failed to transform base deployment for gateway %s: %w", gateway.Version, err) + } 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) - } + applyBaseStructOverrides(&apiDeployment, endpointURL, vhostMain, vhostSandbox, vhostMainOverridden, vhostSandboxOverridden) if endpointURL != nil { s.slogger.Debug("Endpoint URL overridden", "endpointURL", *endpointURL, "deploymentID", deploymentID) } @@ -286,8 +451,11 @@ func (s *DeploymentService) DeployAPI(apiUUID string, req *api.DeployRequest, or s.slogger.Debug("Vhost sandbox overridden", "vhostSandbox", *vhostSandbox, "deploymentID", deploymentID) } } + contentBytes, err = yaml.Marshal(&apiDeployment) + if err != nil { + return nil, fmt.Errorf("failed to marshal promoted deployment YAML: %w", err) + } } - // If base: and no overrides, contentBytes passes through unchanged. // Store vhost in metadata so it is returned in the deployment response. if vhostMain != nil { @@ -306,6 +474,8 @@ func (s *DeploymentService) DeployAPI(apiUUID string, req *api.DeployRequest, or OrganizationID: orgUUID, GatewayID: gatewayID, BaseDeploymentID: baseDeploymentID, + BuildUUID: buildUUID, + BuildID: buildReadableID, Content: contentBytes, Metadata: metadata, CreatedBy: createdBy, @@ -316,7 +486,9 @@ func (s *DeploymentService) DeployAPI(apiUUID string, req *api.DeployRequest, or 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 { + // baseBuild is nil unless this deploy named a build; passing it lets the write + // put the build back if it was pruned while the artifact was being rendered. + if err := s.deploymentRepo.CreateFromBuildWithLimitEnforcement(deployment, baseBuild, hardLimit); err != nil { return nil, fmt.Errorf("failed to create deployment: %w", err) } @@ -354,7 +526,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 +538,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 +618,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 +630,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 +703,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 +715,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 @@ -733,21 +920,6 @@ func applyBaseStructOverrides(d *dto.APIDeploymentYAML, endpointURL *string, vho } } -// 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 +972,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 +1002,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 +1014,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 +1068,35 @@ 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 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, 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) +} + // 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..2934d56b94 100644 --- a/platform-api/internal/service/deployment_test.go +++ b/platform-api/internal/service/deployment_test.go @@ -235,6 +235,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 +339,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 +1354,7 @@ func strPtr(s string) *string { var testConfig = config.Server{ Deployments: config.Deployments{ MaxPerAPIGateway: 20, + MaxBuildsPerAPI: 50, }, } @@ -1930,6 +1941,20 @@ func TestApplyStructOverrides(t *testing.T) { }) } +// applyBaseOverridesYAML round-trips deployment YAML bytes through the base-flow +// override applier (unmarshal -> applyBaseStructOverrides -> marshal), the same way +// the promote path does. It lets the table below assert override behaviour on YAML. +func applyBaseOverridesYAML(content []byte, endpointURL, vhostMain, vhostSandbox *string, vhostMainOverridden, vhostSandboxOverridden bool) ([]byte, error) { + var d dto.APIDeploymentYAML + if err := yaml.Unmarshal(content, &d); err != nil { + return nil, err + } + applyBaseStructOverrides(&d, endpointURL, vhostMain, vhostSandbox, vhostMainOverridden, vhostSandboxOverridden) + return yaml.Marshal(&d) +} + +// TestApplyDeploymentOverrides covers the base-flow override applier: endpoint and +// selective vhost overrides, preserving the fields that were not overridden. func TestApplyDeploymentOverrides(t *testing.T) { baseYAML := `apiVersion: gateway.api-platform.wso2.com/v1 kind: RestApi @@ -1949,7 +1974,7 @@ spec: 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) + result, err := applyBaseOverridesYAML([]byte(baseYAML), &eu, nil, nil, false, false) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1973,7 +1998,7 @@ spec: 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) + result, err := applyBaseOverridesYAML([]byte(baseYAML), nil, &main, nil, true, false) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1994,7 +2019,7 @@ spec: 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) + result, err := applyBaseOverridesYAML([]byte(baseYAML), nil, nil, &sandbox, false, true) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -2017,7 +2042,7 @@ spec: eu := "https://new.example.com/api" main := "api.example.com" sandbox := "sandbox.example.com" - result, err := applyDeploymentOverrides([]byte(baseYAML), &eu, &main, &sandbox, true, true) + result, err := applyBaseOverridesYAML([]byte(baseYAML), &eu, &main, &sandbox, true, true) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -2037,7 +2062,7 @@ spec: }) t.Run("neither override is no-op", func(t *testing.T) { - result, err := applyDeploymentOverrides([]byte(baseYAML), nil, nil, nil, false, false) + result, err := applyBaseOverridesYAML([]byte(baseYAML), nil, nil, nil, false, false) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -2058,7 +2083,7 @@ spec: 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) + _, err := applyBaseOverridesYAML([]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..3003d927e4 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,44 @@ 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 is built from a base — "current", a buildId, or a prior +// deploymentId — and an optional generic override document. That lets a caller +// prepare a snapshot and deploy it (so a deploy cannot silently pick up edits made +// since), promote an existing deployment forward, and customize any field of the +// API config for the target gateway. +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). + // Metadata is stored with the build and returned with it, uninterpreted. + CreateBuildByHandle(apiHandle, orgID, actor 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) + + // DeployAPIByHandle creates a new immutable deployment of an API onto one + // gateway (Create/Promote). + 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) +} diff --git a/platform-api/resources/openapi.yaml b/platform-api/resources/openapi.yaml index 150a659271..20df099eb7 100644 --- a/platform-api/resources/openapi.yaml +++ b/platform-api/resources/openapi.yaml @@ -744,6 +744,125 @@ 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. Access is validated against + the organization in the JWT token. + operationId: CreateBuild + security: + - OAuth2Security: + - ap:rest_api:deployment:create + - ap:rest_api:deployment: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 + 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' + '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:deployment:read + - ap:rest_api:deployment: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:deployment:read + - ap:rest_api:deployment: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' + /rest-apis/{restApiId}/deployments: post: summary: Create and deploy a new deployment @@ -6661,8 +6780,20 @@ 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 latest working copy now. + - `build` — deploy a prepared build, named by `buildId`. + - a `deploymentId` — promote that deployment, 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-]+$' @@ -6675,6 +6806,68 @@ 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: + 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, used as a deployment's `base`. 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 + 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: @@ -6721,6 +6914,17 @@ components: format: uuid nullable: true description: UUID of the base deployment this was created from + buildId: + type: string + nullable: true + description: | + Build this deployment was made from, such as `2026-01-31-2`. Null unless the + deploy named a build: a deployment rendered from the API definition has none, + and so does one promoted from another deployment, which reuses that + deployment's rendered artifact rather than a build. Also null once the build + it came from has been pruned. Null means only that no build can be named — + the deployment is still promotable by `deploymentId`. + example: "2026-01-31-2" metadata: type: object additionalProperties: true