From b7bcf8f29e2df1fbc094613d2220dd3840f429de Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:04:21 +0000 Subject: [PATCH 1/2] Initial plan From 6c0a0edaa21b3b7b556e89c04020c1ba2963c27d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:08:05 +0000 Subject: [PATCH 2/2] fix: apply workspace-scoped override properties Closes #118 Co-authored-by: azaslonov <2320302+azaslonov@users.noreply.github.com> --- docs/guides/environment-overrides.md | 2 +- src/services/override-merger.ts | 56 +++++++- src/services/publish-service.ts | 2 +- src/services/secret-redaction-guard.ts | 2 +- tests/unit/services/override-merger.test.ts | 138 +++++++++++++++++++- 5 files changed, 192 insertions(+), 8 deletions(-) diff --git a/docs/guides/environment-overrides.md b/docs/guides/environment-overrides.md index 30ebcba7..22964fc3 100644 --- a/docs/guides/environment-overrides.md +++ b/docs/guides/environment-overrides.md @@ -416,7 +416,7 @@ workspaces: The supported workspace child sections are: `apis`, `backends`, `diagnostics`, `groups`, `loggers`, `namedValues`, `policyFragments`, `products`, `subscriptions`, `tags`, and `versionSets`. -> ⚠️ **Known limitation — tracked in [#118](https://github.com/Azure/apiops-cli/issues/118):** workspace child overrides are *parsed* (the YAML above is accepted with no errors) but are **not yet applied at publish time**. Until #118 is fixed, only the workspace container's own `properties` are honored for workspace-scoped resources. Authoring overrides in this nested shape today is safe and forward-compatible — they will start taking effect automatically once the merger is updated. +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 diff --git a/src/services/override-merger.ts b/src/services/override-merger.ts index e6d3a012..14c2b334 100644 --- a/src/services/override-merger.ts +++ b/src/services/override-merger.ts @@ -61,14 +61,55 @@ const GRANDCHILD_OVERRIDE_MAP: Partial key.toLowerCase() === lowerName ); } @@ -77,13 +118,20 @@ export function hasNamedValueOverride(name: string, overrides?: OverrideConfig): * 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( descriptor: ResourceDescriptor, json: Record, - overrides: OverrideConfig | undefined + allOverrides: OverrideConfig | undefined ): Record { + if (!allOverrides) { + return { ...json }; + } + + const overrides = resolveOverrideScope(allOverrides, descriptor.workspace, descriptor.type); if (!overrides) { return { ...json }; } diff --git a/src/services/publish-service.ts b/src/services/publish-service.ts index 517667a9..404949c4 100644 --- a/src/services/publish-service.ts +++ b/src/services/publish-service.ts @@ -667,7 +667,7 @@ function splitNamedValues( for (const descriptor of descriptors) { if (descriptor.type === ResourceType.NamedValue) { const name = getNamePart(descriptor.nameParts, 0); - if (isAutoGeneratedId(name) && !hasNamedValueOverride(name, overrides)) { + if (isAutoGeneratedId(name) && !hasNamedValueOverride(name, overrides, descriptor.workspace)) { logger.debug(`Skipping auto-generated named value: ${name}`); continue; } diff --git a/src/services/secret-redaction-guard.ts b/src/services/secret-redaction-guard.ts index ba1a8442..106a4817 100644 --- a/src/services/secret-redaction-guard.ts +++ b/src/services/secret-redaction-guard.ts @@ -111,7 +111,7 @@ async function scanNamedValue( // supplies an explicit override, so scanning them here would flag markers in // artifacts that are never sent to APIM. Mirror the publish filter exactly. const name = getNamePart(descriptor.nameParts, 0); - if (isAutoGeneratedId(name) && !hasNamedValueOverride(name, config.overrides)) { + if (isAutoGeneratedId(name) && !hasNamedValueOverride(name, config.overrides, descriptor.workspace)) { return undefined; } diff --git a/tests/unit/services/override-merger.test.ts b/tests/unit/services/override-merger.test.ts index 13033d6d..d3548616 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,4 +655,140 @@ describe('override-merger', () => { expect(result.properties).toHaveProperty('isCurrent', true); }); }); + describe('workspace-scoped overrides', () => { + 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' } } } }, + }, + }, + }, + }, + }, + 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' } } } }, + }, + }, + }, + }, + }, + }; + + 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'); + }); + + 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 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 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 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 apiPolicy = applyOverrides( + { type: ResourceType.ApiPolicy, nameParts: ['my-api'], workspace: 'my-workspace' }, + { properties: { format: 'rawxml', value: '' } }, + overrideConfig + ); + expect(apiPolicy.properties).toEqual({ format: 'xml', value: '' }); + + 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 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 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'); + }); + + 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); + }); + }); });