diff --git a/platform-api/api/generated.go b/platform-api/api/generated.go index e5050eb044..6dd0ffed9d 100644 --- a/platform-api/api/generated.go +++ b/platform-api/api/generated.go @@ -1031,6 +1031,9 @@ type DeployRequest struct { // Name Name/label for this deployment (e.g., "v1.0-prod", "hotfix-2024-01-15") Name string `binding:"required" json:"name" yaml:"name"` + + // Overrides Optional structured override document deep-merged onto the deployment definition before it is sent to the gateway, letting a caller customize any field of the API config for this deployment. The applied overrides are persisted with the deployment. + Overrides *map[string]interface{} `json:"overrides,omitempty" yaml:"overrides,omitempty"` } // DeploymentListResponse defines model for DeploymentListResponse. diff --git a/platform-api/internal/constants/constants.go b/platform-api/internal/constants/constants.go index 00330f75ae..87c34c4beb 100644 --- a/platform-api/internal/constants/constants.go +++ b/platform-api/internal/constants/constants.go @@ -251,6 +251,10 @@ const ( MetadataKeyVhostMain = "vhostMain" // MetadataKeyVhostSandbox is the metadata key for the per-deployment sandbox vhost value. MetadataKeyVhostSandbox = "vhostSandbox" + // MetadataKeyOverrides is the metadata key under which the applied generic + // override document is persisted, so it can be read back (e.g. to prefill a + // re-deployment from the same environment). + MetadataKeyOverrides = "overrides" // VhostGatewayDefault is the sentinel value that instructs the gateway-controller to resolve // and persist the current gateway default vhosts, ensuring deployments are immune to future // gateway config changes. diff --git a/platform-api/internal/server/server.go b/platform-api/internal/server/server.go index 2ddc1a06d2..84b370c094 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/deployment.go b/platform-api/internal/service/deployment.go index acd50a3d62..db9db70e17 100644 --- a/platform-api/internal/service/deployment.go +++ b/platform-api/internal/service/deployment.go @@ -268,14 +268,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 +296,23 @@ 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) + } + } + + // Apply the generic override document (customize any field of the config for + // this deployment) onto the resolved definition, and persist it so it can be + // read back and carried forward when this deployment is later used as a base. + if req.Overrides != nil && len(*req.Overrides) > 0 { + contentBytes, err = mergeGenericOverrides(contentBytes, *req.Overrides) + if err != nil { + return nil, fmt.Errorf("failed to apply deployment overrides: %w", err) + } + metadata[constants.MetadataKeyOverrides] = *req.Overrides + s.slogger.Debug("Generic overrides applied", "deploymentID", deploymentID) } - // If base: and no overrides, contentBytes passes through unchanged. // Store vhost in metadata so it is returned in the deployment response. if vhostMain != nil { @@ -733,19 +758,106 @@ 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) +// protectedOverridePaths are the artifact's immutable identity fields, which an +// override must never change — doing so would repoint or redefine the API rather +// than customize a deployment of it. A customization that targets any of these is +// rejected. +var protectedOverridePaths = [][]string{ + {"apiVersion"}, + {"kind"}, + {"metadata", "name"}, + {"spec", "context"}, + {"spec", "version"}, + {"spec", "operations"}, + {"spec", "channels"}, +} + +// overrideProtectedPath reports the first protected identity path an override +// document sets (present at or below that path), if any. +func overrideProtectedPath(overrides map[string]interface{}) (string, bool) { + for _, path := range protectedOverridePaths { + cur := overrides + reached := true + for i, seg := range path { + v, exists := cur[seg] + if !exists { + reached = false + break + } + if i == len(path)-1 { + break + } + m, isMap := asStringKeyedMap(v) + if !isMap { + reached = false + break + } + cur = m + } + if reached { + return strings.Join(path, "."), true + } + } + return "", false +} + +// mergeGenericOverrides deep-merges a structured override document onto the +// deployment definition YAML, letting a caller customize any field of the API +// config for this deployment without the service needing to know the field. An +// override that targets an immutable identity field is rejected. +func mergeGenericOverrides(contentBytes []byte, overrides map[string]interface{}) ([]byte, error) { + if path, bad := overrideProtectedPath(overrides); bad { + return nil, apperror.RESTAPIDeploymentValidationFailed.New( + fmt.Sprintf("Override targets the immutable field %q, which cannot be customized for a deployment.", path)) } - applyBaseStructOverrides(&apiDeployment, endpointURL, vhostMain, vhostSandbox, vhostMainOverridden, vhostSandboxOverridden) - modifiedBytes, err := yaml.Marshal(&apiDeployment) + var base map[string]interface{} + if err := yaml.Unmarshal(contentBytes, &base); err != nil { + return nil, fmt.Errorf("failed to parse deployment YAML for override: %w", err) + } + if base == nil { + base = map[string]interface{}{} + } + out, err := yaml.Marshal(deepMergeMap(base, overrides)) if err != nil { - return nil, fmt.Errorf("failed to marshal modified deployment YAML: %w", err) + return nil, fmt.Errorf("failed to marshal overridden deployment YAML: %w", err) + } + return out, nil +} + +// deepMergeMap recursively merges src into dst and returns dst. Nested maps are +// merged key-by-key; every other value in src replaces the value in dst. Keys +// absent from src are left untouched. +func deepMergeMap(dst, src map[string]interface{}) map[string]interface{} { + for k, sv := range src { + if svMap, ok := asStringKeyedMap(sv); ok { + if dvMap, ok := asStringKeyedMap(dst[k]); ok { + dst[k] = deepMergeMap(dvMap, svMap) + continue + } + dst[k] = svMap + continue + } + dst[k] = sv + } + return dst +} + +// asStringKeyedMap normalizes the two map shapes YAML/JSON decoding can produce +// (map[string]interface{} and map[interface{}]interface{}) into a string-keyed +// map, reporting whether the value was a map at all. +func asStringKeyedMap(v interface{}) (map[string]interface{}, bool) { + switch m := v.(type) { + case map[string]interface{}: + return m, true + case map[interface{}]interface{}: + out := make(map[string]interface{}, len(m)) + for k, val := range m { + out[fmt.Sprintf("%v", k)] = val + } + return out, true + default: + return nil, false } - return modifiedBytes, nil } // GetDeployments retrieves all deployments for an API with optional filters diff --git a/platform-api/internal/service/deployment_test.go b/platform-api/internal/service/deployment_test.go index 3df5c45381..2a20740e06 100644 --- a/platform-api/internal/service/deployment_test.go +++ b/platform-api/internal/service/deployment_test.go @@ -1930,6 +1930,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 +1963,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 +1987,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 +2008,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 +2031,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 +2051,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,9 +2072,153 @@ 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") } }) } + +// TestMergeGenericOverrides verifies the generic override deep-merge: nested maps +// merge key-by-key, scalars replace, new keys are added, and untouched siblings +// are preserved. This is the "customize any field" primitive. +func TestMergeGenericOverrides(t *testing.T) { + base := []byte(` +apiVersion: gateway.api-platform.wso2.com/v1 +kind: RestApi +spec: + displayName: Orders + context: /orders + upstream: + main: + url: https://dev-backend.example.com + sandbox: + url: https://dev-sandbox.example.com + operations: + - method: GET + path: /items +`) + overrides := map[string]interface{}{ + "spec": map[string]interface{}{ + "upstream": map[string]interface{}{ + "main": map[string]interface{}{ + "url": "https://prod-backend.example.com", + }, + }, + "displayName": "Orders Prod", + }, + } + + out, err := mergeGenericOverrides(base, overrides) + if err != nil { + t.Fatalf("mergeGenericOverrides: %v", err) + } + var got map[string]interface{} + if err := yaml.Unmarshal(out, &got); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + spec, _ := asStringKeyedMap(got["spec"]) + up, _ := asStringKeyedMap(spec["upstream"]) + main, _ := asStringKeyedMap(up["main"]) + sandbox, _ := asStringKeyedMap(up["sandbox"]) + + if main["url"] != "https://prod-backend.example.com" { + t.Errorf("main url = %v, want the overridden prod url", main["url"]) + } + if sandbox["url"] != "https://dev-sandbox.example.com" { + t.Errorf("sandbox url = %v, want the untouched dev url (sibling preserved)", sandbox["url"]) + } + if spec["displayName"] != "Orders Prod" { + t.Errorf("displayName = %v, want the overridden value", spec["displayName"]) + } + if spec["context"] != "/orders" { + t.Errorf("context = %v, want preserved (untouched sibling)", spec["context"]) + } + if _, ok := spec["operations"]; !ok { + t.Errorf("operations dropped; keys absent from the override must be preserved") + } +} + +// TestDeepMergeMap covers scalar replace, nested merge, new-key add, and that a +// map value replaces a non-map base value. +func TestDeepMergeMap(t *testing.T) { + dst := map[string]interface{}{ + "keep": "me", + "scalar": 1, + "nested": map[string]interface{}{"a": 1, "b": 2}, + "leaf": "string", + } + src := map[string]interface{}{ + "scalar": 2, + "nested": map[string]interface{}{"b": 3, "c": 4}, + "leaf": map[string]interface{}{"now": "map"}, + "added": "new", + } + got := deepMergeMap(dst, src) + + if got["keep"] != "me" { + t.Errorf("untouched key lost: %v", got["keep"]) + } + if got["scalar"] != 2 { + t.Errorf("scalar not replaced: %v", got["scalar"]) + } + nested, _ := asStringKeyedMap(got["nested"]) + if nested["a"] != 1 || nested["b"] != 3 || nested["c"] != 4 { + t.Errorf("nested merge wrong: %v", nested) + } + if _, ok := asStringKeyedMap(got["leaf"]); !ok { + t.Errorf("map value should replace a scalar base value") + } + if got["added"] != "new" { + t.Errorf("new key not added: %v", got["added"]) + } +} + +// TestMergeGenericOverrides_ProtectsImmutableFields verifies the override guard: +// a safe field is applied, but an override targeting an immutable identity field +// (directly or nested) is rejected. +func TestMergeGenericOverrides_ProtectsImmutableFields(t *testing.T) { + base := []byte(` +apiVersion: gateway.api-platform.wso2.com/v1 +kind: RestApi +metadata: + name: orders +spec: + context: /orders + version: v1.0.0 + upstream: + main: + url: https://dev.example.com +`) + + // Safe: overriding a non-identity field succeeds. + if _, err := mergeGenericOverrides(base, map[string]interface{}{ + "spec": map[string]interface{}{"upstream": map[string]interface{}{"sandbox": map[string]interface{}{"url": "https://sbx.example.com"}}}, + }); err != nil { + t.Fatalf("safe override should succeed: %v", err) + } + + protected := []struct { + name string + override map[string]interface{} + }{ + {"kind", map[string]interface{}{"kind": "LLMProvider"}}, + {"apiVersion", map[string]interface{}{"apiVersion": "v2"}}, + {"metadata.name", map[string]interface{}{"metadata": map[string]interface{}{"name": "hijacked"}}}, + {"spec.context", map[string]interface{}{"spec": map[string]interface{}{"context": "/other"}}}, + {"spec.version", map[string]interface{}{"spec": map[string]interface{}{"version": "v9"}}}, + {"spec.operations", map[string]interface{}{"spec": map[string]interface{}{"operations": []interface{}{}}}}, + } + for _, tc := range protected { + if _, err := mergeGenericOverrides(base, tc.override); err == nil { + t.Errorf("override of immutable %q should be rejected", tc.name) + } + } + + // A metadata override that leaves name alone (labels only) is allowed. + if _, err := mergeGenericOverrides(base, map[string]interface{}{ + "metadata": map[string]interface{}{"labels": map[string]interface{}{"tier": "gold"}}, + }); err != nil { + t.Fatalf("metadata.labels override should be allowed: %v", err) + } +} diff --git a/platform-api/pdk/deps.go b/platform-api/pdk/deps.go index bfb0a0d8b9..bdd72f4436 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,28 @@ type Projects interface { // DeleteProject removes a project within an organization (Delete). DeleteProject(handle, orgID, actor string) error } + +// Deployments exposes 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" or a prior deploymentId) and an optional generic +// override document, letting a caller promote an existing deployment forward and +// customize any field of the API config for the target gateway. +type Deployments interface { + // 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/overrides (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 46f76544e1..1056fdcedf 100644 --- a/platform-api/resources/openapi.yaml +++ b/platform-api/resources/openapi.yaml @@ -6674,6 +6674,10 @@ components: type: object additionalProperties: true description: Optional metadata for the deployment. Supported keys include `endpointUrl`, `vhostMain`, and `vhostSandbox`. + overrides: + type: object + additionalProperties: true + description: Optional structured override document deep-merged onto the deployment definition before it is sent to the gateway, letting a caller customize any field of the API config for this deployment. The applied overrides are persisted with the deployment. DeploymentResponse: type: object