diff --git a/docs/guides/environment-overrides.md b/docs/guides/environment-overrides.md
index 2dcfcc04..4c747b7a 100644
--- a/docs/guides/environment-overrides.md
+++ b/docs/guides/environment-overrides.md
@@ -416,6 +416,8 @@ workspaces:
The supported workspace child sections are: `apis`, `backends`, `diagnostics`, `groups`, `loggers`, `namedValues`, `policyFragments`, `products`, `subscriptions`, `tags`, and `versionSets`.
+Workspace-scoped resources are resolved only against the sections nested under their workspace entry (including nested API sub-resources such as `diagnostics`, `operations`, `policies`, and `releases`). Top-level sections such as `apis` or `backends` apply only to service-level resources, never to resources inside a workspace.
+
## Override rules
### Names must be consistent
@@ -581,4 +583,3 @@ apiops publish --overrides configuration.prod.yaml --dry-run \
- [Authentication Guide](authentication.md)
- [Scenarios and Workflows](scenarios-and-workflows.md)
- [GitHub Actions Integration](../ci-cd/github-actions.md)
-
diff --git a/src/services/override-merger.ts b/src/services/override-merger.ts
index 216a27e7..d723a3aa 100644
--- a/src/services/override-merger.ts
+++ b/src/services/override-merger.ts
@@ -83,6 +83,8 @@ export function hasNamedValueOverride(
* Apply environment overrides from OverrideConfig to a resource JSON payload.
* Deep-merges matching override properties using case-insensitive key matching.
* Supports both direct overrides and nested sub-resource overrides.
+ * Workspace-scoped resources (descriptor.workspace set) are resolved against the
+ * child sections nested under the matching `workspaces` entry.
* Returns a new object (does not mutate input).
*/
export function applyOverrides(
diff --git a/tests/unit/services/override-merger.test.ts b/tests/unit/services/override-merger.test.ts
index a58d503a..8eea4c81 100644
--- a/tests/unit/services/override-merger.test.ts
+++ b/tests/unit/services/override-merger.test.ts
@@ -5,7 +5,7 @@
*/
import { describe, it, expect } from 'vitest';
-import { applyOverrides } from '../../../src/services/override-merger.js';
+import { applyOverrides, hasNamedValueOverride } from '../../../src/services/override-merger.js';
import { ResourceType } from '../../../src/models/resource-types.js';
import { ResourceDescriptor } from '../../../src/models/types.js';
import { OverrideConfig } from '../../../src/models/config.js';
@@ -655,59 +655,46 @@ describe('override-merger', () => {
expect(result.properties).toHaveProperty('isCurrent', true);
});
});
-
describe('workspace-scoped overrides', () => {
- it('should apply a direct override for a workspace-scoped API', () => {
- const descriptor: ResourceDescriptor = {
- type: ResourceType.Api,
- nameParts: ['my-api'],
- workspace: 'ws1',
- };
- const json = { name: 'my-api', properties: { serviceUrl: 'https://dev.example.com' } };
- const overrideConfig: OverrideConfig = {
- workspaces: {
- ws1: {
- properties: {},
- children: {
- apis: {
- 'my-api': {
- properties: { serviceUrl: 'https://prod.example.com' },
+ const overrideConfig: OverrideConfig = {
+ apis: {
+ 'my-api': { properties: { serviceUrl: 'https://service-level.example.com' } },
+ },
+ workspaces: {
+ 'my-workspace': {
+ properties: { description: 'Production workspace' },
+ children: {
+ apis: {
+ 'my-api': {
+ properties: { serviceUrl: 'https://prod.example.com' },
+ children: {
+ diagnostics: { applicationinsights: { properties: { verbosity: 'error' } } },
+ policies: { policy: { properties: { format: 'xml' } } },
+ operations: {
+ 'get-op': {
+ properties: { displayName: 'Prod Op' },
+ children: { policies: { policy: { properties: { format: 'xml' } } } },
+ },
+ },
},
},
},
- },
- },
- };
-
- const result = applyOverrides(descriptor, json, overrideConfig);
- expect(result.properties).toHaveProperty('serviceUrl', 'https://prod.example.com');
- });
-
- it('should apply a direct override for a workspace-scoped backend', () => {
- const descriptor: ResourceDescriptor = {
- type: ResourceType.Backend,
- nameParts: ['orders-backend'],
- workspace: 'partner-workspace',
- };
- const json = { name: 'orders-backend', properties: { url: 'https://orders-dev.example.com' } };
- const overrideConfig: OverrideConfig = {
- workspaces: {
- 'partner-workspace': {
- properties: {},
- children: {
- backends: {
- 'orders-backend': {
- properties: { url: 'https://orders-prod.example.com' },
- },
+ backends: {
+ 'my-backend': { properties: { url: 'https://prod-backend.example.com' } },
+ },
+ namedValues: {
+ 'My-NV': { properties: { value: 'prod-value' } },
+ },
+ products: {
+ 'my-product': {
+ properties: { state: 'published' },
+ children: { policies: { policy: { properties: { format: 'xml' } } } },
},
},
},
},
- };
-
- const result = applyOverrides(descriptor, json, overrideConfig);
- expect(result.properties).toHaveProperty('url', 'https://orders-prod.example.com');
- });
+ },
+ };
it('should use the override from the matching workspace when multiple workspaces define the same resource name', () => {
const descriptor: ResourceDescriptor = {
@@ -716,15 +703,13 @@ describe('override-merger', () => {
workspace: 'ws2',
};
const json = { name: 'my-api', properties: { serviceUrl: 'https://dev.example.com' } };
- const overrideConfig: OverrideConfig = {
+ const overrides: OverrideConfig = {
workspaces: {
ws1: {
properties: {},
children: {
apis: {
- 'my-api': {
- properties: { serviceUrl: 'https://ws1.example.com' },
- },
+ 'my-api': { properties: { serviceUrl: 'https://ws1.example.com' } },
},
},
},
@@ -732,152 +717,110 @@ describe('override-merger', () => {
properties: {},
children: {
apis: {
- 'my-api': {
- properties: { serviceUrl: 'https://ws2.example.com' },
- },
+ 'my-api': { properties: { serviceUrl: 'https://ws2.example.com' } },
},
},
},
},
};
- const result = applyOverrides(descriptor, json, overrideConfig);
+ const result = applyOverrides(descriptor, json, overrides);
expect(result.properties).toHaveProperty('serviceUrl', 'https://ws2.example.com');
});
- it('should apply a nested override for a workspace-scoped API diagnostic', () => {
- const descriptor: ResourceDescriptor = {
- type: ResourceType.ApiDiagnostic,
- nameParts: ['my-api', 'applicationinsights'],
- workspace: 'ws1',
- };
- const json = { name: 'applicationinsights', properties: { loggerId: '/old-logger' } };
- const overrideConfig: OverrideConfig = {
- workspaces: {
- ws1: {
- properties: {},
- children: {
- apis: {
- 'my-api': {
- properties: {},
- children: {
- diagnostics: {
- applicationinsights: {
- properties: { loggerId: '/new-logger' },
- },
- },
- },
- },
- },
- },
- },
- },
- };
+ it('should apply override to the workspace container itself', () => {
+ const result = applyOverrides(
+ { type: ResourceType.Workspace, nameParts: ['my-workspace'] },
+ { properties: { description: 'Dev workspace' } },
+ overrideConfig
+ );
+ expect(result.properties).toHaveProperty('description', 'Production workspace');
+ });
- const result = applyOverrides(descriptor, json, overrideConfig);
- expect(result.properties).toHaveProperty('loggerId', '/new-logger');
+ it('should apply nested override to a workspace API (case-insensitive workspace name)', () => {
+ const result = applyOverrides(
+ { type: ResourceType.Api, nameParts: ['my-api'], workspace: 'MY-WORKSPACE' },
+ { properties: { serviceUrl: 'https://dev.example.com', path: 'my' } },
+ overrideConfig
+ );
+ expect(result.properties).toEqual({ serviceUrl: 'https://prod.example.com', path: 'my' });
});
- it('should apply a grandchild override for a workspace-scoped API operation policy', () => {
- const descriptor: ResourceDescriptor = {
- type: ResourceType.ApiOperationPolicy,
- nameParts: ['my-api', 'get-pets'],
- workspace: 'ws1',
- };
- const json = { name: 'policy', properties: { format: 'xml' } };
- const overrideConfig: OverrideConfig = {
- workspaces: {
- ws1: {
- properties: {},
- children: {
- apis: {
- 'my-api': {
- properties: {},
- children: {
- operations: {
- 'get-pets': {
- properties: {},
- children: {
- policies: {
- policy: { properties: { format: 'rawxml' } },
- },
- },
- },
- },
- },
- },
- },
- },
- },
- },
- };
+ it('should apply nested override to a workspace backend and named value', () => {
+ const backend = applyOverrides(
+ { type: ResourceType.Backend, nameParts: ['my-backend'], workspace: 'my-workspace' },
+ { properties: { url: 'https://dev-backend.example.com' } },
+ overrideConfig
+ );
+ expect(backend.properties).toHaveProperty('url', 'https://prod-backend.example.com');
- const result = applyOverrides(descriptor, json, overrideConfig);
- expect(result.properties).toHaveProperty('format', 'rawxml');
+ const nv = applyOverrides(
+ { type: ResourceType.NamedValue, nameParts: ['my-nv'], workspace: 'my-workspace' },
+ { properties: { value: 'dev-value' } },
+ overrideConfig
+ );
+ expect(nv.properties).toHaveProperty('value', 'prod-value');
});
- it('should not leak a top-level override into a workspace-scoped resource', () => {
- const descriptor: ResourceDescriptor = {
- type: ResourceType.Api,
- nameParts: ['my-api'],
- workspace: 'ws1',
- };
- const json = { name: 'my-api', properties: { serviceUrl: 'https://dev.example.com' } };
- // Same resource name is overridden at the top level, but no workspace-scoped override exists.
- const overrideConfig: OverrideConfig = {
- apis: {
- 'my-api': {
- properties: { serviceUrl: 'https://should-not-apply.example.com' },
- },
- },
- };
+ it('should apply overrides to workspace API sub-resources', () => {
+ const diag = applyOverrides(
+ { type: ResourceType.ApiDiagnostic, nameParts: ['my-api', 'applicationinsights'], workspace: 'my-workspace' },
+ { properties: { verbosity: 'information' } },
+ overrideConfig
+ );
+ expect(diag.properties).toHaveProperty('verbosity', 'error');
- const result = applyOverrides(descriptor, json, overrideConfig);
- expect(result).toEqual(json);
- });
+ const apiPolicy = applyOverrides(
+ { type: ResourceType.ApiPolicy, nameParts: ['my-api'], workspace: 'my-workspace' },
+ { properties: { format: 'rawxml', value: '' } },
+ overrideConfig
+ );
+ expect(apiPolicy.properties).toEqual({ format: 'xml', value: '' });
- it('should still apply workspace container property overrides (no workspace field on descriptor)', () => {
- const descriptor: ResourceDescriptor = {
- type: ResourceType.Workspace,
- nameParts: ['ws1'],
- };
- const json = { name: 'ws1', properties: { displayName: 'Dev Workspace' } };
- const overrideConfig: OverrideConfig = {
- workspaces: {
- ws1: {
- properties: { displayName: 'Prod Workspace' },
- },
- },
- };
+ const op = applyOverrides(
+ { type: ResourceType.ApiOperation, nameParts: ['my-api', 'get-op'], workspace: 'my-workspace' },
+ { properties: { displayName: 'Dev Op' } },
+ overrideConfig
+ );
+ expect(op.properties).toHaveProperty('displayName', 'Prod Op');
- const result = applyOverrides(descriptor, json, overrideConfig);
- expect(result.properties).toHaveProperty('displayName', 'Prod Workspace');
+ const opPolicy = applyOverrides(
+ { type: ResourceType.ApiOperationPolicy, nameParts: ['my-api', 'get-op'], workspace: 'my-workspace' },
+ { properties: { format: 'rawxml' } },
+ overrideConfig
+ );
+ expect(opPolicy.properties).toHaveProperty('format', 'xml');
+
+ const productPolicy = applyOverrides(
+ { type: ResourceType.ProductPolicy, nameParts: ['my-product'], workspace: 'my-workspace' },
+ { properties: { format: 'rawxml' } },
+ overrideConfig
+ );
+ expect(productPolicy.properties).toHaveProperty('format', 'xml');
});
- it('should match workspace names case-insensitively', () => {
- const descriptor: ResourceDescriptor = {
- type: ResourceType.Api,
- nameParts: ['my-api'],
- workspace: 'ws1',
- };
- const json = { name: 'my-api', properties: { serviceUrl: 'https://dev.example.com' } };
- const overrideConfig: OverrideConfig = {
- workspaces: {
- WS1: {
- properties: {},
- children: {
- apis: {
- 'my-api': {
- properties: { serviceUrl: 'https://prod.example.com' },
- },
- },
- },
- },
- },
- };
+ it('should not apply service-level overrides to workspace-scoped resources', () => {
+ const result = applyOverrides(
+ { type: ResourceType.Api, nameParts: ['my-api'], workspace: 'other-workspace' },
+ { properties: { serviceUrl: 'https://dev.example.com' } },
+ overrideConfig
+ );
+ expect(result.properties).toHaveProperty('serviceUrl', 'https://dev.example.com');
+ });
- const result = applyOverrides(descriptor, json, overrideConfig);
- expect(result.properties).toHaveProperty('serviceUrl', 'https://prod.example.com');
+ it('should not apply workspace overrides to service-level resources', () => {
+ const result = applyOverrides(
+ { type: ResourceType.Backend, nameParts: ['my-backend'] },
+ { properties: { url: 'https://dev-backend.example.com' } },
+ overrideConfig
+ );
+ expect(result.properties).toHaveProperty('url', 'https://dev-backend.example.com');
+ });
+
+ it('should detect named value overrides within a workspace scope', () => {
+ expect(hasNamedValueOverride('my-nv', overrideConfig, 'my-workspace')).toBe(true);
+ expect(hasNamedValueOverride('my-nv', overrideConfig)).toBe(false);
+ expect(hasNamedValueOverride('my-nv', overrideConfig, 'other-workspace')).toBe(false);
});
});
});