From 9ec46ab88db53fcfcf624f0152f569a6706ef529 Mon Sep 17 00:00:00 2001 From: dakshina99 Date: Thu, 27 Aug 2026 23:01:57 +0530 Subject: [PATCH] Add a generic deployment override and a Deployments pdk capability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the deploy path so a caller can customize any field of an API's config for a single deployment, promote an existing deployment across gateways, and expose deployment deploy/read/undeploy to plugins. - DeployRequest gains an optional `overrides` document that is deep-merged onto the resolved deployment definition before it is sent to the gateway, and is persisted with the deployment (so it can be read back and carried forward when the deployment is used as a promotion base). The existing endpointUrl/vhost metadata overrides are unchanged. An override that targets an immutable identity field (apiVersion, kind, metadata.name, spec.context, spec.version, spec.operations, spec.channels) is rejected, so a customization can never repoint or redefine the API — only customize a deployment of it. - Promotion (base = an existing deployment) re-translates the base deployment's rendered artifact to the target gateway's data version, so promoting across gateways on different versions yields a valid artifact. The base API definition is never re-read: the source data version is computed from the base artifact's own apiVersion, and only the immutable artifact Kind is taken from the API record. - pdk.Deps gains a Deployments capability (DeployAPIByHandle, GetDeploymentsByHandle, GetDeploymentByHandle, UndeployDeploymentByHandle), satisfied verbatim by DeploymentService. Co-Authored-By: Claude Opus 4.8 --- platform-api/api/generated.go | 3 + platform-api/internal/constants/constants.go | 4 + platform-api/internal/server/server.go | 9 +- platform-api/internal/service/deployment.go | 148 +++++++++++++-- .../internal/service/deployment_test.go | 170 +++++++++++++++++- platform-api/pdk/deps.go | 30 +++- platform-api/resources/openapi.yaml | 4 + 7 files changed, 338 insertions(+), 30 deletions(-) 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