Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions docs/guides/environment-overrides.md
Original file line number Diff line number Diff line change
Expand Up @@ -416,8 +416,6 @@ 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.

## Override rules

### Names must be consistent
Expand Down
4 changes: 2 additions & 2 deletions src/services/api-publisher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
} from './resource-publisher.js';
import { mapDescriptor } from './env-mapper.js';
import { runParallel } from '../lib/parallel-runner.js';
import { applyOverrides } from './override-merger.js';
import { applyOverrides, resolveOverrideSection } from './override-merger.js';
import { logger } from '../lib/logger.js';
import {
getNamePart,
Expand Down Expand Up @@ -555,7 +555,7 @@ async function publishRootApi(
json = normalizeApiAuthenticationSettings(json, {
preferLegacyFields: prefersLegacyAuthOverride(
getNamePart(descriptor.nameParts, 0),
config.overrides?.apis
resolveOverrideSection(config.overrides, 'apis', descriptor.workspace)
),
});
const isCurrent = getApiIsCurrent(json);
Expand Down
58 changes: 43 additions & 15 deletions src/services/override-merger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { logger } from '../lib/logger.js';
import { getNameFromNameParts, isSingletonType } from '../lib/resource-path.js';

/** Keys of OverrideConfig that hold OverrideSection values (excludes non-section fields). */
type OverrideSectionKey = keyof { [K in keyof OverrideConfig as OverrideConfig[K] extends OverrideSection | undefined ? K : never]: unknown };
export type OverrideSectionKey = keyof { [K in keyof OverrideConfig as OverrideConfig[K] extends OverrideSection | undefined ? K : never]: unknown };

/**
* Map resource types to their top-level override config section key.
Expand Down Expand Up @@ -65,10 +65,16 @@ const GRANDCHILD_OVERRIDE_MAP: Partial<Record<ResourceType, {
* Check whether a named value has an explicit override entry.
* Uses case-insensitive matching to align with override-merger behavior.
*/
export function hasNamedValueOverride(name: string, overrides?: OverrideConfig): boolean {
if (!overrides?.namedValues) return false;
export function hasNamedValueOverride(
name: string,
overrides?: OverrideConfig,
workspace?: string
): boolean {
if (!overrides) return false;
const namedValues = resolveOverrideSection(overrides, 'namedValues', workspace);
if (!namedValues) return false;
const lowerName = name.toLowerCase();
return Object.keys(overrides.namedValues).some(
return Object.keys(namedValues).some(
(key) => key.toLowerCase() === lowerName
);
}
Expand All @@ -91,26 +97,54 @@ export function applyOverrides(
// Try direct override lookup first
const directSection = OVERRIDE_SECTION_MAP[descriptor.type];
if (directSection) {
const section = overrides[directSection];
const section = resolveOverrideSection(overrides, directSection, descriptor.workspace);
if (!section) return { ...json };
return applyFromSection(descriptor, json, section);
}

// Try nested child override lookup
const childMapping = CHILD_OVERRIDE_MAP[descriptor.type];
if (childMapping) {
return applyNestedOverride(descriptor, json, overrides, childMapping);
const parentSection = resolveOverrideSection(overrides, childMapping.parentSection, descriptor.workspace);
if (!parentSection) return { ...json };
return applyNestedOverride(descriptor, json, parentSection, childMapping);
}

// Try grandchild (3-level) override lookup
const grandchildMapping = GRANDCHILD_OVERRIDE_MAP[descriptor.type];
if (grandchildMapping) {
return applyGrandchildOverride(descriptor, json, overrides, grandchildMapping);
const parentSection = resolveOverrideSection(overrides, grandchildMapping.parentSection, descriptor.workspace);
if (!parentSection) return { ...json };
return applyGrandchildOverride(descriptor, json, parentSection, grandchildMapping);
}

return { ...json };
}

/**
* Resolve the override section to search for a given section key, honoring workspace scoping.
* Workspace-scoped resources are strictly isolated: they only read from
* `overrides.workspaces[workspace].children[sectionKey]`, never the top-level section.
*/
export function resolveOverrideSection(
overrides: OverrideConfig | undefined,
sectionKey: OverrideSectionKey,
workspace: string | undefined
): OverrideSection | undefined {
if (!overrides) return undefined;
if (!workspace) {
return overrides[sectionKey];
}

const workspacesSection = overrides.workspaces;
if (!workspacesSection) return undefined;

const workspaceEntry = findEntryByName(workspacesSection, workspace);
if (!workspaceEntry?.children) return undefined;

return workspaceEntry.children[sectionKey];
}

/**
* Apply override from a direct section match.
* For singleton resources (e.g., ServicePolicy with nameParts: []),
Expand Down Expand Up @@ -172,12 +206,9 @@ function applyFromSection(
function applyNestedOverride(
descriptor: ResourceDescriptor,
json: Record<string, unknown>,
overrides: OverrideConfig,
parentSection: OverrideSection,
mapping: { parentSection: OverrideSectionKey; childKey: string }
): Record<string, unknown> {
const parentSection = overrides[mapping.parentSection];
if (!parentSection) return { ...json };

const parentName = descriptor.nameParts[0];
if (!parentName) return { ...json };

Expand Down Expand Up @@ -220,12 +251,9 @@ function applyNestedOverride(
function applyGrandchildOverride(
descriptor: ResourceDescriptor,
json: Record<string, unknown>,
overrides: OverrideConfig,
parentSection: OverrideSection,
mapping: { parentSection: OverrideSectionKey; childKey: string; grandchildKey: string }
): Record<string, unknown> {
const parentSection = overrides[mapping.parentSection];
if (!parentSection) return { ...json };

const parentName = descriptor.nameParts[0];
if (!parentName) return { ...json };

Expand Down
2 changes: 1 addition & 1 deletion src/services/publish-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
17 changes: 11 additions & 6 deletions src/services/resource-publisher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import type {
} from '../models/types.js';
import type { PublishConfig } from '../models/config.js';
import { ResourceType, RESOURCE_TYPE_METADATA, MANAGED_GATEWAY_NAME } from '../models/resource-types.js';
import { applyOverrides } from './override-merger.js';
import { applyOverrides, resolveOverrideSection } from './override-merger.js';
import { checkKeyVaultSecretAccess } from './keyvault-checker.js';
import {
getNamePart,
Expand Down Expand Up @@ -338,7 +338,7 @@ export async function publishResource(
const hasDisplayNameOverride = hasExplicitPropertyOverride(
canonicalName,
'displayName',
config.overrides?.namedValues,
resolveOverrideSection(config.overrides, 'namedValues', descriptor.workspace),
);
if (!hasDisplayNameOverride) {
const props = json.properties as Record<string, unknown> | undefined;
Expand Down Expand Up @@ -469,8 +469,9 @@ export async function publishResource(
// validation errors in APIM's revision creation.
if (descriptor.type === ResourceType.Api) {
const apiName = getNamePart(descriptor.nameParts, 0);
const apiOverrides = resolveOverrideSection(config.overrides, 'apis', descriptor.workspace);
json = normalizeApiAuthenticationSettings(json, {
preferLegacyFields: prefersLegacyAuthOverride(apiName, config.overrides?.apis),
preferLegacyFields: prefersLegacyAuthOverride(apiName, apiOverrides),
});
if (apiName.includes(';rev=')) {
const baseApiName = apiName.split(';rev=')[0];
Expand Down Expand Up @@ -498,7 +499,7 @@ export async function publishResource(
}
if (
Object.hasOwn(cleanProps, 'description') &&
hasExplicitPropertyOverride(apiName, 'description', config.overrides?.apis)
hasExplicitPropertyOverride(apiName, 'description', apiOverrides)
) {
logger.warn(
`Ignoring 'description' override for revision '${apiName}': ` +
Expand Down Expand Up @@ -1209,7 +1210,11 @@ export function applyApiPathPrefix(
}

const canonicalApiName = getNamePart(descriptor.nameParts, 0).split(';rev=')[0];
if (hasExplicitPropertyOverride(canonicalApiName, 'path', config.overrides?.apis)) {
if (hasExplicitPropertyOverride(
canonicalApiName,
'path',
resolveOverrideSection(config.overrides, 'apis', descriptor.workspace),
)) {
return json;
}

Expand Down Expand Up @@ -1631,7 +1636,7 @@ async function rewriteNamedValueReferences(
const hasDisplayNameOverride = hasExplicitPropertyOverride(
ref,
'displayName',
overrides?.namedValues,
resolveOverrideSection(overrides, 'namedValues', descriptor.workspace),
);
if (!hasDisplayNameOverride) {
const baseDisplayName = displayName ?? ref;
Expand Down
2 changes: 1 addition & 1 deletion src/services/secret-redaction-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
71 changes: 67 additions & 4 deletions tests/unit/services/api-publisher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,16 @@ vi.mock('../../../src/services/resource-publisher.js', async () => {
};
});

// Mock override-merger
vi.mock('../../../src/services/override-merger.js', () => ({
applyOverrides: vi.fn((descriptor, json) => json),
}));
// Mock override-merger while retaining its workspace section resolver.
vi.mock('../../../src/services/override-merger.js', async () => {
const actual = await vi.importActual<typeof import('../../../src/services/override-merger.js')>(
'../../../src/services/override-merger.js'
);
return {
...actual,
applyOverrides: vi.fn((_descriptor, json) => json),
};
});

// Mock parallel-runner
const mockRunParallel = vi.fn();
Expand Down Expand Up @@ -1446,6 +1452,63 @@ describe('api-publisher', () => {
expect(client.putResource).toHaveBeenCalled();
});

it('should honor workspace API auth and path overrides during root post-processing', async () => {
const client = createMockClient();
const store = createMockStore();
store.readResource.mockResolvedValue({
name: 'orders-api',
properties: {
path: 'workspace/orders',
authenticationSettings: {
oAuth2: { authorizationServerId: 'workspace-server' },
oAuth2AuthenticationSettings: [{ authorizationServerId: 'artifact-server' }],
},
},
});
const descriptor: ResourceDescriptor = {
type: ResourceType.Api,
nameParts: ['orders-api'],
workspace: 'team-a',
};
const config: PublishConfig = {
...testConfig,
envMapping: {
prefix: 'dev-',
suffix: '',
apiPathPrefix: 'dev/',
appliesTo: DEFAULT_APPLIES_TO,
},
overrides: {
workspaces: {
'team-a': {
properties: {},
children: {
apis: {
'orders-api': {
properties: {
path: 'workspace/orders',
authenticationSettings: {
oAuth2: { authorizationServerId: 'workspace-server' },
},
},
},
},
},
},
},
},
};

await publishApi(client, store, testContext, descriptor, config);

const putJson = client.putResource.mock.calls[0]?.[2] as Record<string, unknown>;
const properties = putJson.properties as Record<string, unknown>;
expect(properties.path).toBe('workspace/orders');
expect(properties.authenticationSettings).toEqual({
oAuth2: { authorizationServerId: 'workspace-server' },
});
});

it('should return failed result on exception', async () => {
const client = createMockClient();
const store = createMockStore([]);
Expand Down
Loading
Loading