Skip to content

Commit ab99be6

Browse files
authored
Merge pull request #311 from Azure/copilot/fix-workspace-scoped-overrides
fix: apply workspace-scoped override properties
2 parents e8b704f + 87cebd5 commit ab99be6

3 files changed

Lines changed: 122 additions & 176 deletions

File tree

‎docs/guides/environment-overrides.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,8 @@ workspaces:
416416

417417
The supported workspace child sections are: `apis`, `backends`, `diagnostics`, `groups`, `loggers`, `namedValues`, `policyFragments`, `products`, `subscriptions`, `tags`, and `versionSets`.
418418

419+
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.
420+
419421
## Override rules
420422

421423
### Names must be consistent
@@ -581,4 +583,3 @@ apiops publish --overrides configuration.prod.yaml --dry-run \
581583
- [Authentication Guide](authentication.md)
582584
- [Scenarios and Workflows](scenarios-and-workflows.md)
583585
- [GitHub Actions Integration](../ci-cd/github-actions.md)
584-

‎src/services/override-merger.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ export function hasNamedValueOverride(
8383
* Apply environment overrides from OverrideConfig to a resource JSON payload.
8484
* Deep-merges matching override properties using case-insensitive key matching.
8585
* Supports both direct overrides and nested sub-resource overrides.
86+
* Workspace-scoped resources (descriptor.workspace set) are resolved against the
87+
* child sections nested under the matching `workspaces` entry.
8688
* Returns a new object (does not mutate input).
8789
*/
8890
export function applyOverrides(

‎tests/unit/services/override-merger.test.ts‎

Lines changed: 118 additions & 175 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*/
66

77
import { describe, it, expect } from 'vitest';
8-
import { applyOverrides } from '../../../src/services/override-merger.js';
8+
import { applyOverrides, hasNamedValueOverride } from '../../../src/services/override-merger.js';
99
import { ResourceType } from '../../../src/models/resource-types.js';
1010
import { ResourceDescriptor } from '../../../src/models/types.js';
1111
import { OverrideConfig } from '../../../src/models/config.js';
@@ -655,59 +655,46 @@ describe('override-merger', () => {
655655
expect(result.properties).toHaveProperty('isCurrent', true);
656656
});
657657
});
658-
659658
describe('workspace-scoped overrides', () => {
660-
it('should apply a direct override for a workspace-scoped API', () => {
661-
const descriptor: ResourceDescriptor = {
662-
type: ResourceType.Api,
663-
nameParts: ['my-api'],
664-
workspace: 'ws1',
665-
};
666-
const json = { name: 'my-api', properties: { serviceUrl: 'https://dev.example.com' } };
667-
const overrideConfig: OverrideConfig = {
668-
workspaces: {
669-
ws1: {
670-
properties: {},
671-
children: {
672-
apis: {
673-
'my-api': {
674-
properties: { serviceUrl: 'https://prod.example.com' },
659+
const overrideConfig: OverrideConfig = {
660+
apis: {
661+
'my-api': { properties: { serviceUrl: 'https://service-level.example.com' } },
662+
},
663+
workspaces: {
664+
'my-workspace': {
665+
properties: { description: 'Production workspace' },
666+
children: {
667+
apis: {
668+
'my-api': {
669+
properties: { serviceUrl: 'https://prod.example.com' },
670+
children: {
671+
diagnostics: { applicationinsights: { properties: { verbosity: 'error' } } },
672+
policies: { policy: { properties: { format: 'xml' } } },
673+
operations: {
674+
'get-op': {
675+
properties: { displayName: 'Prod Op' },
676+
children: { policies: { policy: { properties: { format: 'xml' } } } },
677+
},
678+
},
675679
},
676680
},
677681
},
678-
},
679-
},
680-
};
681-
682-
const result = applyOverrides(descriptor, json, overrideConfig);
683-
expect(result.properties).toHaveProperty('serviceUrl', 'https://prod.example.com');
684-
});
685-
686-
it('should apply a direct override for a workspace-scoped backend', () => {
687-
const descriptor: ResourceDescriptor = {
688-
type: ResourceType.Backend,
689-
nameParts: ['orders-backend'],
690-
workspace: 'partner-workspace',
691-
};
692-
const json = { name: 'orders-backend', properties: { url: 'https://orders-dev.example.com' } };
693-
const overrideConfig: OverrideConfig = {
694-
workspaces: {
695-
'partner-workspace': {
696-
properties: {},
697-
children: {
698-
backends: {
699-
'orders-backend': {
700-
properties: { url: 'https://orders-prod.example.com' },
701-
},
682+
backends: {
683+
'my-backend': { properties: { url: 'https://prod-backend.example.com' } },
684+
},
685+
namedValues: {
686+
'My-NV': { properties: { value: 'prod-value' } },
687+
},
688+
products: {
689+
'my-product': {
690+
properties: { state: 'published' },
691+
children: { policies: { policy: { properties: { format: 'xml' } } } },
702692
},
703693
},
704694
},
705695
},
706-
};
707-
708-
const result = applyOverrides(descriptor, json, overrideConfig);
709-
expect(result.properties).toHaveProperty('url', 'https://orders-prod.example.com');
710-
});
696+
},
697+
};
711698

712699
it('should use the override from the matching workspace when multiple workspaces define the same resource name', () => {
713700
const descriptor: ResourceDescriptor = {
@@ -716,168 +703,124 @@ describe('override-merger', () => {
716703
workspace: 'ws2',
717704
};
718705
const json = { name: 'my-api', properties: { serviceUrl: 'https://dev.example.com' } };
719-
const overrideConfig: OverrideConfig = {
706+
const overrides: OverrideConfig = {
720707
workspaces: {
721708
ws1: {
722709
properties: {},
723710
children: {
724711
apis: {
725-
'my-api': {
726-
properties: { serviceUrl: 'https://ws1.example.com' },
727-
},
712+
'my-api': { properties: { serviceUrl: 'https://ws1.example.com' } },
728713
},
729714
},
730715
},
731716
ws2: {
732717
properties: {},
733718
children: {
734719
apis: {
735-
'my-api': {
736-
properties: { serviceUrl: 'https://ws2.example.com' },
737-
},
720+
'my-api': { properties: { serviceUrl: 'https://ws2.example.com' } },
738721
},
739722
},
740723
},
741724
},
742725
};
743726

744-
const result = applyOverrides(descriptor, json, overrideConfig);
727+
const result = applyOverrides(descriptor, json, overrides);
745728
expect(result.properties).toHaveProperty('serviceUrl', 'https://ws2.example.com');
746729
});
747730

748-
it('should apply a nested override for a workspace-scoped API diagnostic', () => {
749-
const descriptor: ResourceDescriptor = {
750-
type: ResourceType.ApiDiagnostic,
751-
nameParts: ['my-api', 'applicationinsights'],
752-
workspace: 'ws1',
753-
};
754-
const json = { name: 'applicationinsights', properties: { loggerId: '/old-logger' } };
755-
const overrideConfig: OverrideConfig = {
756-
workspaces: {
757-
ws1: {
758-
properties: {},
759-
children: {
760-
apis: {
761-
'my-api': {
762-
properties: {},
763-
children: {
764-
diagnostics: {
765-
applicationinsights: {
766-
properties: { loggerId: '/new-logger' },
767-
},
768-
},
769-
},
770-
},
771-
},
772-
},
773-
},
774-
},
775-
};
731+
it('should apply override to the workspace container itself', () => {
732+
const result = applyOverrides(
733+
{ type: ResourceType.Workspace, nameParts: ['my-workspace'] },
734+
{ properties: { description: 'Dev workspace' } },
735+
overrideConfig
736+
);
737+
expect(result.properties).toHaveProperty('description', 'Production workspace');
738+
});
776739

777-
const result = applyOverrides(descriptor, json, overrideConfig);
778-
expect(result.properties).toHaveProperty('loggerId', '/new-logger');
740+
it('should apply nested override to a workspace API (case-insensitive workspace name)', () => {
741+
const result = applyOverrides(
742+
{ type: ResourceType.Api, nameParts: ['my-api'], workspace: 'MY-WORKSPACE' },
743+
{ properties: { serviceUrl: 'https://dev.example.com', path: 'my' } },
744+
overrideConfig
745+
);
746+
expect(result.properties).toEqual({ serviceUrl: 'https://prod.example.com', path: 'my' });
779747
});
780748

781-
it('should apply a grandchild override for a workspace-scoped API operation policy', () => {
782-
const descriptor: ResourceDescriptor = {
783-
type: ResourceType.ApiOperationPolicy,
784-
nameParts: ['my-api', 'get-pets'],
785-
workspace: 'ws1',
786-
};
787-
const json = { name: 'policy', properties: { format: 'xml' } };
788-
const overrideConfig: OverrideConfig = {
789-
workspaces: {
790-
ws1: {
791-
properties: {},
792-
children: {
793-
apis: {
794-
'my-api': {
795-
properties: {},
796-
children: {
797-
operations: {
798-
'get-pets': {
799-
properties: {},
800-
children: {
801-
policies: {
802-
policy: { properties: { format: 'rawxml' } },
803-
},
804-
},
805-
},
806-
},
807-
},
808-
},
809-
},
810-
},
811-
},
812-
},
813-
};
749+
it('should apply nested override to a workspace backend and named value', () => {
750+
const backend = applyOverrides(
751+
{ type: ResourceType.Backend, nameParts: ['my-backend'], workspace: 'my-workspace' },
752+
{ properties: { url: 'https://dev-backend.example.com' } },
753+
overrideConfig
754+
);
755+
expect(backend.properties).toHaveProperty('url', 'https://prod-backend.example.com');
814756

815-
const result = applyOverrides(descriptor, json, overrideConfig);
816-
expect(result.properties).toHaveProperty('format', 'rawxml');
757+
const nv = applyOverrides(
758+
{ type: ResourceType.NamedValue, nameParts: ['my-nv'], workspace: 'my-workspace' },
759+
{ properties: { value: 'dev-value' } },
760+
overrideConfig
761+
);
762+
expect(nv.properties).toHaveProperty('value', 'prod-value');
817763
});
818764

819-
it('should not leak a top-level override into a workspace-scoped resource', () => {
820-
const descriptor: ResourceDescriptor = {
821-
type: ResourceType.Api,
822-
nameParts: ['my-api'],
823-
workspace: 'ws1',
824-
};
825-
const json = { name: 'my-api', properties: { serviceUrl: 'https://dev.example.com' } };
826-
// Same resource name is overridden at the top level, but no workspace-scoped override exists.
827-
const overrideConfig: OverrideConfig = {
828-
apis: {
829-
'my-api': {
830-
properties: { serviceUrl: 'https://should-not-apply.example.com' },
831-
},
832-
},
833-
};
765+
it('should apply overrides to workspace API sub-resources', () => {
766+
const diag = applyOverrides(
767+
{ type: ResourceType.ApiDiagnostic, nameParts: ['my-api', 'applicationinsights'], workspace: 'my-workspace' },
768+
{ properties: { verbosity: 'information' } },
769+
overrideConfig
770+
);
771+
expect(diag.properties).toHaveProperty('verbosity', 'error');
834772

835-
const result = applyOverrides(descriptor, json, overrideConfig);
836-
expect(result).toEqual(json);
837-
});
773+
const apiPolicy = applyOverrides(
774+
{ type: ResourceType.ApiPolicy, nameParts: ['my-api'], workspace: 'my-workspace' },
775+
{ properties: { format: 'rawxml', value: '<policies/>' } },
776+
overrideConfig
777+
);
778+
expect(apiPolicy.properties).toEqual({ format: 'xml', value: '<policies/>' });
838779

839-
it('should still apply workspace container property overrides (no workspace field on descriptor)', () => {
840-
const descriptor: ResourceDescriptor = {
841-
type: ResourceType.Workspace,
842-
nameParts: ['ws1'],
843-
};
844-
const json = { name: 'ws1', properties: { displayName: 'Dev Workspace' } };
845-
const overrideConfig: OverrideConfig = {
846-
workspaces: {
847-
ws1: {
848-
properties: { displayName: 'Prod Workspace' },
849-
},
850-
},
851-
};
780+
const op = applyOverrides(
781+
{ type: ResourceType.ApiOperation, nameParts: ['my-api', 'get-op'], workspace: 'my-workspace' },
782+
{ properties: { displayName: 'Dev Op' } },
783+
overrideConfig
784+
);
785+
expect(op.properties).toHaveProperty('displayName', 'Prod Op');
852786

853-
const result = applyOverrides(descriptor, json, overrideConfig);
854-
expect(result.properties).toHaveProperty('displayName', 'Prod Workspace');
787+
const opPolicy = applyOverrides(
788+
{ type: ResourceType.ApiOperationPolicy, nameParts: ['my-api', 'get-op'], workspace: 'my-workspace' },
789+
{ properties: { format: 'rawxml' } },
790+
overrideConfig
791+
);
792+
expect(opPolicy.properties).toHaveProperty('format', 'xml');
793+
794+
const productPolicy = applyOverrides(
795+
{ type: ResourceType.ProductPolicy, nameParts: ['my-product'], workspace: 'my-workspace' },
796+
{ properties: { format: 'rawxml' } },
797+
overrideConfig
798+
);
799+
expect(productPolicy.properties).toHaveProperty('format', 'xml');
855800
});
856801

857-
it('should match workspace names case-insensitively', () => {
858-
const descriptor: ResourceDescriptor = {
859-
type: ResourceType.Api,
860-
nameParts: ['my-api'],
861-
workspace: 'ws1',
862-
};
863-
const json = { name: 'my-api', properties: { serviceUrl: 'https://dev.example.com' } };
864-
const overrideConfig: OverrideConfig = {
865-
workspaces: {
866-
WS1: {
867-
properties: {},
868-
children: {
869-
apis: {
870-
'my-api': {
871-
properties: { serviceUrl: 'https://prod.example.com' },
872-
},
873-
},
874-
},
875-
},
876-
},
877-
};
802+
it('should not apply service-level overrides to workspace-scoped resources', () => {
803+
const result = applyOverrides(
804+
{ type: ResourceType.Api, nameParts: ['my-api'], workspace: 'other-workspace' },
805+
{ properties: { serviceUrl: 'https://dev.example.com' } },
806+
overrideConfig
807+
);
808+
expect(result.properties).toHaveProperty('serviceUrl', 'https://dev.example.com');
809+
});
878810

879-
const result = applyOverrides(descriptor, json, overrideConfig);
880-
expect(result.properties).toHaveProperty('serviceUrl', 'https://prod.example.com');
811+
it('should not apply workspace overrides to service-level resources', () => {
812+
const result = applyOverrides(
813+
{ type: ResourceType.Backend, nameParts: ['my-backend'] },
814+
{ properties: { url: 'https://dev-backend.example.com' } },
815+
overrideConfig
816+
);
817+
expect(result.properties).toHaveProperty('url', 'https://dev-backend.example.com');
818+
});
819+
820+
it('should detect named value overrides within a workspace scope', () => {
821+
expect(hasNamedValueOverride('my-nv', overrideConfig, 'my-workspace')).toBe(true);
822+
expect(hasNamedValueOverride('my-nv', overrideConfig)).toBe(false);
823+
expect(hasNamedValueOverride('my-nv', overrideConfig, 'other-workspace')).toBe(false);
881824
});
882825
});
883826
});

0 commit comments

Comments
 (0)