From 76bf0e6efe5348cc1ddd32f04f394eaff3006e39 Mon Sep 17 00:00:00 2001 From: Chris Dayne Date: Tue, 4 Aug 2026 09:48:32 +1000 Subject: [PATCH 1/3] feat: add tests for transitive dependencies extraction and publishing - Implemented unit tests for extracting backend pool members and policy fragment dependencies transitively in extract-service. - Added tests to ensure API exclusions are respected during product publishing in product-publisher. - Enhanced publish-service tests to cover filtering resources, including transitive dependencies and dry-run scenarios. - Introduced tests for resource-publisher to skip entries based on exclusion filters for subscriptions and API tags. - Added tests for workspace-extractor to validate workspace exclusions and transitive extraction of policy fragments. - Created transitive-extractor service to handle extraction of transitive dependencies with proper logging and error handling. --- README.md | 15 +- docs/architecture.md | 1 + docs/commands/publish.md | 18 +- docs/guides/filtering-resources.md | 58 ++- src/cli/publish-command.ts | 28 +- src/lib/resource-path.ts | 17 + src/models/config.ts | 2 + src/services/api-publisher.ts | 167 ++++++++- src/services/extract-service.ts | 97 ++--- src/services/filter-service.ts | 49 ++- src/services/product-publisher.ts | 100 +++++- src/services/publish-service.ts | 251 +++++++++++-- src/services/resource-publisher.ts | 86 +++++ src/services/transitive-extractor.ts | 99 +++++ src/services/transitive-resolver.ts | 189 +++++++++- src/services/workspace-extractor.ts | 140 ++++---- tests/unit/cli/publish-command.test.ts | 20 ++ tests/unit/lib/resource-path.test.ts | 32 ++ tests/unit/services/api-publisher.test.ts | 257 +++++++++++++ tests/unit/services/extract-service.test.ts | 72 ++++ tests/unit/services/product-publisher.test.ts | 88 +++++ tests/unit/services/publish-service.test.ts | 339 ++++++++++++++++++ .../unit/services/resource-publisher.test.ts | 184 ++++++++++ .../unit/services/transitive-resolver.test.ts | 117 +++++- .../unit/services/workspace-extractor.test.ts | 140 ++++++++ 25 files changed, 2332 insertions(+), 234 deletions(-) create mode 100644 src/services/transitive-extractor.ts diff --git a/README.md b/README.md index aec7ad71..363ff38e 100644 --- a/README.md +++ b/README.md @@ -83,9 +83,11 @@ Publish local artifact files to an Azure APIM service. | `--service-name ` | *(required)* | APIM service name | | `--source ` | `./apim-artifacts` | Source artifacts directory | | `--overrides ` | | Path to overrides file | +| `--filter ` | | Filter YAML file (same format as `extract`) | +| `--no-transitive` | | Publish only filter matches, without referenced dependencies | | `--commit-id ` | | Git commit SHA for incremental publish | | `--dry-run` | | Preview changes without applying | -| `--delete-unmatched` | | Delete resources not in artifacts (mutually exclusive with `--commit-id`) | +| `--delete-unmatched` | | Delete resources not in artifacts (mutually exclusive with `--filter` and `--commit-id`) | ```bash apiops publish --help @@ -107,8 +109,19 @@ apiops publish \ --resource-group \ --service-name \ --commit-id + +# Publish a filtered subset; transitive dependencies are included by default +apiops publish \ + --resource-group \ + --service-name \ + --source ./apim-artifacts \ + --filter ./filter.yaml ``` +The publish filter uses the same YAML file and matching rules as `apiops extract --filter`. +Referenced named values, backends (including backend pool members), policy fragments, and version +sets are included automatically. Add `--no-transitive` to publish only the exact filter matches. + ### `apiops init` Scaffold a new APIM artifacts repository with CI/CD pipelines. diff --git a/docs/architecture.md b/docs/architecture.md index 08c20f48..e4e1b211 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -109,6 +109,7 @@ flowchart TB extract_svc --> apim_client extract_svc --> store + publish_svc --> filter_svc publish_svc --> override_svc publish_svc --> git_svc publish_svc --> dry_svc diff --git a/docs/commands/publish.md b/docs/commands/publish.md index d074d5fe..ef58ffbe 100644 --- a/docs/commands/publish.md +++ b/docs/commands/publish.md @@ -44,6 +44,20 @@ apiops publish \ --commit-id abc123def456 ``` +### Publish a filtered subset + +```bash +apiops publish \ + --resource-group my-rg \ + --service-name my-apim \ + --filter ./configuration.extractor.yaml +``` + +The filter uses the same YAML format as `apiops extract --filter`. Referenced named values, +backends (including backend pool members), policy fragments, and version sets are included by +default; use `--no-transitive` to publish only exact matches. Product, gateway, and subscription +links do not pull their API or Product targets into the publish set. + ### Delete resources not in source ```bash @@ -73,11 +87,13 @@ apiops publish \ | `--service-name ` | string | — | Yes | APIM service instance name | | `--source ` | string | `./apim-artifacts` | No | Source directory containing artifacts | | `--overrides ` | string | — | No | Override configuration YAML file | +| `--filter ` | string | — | No | Filter YAML file shared with `extract` | +| `--no-transitive` | boolean | `false` | No | Publish only exact filter matches | | `--commit-id ` | string | env: `COMMIT_ID` | No | Git commit SHA for incremental publish | | `--dry-run` | boolean | `false` | No | Preview changes without applying | | `--delete-unmatched` | boolean | `false` | No | Delete APIM resources not present in source | -> **Note:** `--commit-id` and `--delete-unmatched` are **mutually exclusive**. The CLI will error if both are specified. +> **Note:** `--filter` and `--commit-id` can be combined; `--delete-unmatched` cannot be combined with either option. ### Global flags diff --git a/docs/guides/filtering-resources.md b/docs/guides/filtering-resources.md index f643f53b..51ec2adc 100644 --- a/docs/guides/filtering-resources.md +++ b/docs/guides/filtering-resources.md @@ -64,7 +64,7 @@ documentations: [] workspaces: [] ``` -With transitive resolution enabled (the default), any version set, backend, named value, policy fragment, or tag directly referenced by `my-own-api` or its policies is still pulled in automatically — even though those keys are set to `[]`. Use `--no-transitive` to disable that behavior. +With transitive resolution enabled (the default), any version set, backend, named value, or policy fragment directly referenced by `my-own-api` or its policies is still pulled in automatically — even though those keys are set to `[]`. Use `--no-transitive` to disable that behavior. The three states for every key are: @@ -200,16 +200,14 @@ namedValues: - '!keyvault-*' ``` -Exclusions work anywhere a string list is accepted, including sub-filter fields inside `apiSubFilters` and `workspaceSubFilters`: +Exclusions work anywhere a string list is accepted, including nested API and workspace sub-filters: ```yaml apis: - - 'my-api' -apiSubFilters: - my-api: - operations: - - 'get-*' - - '!get-internal-*' # keep all get-* operations except internal ones + - 'my-api': + operations: + - 'get-*' + - '!get-internal-*' # keep all get-* operations except internal ones ``` --- @@ -260,7 +258,7 @@ Supported workspace sub-filter keys: `apis`, `backends`, `diagnostics`, `groups` --- -## All 16 Filterable Resource Types +## All 17 Filterable Resource Types | Filter Field | APIM Resource | Example Values | |-------------|---------------|----------------| @@ -295,10 +293,9 @@ flowchart TD A[Filtered API] --> B[Backends referenced in policies] A --> C[Named Values referenced in policies] A --> D[Policy Fragments included in policies] - A --> E[Tags attached to the API] - A --> F[Products containing the API] - A --> G[Diagnostics configured on the API] - A --> H[Version Set the API belongs to] + A --> E[Version Set the API belongs to] + B --> F[Backend pool members] + D --> G[Resources referenced by fragment content] ``` ### Example @@ -315,11 +312,23 @@ If `petstore-api` has a policy that references: - Named value `petstore-api-key` → **auto-included** - Policy fragment `rate-limit-fragment` → **auto-included** -And `petstore-api` is assigned to: -- Product `starter` → **auto-included** -- Tag `production` → **auto-included** +The extract output includes these dependencies even though only `apis` was specified in the filter. +Product, gateway, tag, and subscription links do not reverse the dependency direction or +auto-include composite API or Product targets. + +### API Children Are Still Extracted + +Tags and diagnostics were not removed from API extraction. They are API child artifacts, not +transitive dependencies: -The extract output includes all of these, even though only `apiNames` was specified in the filter. +- Selecting an API also extracts its API tag associations and API diagnostics. +- Use a nested API `diagnostics` filter to narrow or exclude API diagnostics. +- Product selection remains independent. Selecting an API does not find and include Products that + contain it; omit `products` to include all Products, list specific Products, or use `products: []` + to include none. + +Top-level `tags` and `diagnostics` filter their resource definitions independently from API child +artifacts. This follows the general rule that every top-level filter key is independent. ### Why Transitive Matters @@ -329,7 +338,7 @@ Without transitive resolution, publishing the extracted artifacts to a new APIM ## Disabling Transitive Dependencies -Use `--no-transitive` to extract **only** the explicitly listed resources: +Use `--no-transitive` to disable referenced dependency expansion: ```bash apiops extract \ @@ -345,6 +354,9 @@ apiops extract \ - You want a minimal extract and will handle missing references manually - Debugging — to see exactly what was explicitly filtered +Selected parent resources still include their normal child artifacts. For example, selecting an API +still extracts its tags and diagnostics unless nested API sub-filters exclude them. + > ⚠️ **Caution:** Extracted artifacts without transitive dependencies may not be publishable standalone. You'll need to ensure all referenced resources exist in the target APIM instance. --- @@ -401,7 +413,15 @@ backends: ### Full Extract Minus Specific Resources -There is no "exclude" syntax. To extract everything except certain resources, list all the resources you _do_ want. For large instances, it's often easier to extract everything and use `.gitignore` or separate branches to manage visibility. +Use `!`-prefixed entries to extract everything except specific resources. For example: + +```yaml +apis: + - '*' + - '!legacy-api' +``` + +For large instances, it can still be easier to extract everything and use `.gitignore` or separate branches to manage visibility. ### Pattern-Based Team Filtering diff --git a/src/cli/publish-command.ts b/src/cli/publish-command.ts index 61bfc165..469da08c 100644 --- a/src/cli/publish-command.ts +++ b/src/cli/publish-command.ts @@ -3,7 +3,7 @@ /** * Publish command CLI registration * Commander subcommand with --resource-group, --service-name, --source, - * --overrides, --dry-run, --delete-unmatched flags. + * --overrides, --filter, --no-transitive, --dry-run, --delete-unmatched flags. * Includes --format json: machine-readable JSON output mode. */ @@ -11,7 +11,7 @@ import { Command } from 'commander'; import { PublishConfig } from '../models/config.js'; import { ApimServiceContext } from '../models/types.js'; import { runPublish, PublishResult } from '../services/publish-service.js'; -import { loadOverrideConfig } from '../lib/config-loader.js'; +import { loadFilterConfig, loadOverrideConfig } from '../lib/config-loader.js'; import { logger, parseLogLevel } from '../lib/logger.js'; import { ApimClient } from '../clients/apim-client.js'; import { ArtifactStore } from '../clients/artifact-store.js'; @@ -25,6 +25,8 @@ interface PublishOptions { serviceName: string; source: string; overrides?: string; + filter?: string; + transitive: boolean; commitId?: string; dryRun: boolean; deleteUnmatched: boolean; @@ -40,6 +42,8 @@ export function createPublishCommand(): Command { .requiredOption('--service-name ', 'APIM service instance name') .option('--source ', 'Source directory with artifacts', './apim-artifacts') .option('--overrides ', 'Override configuration YAML file') + .option('--filter ', 'Filter configuration YAML file') + .option('--no-transitive', 'Disable transitive dependency inclusion') .option( '--commit-id ', 'Git commit SHA for incremental publish (overrides COMMIT_ID env var)' @@ -121,15 +125,24 @@ async function executePublish( } } + let filterConfig; + if (options.filter) { + filterConfig = await loadFilterConfig(options.filter); + if (!filterConfig) { + logger.error(`Filter file not found: ${options.filter}`); + process.exit(2); + } + } + // Resolve commit ID for incremental publish const commitId = options.commitId ?? process.env.COMMIT_ID; if (commitId) { logger.debug(`Using incremental publish with commit ID: ${commitId}`); } - if (hasMutuallyExclusivePublishOptions(options.deleteUnmatched, commitId)) { + if (hasMutuallyExclusivePublishOptions(options.deleteUnmatched, commitId, Boolean(options.filter))) { logger.error( - 'Options --commit-id (or COMMIT_ID) and --delete-unmatched are mutually exclusive.' + 'Option --delete-unmatched cannot be combined with --filter or --commit-id (or COMMIT_ID).' ); process.exit(2); } @@ -138,6 +151,8 @@ async function executePublish( const publishConfig: PublishConfig = { service: context, sourceDir: options.source, + filter: filterConfig, + includeTransitive: options.transitive, overrides: overrideConfig, dryRun: options.dryRun, deleteUnmatched: options.deleteUnmatched, @@ -167,9 +182,10 @@ async function executePublish( */ export function hasMutuallyExclusivePublishOptions( deleteUnmatched: boolean, - commitId?: string + commitId?: string, + hasFilter = false ): boolean { - return deleteUnmatched && Boolean(commitId); + return deleteUnmatched && (Boolean(commitId) || hasFilter); } /** diff --git a/src/lib/resource-path.ts b/src/lib/resource-path.ts index 0dc9835d..b8032224 100644 --- a/src/lib/resource-path.ts +++ b/src/lib/resource-path.ts @@ -143,6 +143,23 @@ export function getNamePart(nameParts: string[], index: number): string { return value; } +/** + * Create a case-insensitive identity key for a resource descriptor. + */ +export function getResourceDescriptorKey(descriptor: ResourceDescriptor): string { + return `${descriptor.type}:${descriptor.workspace ?? ''}:${descriptor.nameParts.join('/')}`.toLowerCase(); +} + +/** + * Compare resource descriptors using APIM's case-insensitive names. + */ +export function sameResourceDescriptor( + left: ResourceDescriptor, + right: ResourceDescriptor +): boolean { + return getResourceDescriptorKey(left) === getResourceDescriptorKey(right); +} + /** * Converts a positional template string to a capturing regex. * Each `{i}` placeholder becomes a `([^/]+)` capture group; all other diff --git a/src/models/config.ts b/src/models/config.ts index d16f9243..644b24b1 100644 --- a/src/models/config.ts +++ b/src/models/config.ts @@ -75,6 +75,8 @@ export interface FilterConfig { export interface PublishConfig { service: ApimServiceContext; sourceDir: string; + filter?: FilterConfig; + includeTransitive?: boolean; overrides?: OverrideConfig; dryRun: boolean; deleteUnmatched: boolean; diff --git a/src/services/api-publisher.ts b/src/services/api-publisher.ts index cbb9c248..0720604d 100644 --- a/src/services/api-publisher.ts +++ b/src/services/api-publisher.ts @@ -21,8 +21,13 @@ import { import { runParallel } from '../lib/parallel-runner.js'; import { applyOverrides } from './override-merger.js'; import { logger } from '../lib/logger.js'; -import { getNamePart, getPublishTier } from '../lib/resource-path.js'; +import { + getNamePart, + getPublishTier, + getResourceDescriptorKey, +} from '../lib/resource-path.js'; import { isAutoGeneratedId } from '../lib/auto-generated.js'; +import { resolveWorkspaceFilter, shouldIncludeResource } from './filter-service.js'; /** * API child resource types that should be published after the API itself @@ -51,11 +56,20 @@ export async function publishApi( store: IArtifactStore, context: ApimServiceContext, descriptor: ResourceDescriptor, - config: PublishConfig + config: PublishConfig, + allowedDescriptors?: ResourceDescriptor[] ): Promise { try { + const includeSpecification = await shouldImportSpecification( + store, + descriptor, + config, + allowedDescriptors + ); // Step 1: Publish root API (with spec import if available) - const rootResult = await publishRootApi(client, store, context, descriptor, config); + const rootResult = await publishRootApi(client, store, context, descriptor, config, { + includeSpecification, + }); if (rootResult.status !== 'success') { return rootResult; } @@ -70,7 +84,14 @@ export async function publishApi( } // Step 2: Find and publish revisions in numeric order - const publishedRevisionCount = await publishApiRevisions(client, store, context, descriptor, config); + const publishedRevisionCount = await publishApiRevisions( + client, + store, + context, + descriptor, + config, + allowedDescriptors + ); // Step 2b: Align root API only when source marks it as current. // Source of truth is properties.isCurrent in root apiInformation.json. @@ -89,7 +110,15 @@ export async function publishApi( // Step 3: Publish child resources in parallel // When a spec was imported, operations and schemas are auto-created by APIM - await publishApiChildren(client, store, context, descriptor, config, rootResult.specImported); + await publishApiChildren( + client, + store, + context, + descriptor, + config, + rootResult.specImported, + allowedDescriptors + ); return { descriptor, @@ -106,6 +135,43 @@ export async function publishApi( } } +async function shouldImportSpecification( + store: IArtifactStore, + apiDescriptor: ResourceDescriptor, + config: PublishConfig, + allowedDescriptors?: ResourceDescriptor[] +): Promise { + if (!allowedDescriptors) { + return true; + } + + const apiName = getNamePart(apiDescriptor.nameParts, 0); + const managedChildren = (await store.listResources(config.sourceDir)).filter( + (descriptor) => + SPEC_MANAGED_CHILD_TYPES.has(descriptor.type) && + getNamePart(descriptor.nameParts, 0) === apiName && + descriptor.workspace === apiDescriptor.workspace + ); + const effectiveFilter = apiDescriptor.workspace + ? resolveWorkspaceFilter(apiDescriptor.workspace, config.filter) + : config.filter; + if (managedChildren.length === 0) { + const subFilter = Object.entries(effectiveFilter?.apiSubFilters ?? {}).find( + ([name]) => name.toLowerCase() === apiName.toLowerCase() + )?.[1]; + return subFilter?.operations === undefined && subFilter?.schemas === undefined; + } + + if (!config.filter) { + const allowed = new Set(allowedDescriptors.map(getResourceDescriptorKey)); + return managedChildren.every((descriptor) => allowed.has(getResourceDescriptorKey(descriptor))); + } + + return managedChildren.every((descriptor) => + shouldIncludeResource(descriptor, effectiveFilter) + ); +} + /** * Maps spec file format to APIM ContentFormat for inline import. * @param specDialect - The spec dialect of a JSON spec. Swagger 2.0 ('swagger2') @@ -275,16 +341,32 @@ async function publishApiRevisions( store: IArtifactStore, context: ApimServiceContext, apiDescriptor: ResourceDescriptor, - config: PublishConfig + config: PublishConfig, + allowedDescriptors?: ResourceDescriptor[] ): Promise { // List all resources from store const allDescriptors = await store.listResources(config.sourceDir); + const allowed = allowedDescriptors + ? new Set(allowedDescriptors.map(getResourceDescriptorKey)) + : undefined; + const effectiveFilter = apiDescriptor.workspace + ? resolveWorkspaceFilter(apiDescriptor.workspace, config.filter) + : config.filter; + const rootName = getNamePart(apiDescriptor.nameParts, 0).toLowerCase(); // Find revision descriptors for this API const revisionDescriptors = allDescriptors.filter( (d) => d.type === ResourceType.Api && - getNamePart(d.nameParts, 0).startsWith(`${getNamePart(apiDescriptor.nameParts, 0)};rev=`) + d.workspace === apiDescriptor.workspace && + getNamePart(d.nameParts, 0).toLowerCase().startsWith(`${rootName};rev=`) && + ( + !allowed || + allowed.has(getResourceDescriptorKey(d)) || + // Incremental mode only diffs changed files; fall back to the filter + // so an unchanged revision still republishes with its root API. + (config.commitId !== undefined && shouldIncludeResource(d, effectiveFilter)) + ) ); // Sort revisions by revision number @@ -343,19 +425,35 @@ async function publishApiChildren( context: ApimServiceContext, apiDescriptor: ResourceDescriptor, config: PublishConfig, - specImported: boolean = false + specImported: boolean = false, + allowedDescriptors?: ResourceDescriptor[] ): Promise { // List all resources from store const allDescriptors = await store.listResources(config.sourceDir); + const effectiveFilter = apiDescriptor.workspace + ? resolveWorkspaceFilter(apiDescriptor.workspace, config.filter) + : config.filter; // Find child descriptors for this API let childDescriptors = allDescriptors.filter( (d) => API_CHILD_TYPES.includes(d.type) && - getNamePart(d.nameParts, 0) === getNamePart(apiDescriptor.nameParts, 0) && + getNamePart(d.nameParts, 0).toLowerCase() === getNamePart(apiDescriptor.nameParts, 0).toLowerCase() && + d.workspace === apiDescriptor.workspace && !(specImported && SPEC_MANAGED_CHILD_TYPES.has(d.type)) ); + if (allowedDescriptors) { + const allowed = new Set(allowedDescriptors.map(getResourceDescriptorKey)); + childDescriptors = childDescriptors.filter( + (descriptor) => + allowed.has(getResourceDescriptorKey(descriptor)) || + // Incremental mode only diffs changed files; fall back to the filter + // so an unchanged child still republishes with its parent API. + (config.commitId !== undefined && shouldIncludeResource(descriptor, effectiveFilter)) + ); + } + if (specImported) { // Re-include explicitly named schemas (non-auto-generated IDs). // Auto-generated schemas have 24-char hex names and are recreated by spec import. @@ -363,15 +461,27 @@ async function publishApiChildren( const explicitSchemas = allDescriptors.filter( (d) => d.type === ResourceType.ApiSchema && - getNamePart(d.nameParts, 0) === getNamePart(apiDescriptor.nameParts, 0) && + getNamePart(d.nameParts, 0).toLowerCase() === getNamePart(apiDescriptor.nameParts, 0).toLowerCase() && + d.workspace === apiDescriptor.workspace && !isAutoGeneratedId(getNamePart(d.nameParts, 1)) ); - if (explicitSchemas.length > 0) { + const filteredExplicitSchemas = config.filter + ? explicitSchemas.filter((descriptor) => + shouldIncludeResource(descriptor, effectiveFilter) + ) + : allowedDescriptors + ? explicitSchemas.filter((d) => + allowedDescriptors.some( + (allowed) => getResourceDescriptorKey(allowed) === getResourceDescriptorKey(d) + ) + ) + : explicitSchemas; + if (filteredExplicitSchemas.length > 0) { logger.debug( - `Re-publishing ${explicitSchemas.length} explicit schema(s) after spec import for "${getNamePart(apiDescriptor.nameParts, 0)}"` + `Re-publishing ${filteredExplicitSchemas.length} explicit schema(s) after spec import for "${getNamePart(apiDescriptor.nameParts, 0)}"` ); - childDescriptors = [...childDescriptors, ...explicitSchemas]; + childDescriptors = [...childDescriptors, ...filteredExplicitSchemas]; } } @@ -404,7 +514,15 @@ async function publishApiChildren( // regardless of what the importer defaulted. PATCH is idempotent and only updates // the fields present in the persisted JSON. if (specImported) { - await reconcileOperationsAfterSpecImport(client, store, context, apiDescriptor, config, allDescriptors); + await reconcileOperationsAfterSpecImport( + client, + store, + context, + apiDescriptor, + config, + allDescriptors, + allowedDescriptors + ); } } @@ -423,14 +541,29 @@ async function reconcileOperationsAfterSpecImport( context: ApimServiceContext, apiDescriptor: ResourceDescriptor, config: PublishConfig, - allDescriptors: ResourceDescriptor[] + allDescriptors: ResourceDescriptor[], + allowedDescriptors?: ResourceDescriptor[] ): Promise { - const operationDescriptors = allDescriptors.filter( + let operationDescriptors = allDescriptors.filter( (d) => d.type === ResourceType.ApiOperation && - getNamePart(d.nameParts, 0) === getNamePart(apiDescriptor.nameParts, 0) && + getNamePart(d.nameParts, 0).toLowerCase() === getNamePart(apiDescriptor.nameParts, 0).toLowerCase() && + d.workspace === apiDescriptor.workspace && !isAutoGeneratedId(getNamePart(d.nameParts, 1)) ); + if (config.filter) { + const effectiveFilter = apiDescriptor.workspace + ? resolveWorkspaceFilter(apiDescriptor.workspace, config.filter) + : config.filter; + operationDescriptors = operationDescriptors.filter((descriptor) => + shouldIncludeResource(descriptor, effectiveFilter) + ); + } else if (allowedDescriptors) { + const allowed = new Set(allowedDescriptors.map(getResourceDescriptorKey)); + operationDescriptors = operationDescriptors.filter((d) => + allowed.has(getResourceDescriptorKey(d)) + ); + } if (operationDescriptors.length === 0) return; diff --git a/src/services/extract-service.ts b/src/services/extract-service.ts index e48eb20a..543d98c4 100644 --- a/src/services/extract-service.ts +++ b/src/services/extract-service.ts @@ -26,9 +26,7 @@ import { isSingletonType, isChildType } from '../lib/resource-path.js'; import { extractApiResources, ApiExtractionResult } from './api-extractor.js'; import { extractProductResources, ProductExtractionResult } from './product-extractor.js'; import { extractWorkspaces, WorkspaceExtractionResult } from './workspace-extractor.js'; -import { - findTransitiveDependencies, -} from './transitive-resolver.js'; +import { extractTransitiveDependencies } from './transitive-extractor.js'; import { redactAndWarnPolicySecrets } from './secret-redactor.js'; import { shouldIncludeResource } from './filter-service.js'; import { logger } from '../lib/logger.js'; @@ -132,7 +130,15 @@ export async function runExtraction( } // Phase 7: Extract workspace-scoped resources - await extractWorkspaceResources(client, store, service, outputDir, filter, result); + await extractWorkspaceResources( + client, + store, + service, + outputDir, + filter, + config.includeTransitive, + result + ); // Compute exit code if (result.totalErrors > 0 && result.totalExtracted > 0) { @@ -463,14 +469,6 @@ async function extractGatewayAssociations( } } -/** - * Result of extracting a single transitive dependency. - */ -interface TransitiveTaskResult { - dep: ResourceDescriptor; - success: boolean; -} - /** * Resolve transitive dependencies and extract any additional resources. * Collects results per-task and merges after all tasks complete to avoid @@ -499,61 +497,27 @@ async function resolveAndExtractTransitive( } } - // Find transitive dependencies - const transitiveDeps = findTransitiveDependencies( - result.collectedPolicies, - apiJsonMap + const resources = result.typeResults.flatMap((typeResult) => + typeResult.extracted + .filter((extracted) => extracted.status === 'success') + .map((extracted) => ({ + descriptor: extracted.descriptor, + json: extracted.json, + })) ); - - // Filter out already-extracted resources - // Use buildResourceLabel for the key — it handles singleton types (e.g. - // ServicePolicy) whose nameParts are empty, avoiding a getNamePart crash. - const alreadyExtracted = new Set( - result.extractedDescriptors.map( - (d) => `${d.type}:${buildResourceLabel(d).toLowerCase()}` - ) - ); - - const newDeps = transitiveDeps.filter( - (dep) => !alreadyExtracted.has(`${dep.type}:${buildResourceLabel(dep).toLowerCase()}`) + const transitiveResult = await extractTransitiveDependencies( + client, + store, + context, + outputDir, + result.collectedPolicies, + apiJsonMap, + resources, + result.extractedDescriptors ); - - if (newDeps.length === 0) { - logger.debug('No additional transitive dependencies found'); - return; - } - - logger.info(`Found ${newDeps.length} transitive dependencies to extract`); - - // Extract each transitive dependency - const tasks = newDeps.map((dep) => async (): Promise => { - try { - const json = await client.getResource(context, dep); - if (json) { - await store.writeResource(outputDir, dep, json); - logger.info(`Extracted transitive dependency ${buildResourceLabel(dep)}`); - return { dep, success: true }; - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logger.warn(`Failed to extract transitive dependency ${buildResourceLabel(dep)}: ${errorMessage}`); - } - return { dep, success: false }; - }); - - const taskResults = await runParallel(tasks, DEFAULT_CONCURRENCY); - - // Merge results sequentially after parallel execution completes - for (const taskResult of taskResults) { - if (taskResult.status === 'fulfilled' && taskResult.value) { - if (taskResult.value.success) { - result.totalExtracted++; - result.extractedDescriptors.push(taskResult.value.dep); - } else { - result.totalErrors++; - } - } - } + result.totalExtracted += transitiveResult.extractedDescriptors.length; + result.totalErrors += transitiveResult.errorCount; + result.extractedDescriptors.push(...transitiveResult.extractedDescriptors); } /** @@ -565,10 +529,11 @@ async function extractWorkspaceResources( context: ApimServiceContext, outputDir: string, filter: FilterConfig | undefined, + includeTransitive: boolean, result: ExtractionResult ): Promise { const wsResults = await extractWorkspaces( - client, store, context, outputDir, filter + client, store, context, outputDir, filter, includeTransitive ); result.workspaceResults = wsResults; diff --git a/src/services/filter-service.ts b/src/services/filter-service.ts index e3a82962..98e59807 100644 --- a/src/services/filter-service.ts +++ b/src/services/filter-service.ts @@ -6,7 +6,7 @@ * case-insensitive matching, API root-name matching for revisions. */ -import { FilterConfig, ApiSubFilter } from '../models/config.js'; +import { FilterConfig, ApiSubFilter, WorkspaceSubFilter } from '../models/config.js'; import { ResourceType, RESOURCE_TYPE_METADATA } from '../models/resource-types.js'; import { ResourceDescriptor } from '../models/types.js'; import { logger } from '../lib/logger.js'; @@ -123,6 +123,40 @@ export function shouldIncludeResource( return true; } +/** + * Resolve the resource filter configured for a workspace. + * Returns undefined when the workspace has no nested filter. + */ +export function resolveWorkspaceFilter( + workspaceName: string, + filter?: FilterConfig +): FilterConfig | undefined { + const lowerName = workspaceName.toLowerCase(); + const matchingKey = Object.keys(filter?.workspaceSubFilters ?? {}).find( + (key) => key.toLowerCase() === lowerName + ); + const subFilter = matchingKey ? filter?.workspaceSubFilters?.[matchingKey] : undefined; + return subFilter ? workspaceSubFilterToFilterConfig(subFilter) : undefined; +} + +function workspaceSubFilterToFilterConfig(sub: WorkspaceSubFilter): FilterConfig { + return { + apis: sub.apis, + apiSubFilters: sub.apiSubFilters, + backends: sub.backends, + diagnostics: sub.diagnostics, + groups: sub.groups, + loggers: sub.loggers, + namedValues: sub.namedValues, + policyFragments: sub.policyFragments, + products: sub.products, + schemas: sub.schemas, + subscriptions: sub.subscriptions, + tags: sub.tags, + versionSets: sub.versionSets, + }; +} + /** * Get the fixed singleton name for a resource type from its ARM path. * E.g., ServicePolicy → "policy" @@ -190,6 +224,19 @@ function getParentNameForFilter(descriptor: ResourceDescriptor): string | undefi : parentName; } +/** + * Whether the filter's field for this resource type is a defined array + * (user explicitly scoped it down), as opposed to simply absent. + */ +export function hasExplicitTypeFilter(type: ResourceType, filter?: FilterConfig): boolean { + if (!filter) { + return false; + } + + const field = FILTER_FIELD_MAP[type]; + return field !== undefined && filter[field] !== undefined; +} + /** * Extract root API name from a potentially revision-qualified name. * E.g., "my-api;rev=2" → "my-api" diff --git a/src/services/product-publisher.ts b/src/services/product-publisher.ts index 2720b0cb..3cbed42f 100644 --- a/src/services/product-publisher.ts +++ b/src/services/product-publisher.ts @@ -12,10 +12,11 @@ import type { PublishConfig } from '../models/config.js'; import { ResourceType, RESOURCE_TYPE_METADATA } from '../models/resource-types.js'; import { publishResource, type ResourcePublishResult } from './resource-publisher.js'; import { logger } from '../lib/logger.js'; -import { getNamePart } from '../lib/resource-path.js'; +import { getNamePart, sameResourceDescriptor } from '../lib/resource-path.js'; import { parseArmUri } from '../lib/resource-uri.js'; import { isWorkspaceScope, buildLinkPayload } from '../lib/workspace-link.js'; import { isLinkAlreadyExistsError } from '../clients/apim-client.js'; +import { hasExplicitTypeFilter, resolveWorkspaceFilter, shouldIncludeResource } from './filter-service.js'; /** * Publish a Product with all its associations (APIs, Groups, Tags). @@ -26,7 +27,8 @@ export async function publishProduct( store: IArtifactStore, context: ApimServiceContext, descriptor: ResourceDescriptor, - config: PublishConfig + config: PublishConfig, + allowedDescriptors?: ResourceDescriptor[] ): Promise { try { const productName = getNamePart(descriptor.nameParts, 0); @@ -50,7 +52,8 @@ export async function publishProduct( descriptor, config, 'apis', - ResourceType.ProductApi + ResourceType.ProductApi, + allowedDescriptors ); // Step 3: Publish ProductGroup associations @@ -61,12 +64,13 @@ export async function publishProduct( descriptor, config, 'groups', - ResourceType.ProductGroup + ResourceType.ProductGroup, + allowedDescriptors ); // Step 4: Publish ProductTag associations // Tags are stored in the product directory, need to check for tags - await publishProductTags(client, store, context, descriptor, config); + await publishProductTags(client, store, context, descriptor, config, allowedDescriptors); // Step 5: Publish ProductPolicy if exists const policyDescriptor: ResourceDescriptor = { @@ -75,7 +79,7 @@ export async function publishProduct( workspace: descriptor.workspace, }; const policyContent = await store.readContent(config.sourceDir, policyDescriptor, 'policy'); - if (policyContent) { + if (policyContent && isDescriptorAllowed(policyDescriptor, config, allowedDescriptors)) { await publishResource(client, store, context, policyDescriptor, config); logger.debug(`Published policy for product: ${productName}`); } @@ -164,7 +168,8 @@ async function publishProductAssociations( productDescriptor: ResourceDescriptor, config: PublishConfig, associationType: 'apis' | 'groups', - resourceType: ResourceType + resourceType: ResourceType, + allowedDescriptors?: ResourceDescriptor[] ): Promise { const productName = getNamePart(productDescriptor.nameParts, 0); @@ -194,6 +199,17 @@ async function publishProductAssociations( nameParts: [productName, name], workspace: productDescriptor.workspace, }; + const targetDescriptor: ResourceDescriptor = { + type: associationType === 'apis' ? ResourceType.Api : ResourceType.Group, + nameParts: [name], + workspace: entry.scope === 'service' ? undefined : productDescriptor.workspace, + }; + if (!isAssociationAllowed(assocDescriptor, targetDescriptor, config, allowedDescriptors)) { + logger.warn( + `Skipping ${resourceType} association "${assocDescriptor.nameParts.join('/')}" because its target is excluded by the filter or unavailable in the publish set` + ); + continue; + } try { // In workspace scope, PUT with link payload; otherwise empty body. @@ -226,7 +242,8 @@ async function publishProductTags( store: IArtifactStore, context: ApimServiceContext, productDescriptor: ResourceDescriptor, - config: PublishConfig + config: PublishConfig, + allowedDescriptors?: ResourceDescriptor[] ): Promise { const productName = getNamePart(productDescriptor.nameParts, 0); @@ -253,6 +270,17 @@ async function publishProductTags( nameParts: [productName, tagName], workspace: productDescriptor.workspace, }; + const targetDescriptor: ResourceDescriptor = { + type: ResourceType.Tag, + nameParts: [tagName], + workspace: productDescriptor.workspace, + }; + if (!isAssociationAllowed(tagDescriptor, targetDescriptor, config, allowedDescriptors)) { + logger.warn( + `Skipping ProductTag association "${tagDescriptor.nameParts.join('/')}" because its target is excluded by the filter or unavailable in the publish set` + ); + continue; + } try { // The workspace ProductTag link references the product (productId), which @@ -274,3 +302,59 @@ async function publishProductTags( logger.info(`Published ${tagEntries.length} tags for product: ${productName}`); } + +function isDescriptorAllowed( + descriptor: ResourceDescriptor, + config: PublishConfig, + allowedDescriptors?: ResourceDescriptor[] +): boolean { + if (!allowedDescriptors) { + return true; + } + + if (allowedDescriptors.some((allowed) => sameResourceDescriptor(allowed, descriptor))) { + return true; + } + + // Incremental mode only diffs changed files; an unchanged product policy + // can still need republishing when its product does. + return config.commitId !== undefined; +} + +function isAssociationAllowed( + association: ResourceDescriptor, + target: ResourceDescriptor, + config: PublishConfig, + allowedDescriptors?: ResourceDescriptor[] +): boolean { + if (!allowedDescriptors) { + return true; + } + + const effectiveFilter = target.workspace + ? resolveWorkspaceFilter(target.workspace, config.filter) + : config.filter; + if (effectiveFilter && !shouldIncludeResource(target, effectiveFilter)) { + return false; + } + + if (allowedDescriptors.some((allowed) => sameResourceDescriptor(allowed, association))) { + return true; + } + + if (allowedDescriptors.some((allowed) => sameResourceDescriptor(allowed, target))) { + return true; + } + + // Incremental targets contain only changed resources. An unchanged target + // can still exist in APIM and remain explicitly allowed by the filter. + if (config.commitId) { + return true; + } + + if (hasExplicitTypeFilter(target.type, effectiveFilter)) { + return false; + } + + return true; +} diff --git a/src/services/publish-service.ts b/src/services/publish-service.ts index ed9604c9..4139d601 100644 --- a/src/services/publish-service.ts +++ b/src/services/publish-service.ts @@ -8,7 +8,7 @@ import { IApimClient } from '../clients/iapim-client.js'; import { IArtifactStore } from '../clients/iartifact-store.js'; -import { PublishConfig, OverrideConfig } from '../models/config.js'; +import { FilterConfig, PublishConfig, OverrideConfig } from '../models/config.js'; import { ApimServiceContext, ResourceDescriptor } from '../models/types.js'; import { ResourceType } from '../models/resource-types.js'; import { getResourceTier } from '../lib/dependency-graph.js'; @@ -17,7 +17,17 @@ import { logger } from '../lib/logger.js'; import { isAutoGeneratedId } from '../lib/auto-generated.js'; import { EXIT_SUCCESS, EXIT_PARTIAL, EXIT_FATAL } from '../lib/exit-codes.js'; import { buildResourceLabel } from '../lib/resource-uri.js'; -import { getNamePart, isChildType, isTopLevelSingleton } from '../lib/resource-path.js'; +import { + getNamePart, + getResourceDescriptorKey, + isChildType, + isTopLevelSingleton, +} from '../lib/resource-path.js'; +import { + extractRootApiName, + resolveWorkspaceFilter, + shouldIncludeResource, +} from './filter-service.js'; // Import from other agents' files (will be created in parallel) import { publishResource, ResourcePublishResult } from './resource-publisher.js'; @@ -29,6 +39,7 @@ import { computeGitDiff } from './git-diff-service.js'; import { scanForRedactionMarkers } from './secret-redaction-guard.js'; import { hasNamedValueOverride } from './override-merger.js'; import { REDACTION_MARKER } from './secret-redactor.js'; +import { scanArtifactReferences } from './transitive-resolver.js'; /** * The APIM Backend properties.type value that identifies a pool backend. @@ -205,24 +216,146 @@ async function determinePublishTargets( store: IArtifactStore, config: PublishConfig ): Promise { + let targetDescriptors: ResourceDescriptor[]; + let deletedDescriptors: ResourceDescriptor[]; + if (config.commitId) { // Incremental mode: use git diff logger.debug( `Using incremental publish mode with commit ID: ${config.commitId}` ); const diffResult = await computeGitDiff(config.sourceDir, config.commitId); - return { - targetDescriptors: diffResult.changedDescriptors, - deletedDescriptors: diffResult.deletedDescriptors, - }; + targetDescriptors = diffResult.changedDescriptors; + deletedDescriptors = diffResult.deletedDescriptors; } else { // Full mode: publish all artifacts logger.debug('Using full publish mode (all artifacts)'); - return { - targetDescriptors: await store.listResources(config.sourceDir), - deletedDescriptors: [], - }; + targetDescriptors = await store.listResources(config.sourceDir); + deletedDescriptors = []; + } + + targetDescriptors = filterPublishDescriptors(targetDescriptors, config.filter); + deletedDescriptors = filterPublishDescriptors(deletedDescriptors, config.filter); + + if (config.filter && config.includeTransitive !== false) { + const availableDescriptors = await store.listResources(config.sourceDir); + targetDescriptors = await expandTransitivePublishTargets( + store, + config.sourceDir, + targetDescriptors, + availableDescriptors + ); + } + + return { targetDescriptors, deletedDescriptors }; +} + +async function expandTransitivePublishTargets( + store: IArtifactStore, + sourceDir: string, + initialDescriptors: ResourceDescriptor[], + availableDescriptors: ResourceDescriptor[] +): Promise { + const availableByKey = new Map( + availableDescriptors.map((descriptor) => [getResourceDescriptorKey(descriptor), descriptor]) + ); + const expanded = [...initialDescriptors]; + const included = new Set(expanded.map(getResourceDescriptorKey)); + const scanQueue = [...initialDescriptors]; + const scanQueued = new Set(scanQueue.map(getResourceDescriptorKey)); + + // A changed API or product info file can be the only diff entry even when + // its policy/association artifacts contain the references to resolve. + for (const descriptor of initialDescriptors) { + if (descriptor.type !== ResourceType.Api && descriptor.type !== ResourceType.Product) { + continue; + } + const parentName = descriptor.type === ResourceType.Api + ? extractRootApiName(descriptor.nameParts[0] ?? '').toLowerCase() + : (descriptor.nameParts[0] ?? '').toLowerCase(); + for (const candidate of availableDescriptors) { + const isSameResourceType = candidate.type === descriptor.type; + const isOwnedChild = getParentType(candidate.type) === descriptor.type; + if ( + (!isSameResourceType && !isOwnedChild) || + candidate.workspace !== descriptor.workspace || + candidate.nameParts.length < 1 + ) { + continue; + } + const candidateName = candidate.type === ResourceType.Api + ? extractRootApiName(candidate.nameParts[0] ?? '').toLowerCase() + : (candidate.nameParts[0] ?? '').toLowerCase(); + if (candidateName === parentName && !scanQueued.has(getResourceDescriptorKey(candidate))) { + scanQueue.push(candidate); + scanQueued.add(getResourceDescriptorKey(candidate)); + } + } + } + + for (let index = 0; index < scanQueue.length; index++) { + let references: ResourceDescriptor[]; + try { + references = await scanArtifactReferences(store, sourceDir, scanQueue[index]); + } catch (error) { + logger.warn( + `Unable to scan transitive references for ${scanQueue[index].type} "${scanQueue[index].nameParts.join('/')}": ${String(error)}` + ); + continue; + } + for (const reference of references) { + const available = availableByKey.get(getResourceDescriptorKey(reference)); + if (available && !included.has(getResourceDescriptorKey(available))) { + included.add(getResourceDescriptorKey(available)); + expanded.push(available); + scanQueue.push(available); + } + } + } + + if (expanded.length > initialDescriptors.length) { + logger.debug( + `Transitive publish resolution added ${expanded.length - initialDescriptors.length} resources` + ); + } + return expanded; +} + +function filterPublishDescriptors( + descriptors: ResourceDescriptor[], + filter?: FilterConfig +): ResourceDescriptor[] { + if (!filter) { + return descriptors; + } + + return descriptors.filter((descriptor) => { + if (descriptor.type === ResourceType.Workspace) { + return matchesWorkspaceName(descriptor.nameParts[0] ?? '', filter.workspaces); + } + + if (!descriptor.workspace) { + return shouldIncludeResource(descriptor, filter); + } + + if (!matchesWorkspaceName(descriptor.workspace, filter.workspaces)) { + return false; + } + + const workspaceFilter = resolveWorkspaceFilter(descriptor.workspace, filter); + return shouldIncludeResource(descriptor, workspaceFilter ?? undefined); + }); +} + +function matchesWorkspaceName(name: string, allowlist: string[] | undefined): boolean { + if (allowlist === undefined) { + return true; } + + return shouldIncludeResource( + { type: ResourceType.Workspace, nameParts: [name] }, + { workspaces: allowlist } + ); } /** @@ -263,7 +396,7 @@ async function executePuts( if (!parentNamesByType.has(d.type)) { parentNamesByType.set(d.type, new Set()); } - parentNamesByType.get(d.type)!.add(getNamePart(d.nameParts, 0)); + parentNamesByType.get(d.type)!.add(parentScopeKey(d)); } } @@ -279,7 +412,7 @@ async function executePuts( if (workspaces.length > 0) { logger.debug(`Publishing ${workspaces.length} workspace container(s) first (wave 0 of tier 1)`); - await publishAndOutput(client, store, context, config, workspaces, results); + await publishAndOutput(client, store, context, config, workspaces, targetDescriptors, results); } // Within Tier 1 we publish in three ordered waves to satisfy implicit @@ -302,7 +435,7 @@ async function executePuts( if (namedValues.length > 0) { logger.debug(`Publishing ${namedValues.length} named value(s) first (wave 1 of tier 1)`); - await publishAndOutput(client, store, context, config, namedValues, results); + await publishAndOutput(client, store, context, config, namedValues, targetDescriptors, results); } const { poolBackends, regularTier1 } = await splitPoolBackends( @@ -310,7 +443,7 @@ async function executePuts( config.sourceDir, otherTier1 ); - await publishAndOutput(client, store, context, config, regularTier1, results); + await publishAndOutput(client, store, context, config, regularTier1, targetDescriptors, results); if (poolBackends.length > 0) { logger.debug( `Publishing ${poolBackends.length} pool backend(s) after regular backends` @@ -321,6 +454,7 @@ async function executePuts( context, config, poolBackends, + targetDescriptors, results ); } @@ -333,11 +467,11 @@ async function executePuts( tier2Descriptors ); - await publishAndOutput(client, store, context, config, regularTier2, results); + await publishAndOutput(client, store, context, config, regularTier2, targetDescriptors, results); if (mcpApis.length > 0) { logger.debug(`Publishing ${mcpApis.length} MCP API resource(s) after regular tier 2 resources`); - await publishAndOutput(client, store, context, config, mcpApis, results); + await publishAndOutput(client, store, context, config, mcpApis, targetDescriptors, results); } } else { // For tiers 3/4, exclude child resources whose parent is being published @@ -352,27 +486,67 @@ async function executePuts( if (!isChildType(d.type)) return true; // Child resources have the parent name as nameParts[0] - const owningParent = getNamePart(d.nameParts, 0); + const parentType = getParentType(d.type); + if (!parentType) return true; - // Skip if this parent name is being published in tier 2 - for (const parentNames of parentNamesByType.values()) { - if (parentNames.has(owningParent)) return false; - } - return true; + return !parentNamesByType.get(parentType)?.has(parentScopeKey(d)); }); const skipped = countBefore - tierDescriptors.length; if (skipped > 0) { logger.debug(`Skipping ${skipped} child resource(s) in tier ${tier} (handled by parent publisher)`); } + } - await publishAndOutput(client, store, context, config, tierDescriptors, results); + await publishAndOutput(client, store, context, config, tierDescriptors, targetDescriptors, results); } } return results; } +function parentScopeKey(descriptor: ResourceDescriptor): string { + return `${descriptor.workspace ?? ''}:${getNamePart(descriptor.nameParts, 0)}`.toLowerCase(); +} + +function getParentType(type: ResourceType): ResourceType | undefined { + if ( + [ + ResourceType.ApiPolicy, + ResourceType.ApiTag, + ResourceType.ApiDiagnostic, + ResourceType.ApiOperation, + ResourceType.ApiOperationPolicy, + ResourceType.ApiSchema, + ResourceType.ApiRelease, + ResourceType.ApiTagDescription, + ResourceType.ApiWiki, + ResourceType.GraphQLResolver, + ResourceType.GraphQLResolverPolicy, + ].includes(type) + ) { + return ResourceType.Api; + } + + if ( + [ + ResourceType.ProductPolicy, + ResourceType.ProductApi, + ResourceType.ProductGroup, + ResourceType.ProductTag, + ResourceType.ProductWiki, + ].includes(type) + ) { + return ResourceType.Product; + } + + if (type === ResourceType.GatewayApi) { + return ResourceType.Gateway; + } + + return undefined; +} + function splitWorkspaces( descriptors: ResourceDescriptor[] ): { workspaces: ResourceDescriptor[]; nonWorkspaceTier1: ResourceDescriptor[] } { @@ -399,10 +573,11 @@ async function publishAndOutput( context: ApimServiceContext, config: PublishConfig, descriptors: ResourceDescriptor[], + targetDescriptors: ResourceDescriptor[], results: PublishActionResult[] ): Promise { if (descriptors.length === 0) return; - const tierResults = await publishTier(client, store, context, config, descriptors); + const tierResults = await publishTier(client, store, context, config, descriptors, targetDescriptors); results.push(...tierResults); for (const result of tierResults) { outputActionStatus(result); @@ -513,7 +688,8 @@ async function publishTier( store: IArtifactStore, context: ApimServiceContext, config: PublishConfig, - descriptors: ResourceDescriptor[] + descriptors: ResourceDescriptor[], + allTargetDescriptors: ResourceDescriptor[] ): Promise { const tasks = descriptors.map((descriptor) => async () => { try { @@ -525,9 +701,18 @@ async function publishTier( // Use specialized publishers for Api and Product types if (descriptor.type === ResourceType.Api && !isApiRevision) { - publishResult = await publishApi(client, store, context, descriptor, config); + publishResult = await publishApi( + client, + store, + context, + descriptor, + config, + config.filter ? allTargetDescriptors : undefined + ); } else if (descriptor.type === ResourceType.Product) { - publishResult = await publishProduct(client, store, context, descriptor, config); + publishResult = config.filter + ? await publishProduct(client, store, context, descriptor, config, allTargetDescriptors) + : await publishProduct(client, store, context, descriptor, config); } else { publishResult = await publishResource( client, @@ -577,7 +762,7 @@ async function publishTier( function filterApiRevisionsHandledByRootApis( descriptors: ResourceDescriptor[] ): ResourceDescriptor[] { - const rootApiNames = new Set(); + const rootApiKeys = new Set(); for (const descriptor of descriptors) { if (descriptor.type !== ResourceType.Api) { continue; @@ -585,11 +770,11 @@ function filterApiRevisionsHandledByRootApis( const apiName = getNamePart(descriptor.nameParts, 0); if (!isApiRevisionName(apiName)) { - rootApiNames.add(apiName); + rootApiKeys.add(apiScopeKey(descriptor.workspace, apiName)); } } - if (rootApiNames.size === 0) { + if (rootApiKeys.size === 0) { return descriptors; } @@ -603,10 +788,14 @@ function filterApiRevisionsHandledByRootApis( return true; } - return !rootApiNames.has(getApiRootName(apiName)); + return !rootApiKeys.has(apiScopeKey(descriptor.workspace, getApiRootName(apiName))); }); } +function apiScopeKey(workspace: string | undefined, apiName: string): string { + return `${workspace ?? ''}:${apiName}`.toLowerCase(); +} + function isApiRevisionName(apiName: string): boolean { return apiName.includes(';rev='); } diff --git a/src/services/resource-publisher.ts b/src/services/resource-publisher.ts index f41d0473..773f6961 100644 --- a/src/services/resource-publisher.ts +++ b/src/services/resource-publisher.ts @@ -22,6 +22,8 @@ import { REDACTION_MARKER } from './secret-redactor.js'; import { isLinkAlreadyExistsError } from '../clients/apim-client.js'; import type { OverrideConfig } from '../models/config.js'; import { buildResourceLabel } from '../lib/resource-uri.js'; +import { hasExplicitTypeFilter, resolveWorkspaceFilter, shouldIncludeResource } from './filter-service.js'; +import { findSubscriptionTargets } from './transitive-resolver.js'; export interface ResourcePublishResult { descriptor: ResourceDescriptor; @@ -95,6 +97,24 @@ export async function publishResource( ); } + if (descriptor.type === ResourceType.ApiTag) { + const targetDescriptor: ResourceDescriptor = { + type: ResourceType.Tag, + nameParts: [getNamePart(descriptor.nameParts, 1)], + workspace: descriptor.workspace, + }; + if (!(await isTargetAllowedByFilter(store, targetDescriptor, config))) { + logger.warn( + `Skipping ApiTag association "${descriptor.nameParts.join('/')}" because its target is excluded by the filter or was never extracted` + ); + return { + descriptor, + status: 'skipped', + action: 'noop', + }; + } + } + // Handle workspace ApiTag — uses link endpoint with link payload. // In service scope, ApiTag is a regular PUT with the tag JSON; in workspace // scope it becomes a link resource at `tags/{tag}/apiLinks/{api}`. @@ -223,6 +243,17 @@ export async function publishResource( }; } + if (!(await areSubscriptionTargetsAllowed(store, json, descriptor, config))) { + logger.warn( + `Skipping subscription "${subscriptionName}" because its API or Product target is excluded by the filter or was never extracted` + ); + return { + descriptor, + status: 'skipped', + action: 'noop', + }; + } + json = normalizeSubscriptionScope(json, context); } @@ -309,7 +340,23 @@ async function publishAssociation( const assocDescriptor: ResourceDescriptor = { type: descriptor.type, nameParts: [getNamePart(descriptor.nameParts, 0), entry.name], + workspace: descriptor.workspace, + }; + const targetDescriptor: ResourceDescriptor = { + type: descriptor.type === ResourceType.GatewayApi + ? ResourceType.Api + : descriptor.type === ResourceType.ProductApi + ? ResourceType.Api + : ResourceType.Group, + nameParts: [entry.name], + workspace: entry.scope === 'service' ? undefined : descriptor.workspace, }; + if (!(await isTargetAllowedByFilter(store, targetDescriptor, config))) { + logger.warn( + `Skipping ${descriptor.type} association "${assocDescriptor.nameParts.join('/')}" because its target is excluded by the filter or was never extracted` + ); + continue; + } try { // PUT empty body for association (APIM uses PUT to create association) await client.putResource(context, assocDescriptor, {}); @@ -336,6 +383,45 @@ async function publishAssociation( } } +async function areSubscriptionTargetsAllowed( + store: IArtifactStore, + json: Record, + descriptor: ResourceDescriptor, + config: PublishConfig +): Promise { + for (const target of findSubscriptionTargets(json, descriptor.workspace)) { + if (!(await isTargetAllowedByFilter(store, target, config))) { + return false; + } + } + return true; +} + +async function isTargetAllowedByFilter( + store: IArtifactStore, + target: ResourceDescriptor, + config: PublishConfig +): Promise { + if (!config.filter) { + return true; + } + + const effectiveFilter = target.workspace + ? resolveWorkspaceFilter(target.workspace, config.filter) + : config.filter; + if (effectiveFilter && !shouldIncludeResource(target, effectiveFilter)) { + return false; + } + + // Explicitly scoped types must actually exist among the artifacts, not + // just pass the filter — catches dangling references (mirrors publishProduct). + if (hasExplicitTypeFilter(target.type, effectiveFilter)) { + return (await store.readResource(config.sourceDir, target)) !== undefined; + } + + return true; +} + /** * Publish wiki resource (ApiWiki, ProductWiki) */ diff --git a/src/services/transitive-extractor.ts b/src/services/transitive-extractor.ts new file mode 100644 index 00000000..4717d95f --- /dev/null +++ b/src/services/transitive-extractor.ts @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { IApimClient } from '../clients/iapim-client.js'; +import type { IArtifactStore } from '../clients/iartifact-store.js'; +import type { ApimServiceContext, ResourceDescriptor } from '../models/types.js'; +import { getResourceDescriptorKey } from '../lib/resource-path.js'; +import { buildResourceLabel } from '../lib/resource-uri.js'; +import { logger } from '../lib/logger.js'; +import { runParallel } from '../lib/parallel-runner.js'; +import { redactSecrets } from './secret-redactor.js'; +import { findTransitiveDependencies } from './transitive-resolver.js'; + +const DEFAULT_CONCURRENCY = 5; + +export interface TransitiveResourceArtifact { + descriptor: ResourceDescriptor; + json: Record; +} + +export interface TransitiveExtractionResult { + extractedDescriptors: ResourceDescriptor[]; + errorCount: number; +} + +export async function extractTransitiveDependencies( + client: IApimClient, + store: IArtifactStore, + context: ApimServiceContext, + outputDir: string, + policies: Map, + apis: Map>, + resources: TransitiveResourceArtifact[], + alreadyExtracted: ResourceDescriptor[], + workspace?: string, + serviceContext?: ApimServiceContext +): Promise { + const attempted = new Set(alreadyExtracted.map(getResourceDescriptorKey)); + const extractedDescriptors: ResourceDescriptor[] = []; + let errorCount = 0; + let foundDependencies = false; + + while (true) { + const newDeps = findTransitiveDependencies( + policies, + apis, + workspace, + resources + ).filter((dep) => !attempted.has(getResourceDescriptorKey(dep))); + + if (newDeps.length === 0) { + if (!foundDependencies) { + logger.debug('No additional transitive dependencies found'); + } + return { extractedDescriptors, errorCount }; + } + + foundDependencies = true; + logger.info(`Found ${newDeps.length} transitive dependencies to extract`); + for (const dep of newDeps) { + attempted.add(getResourceDescriptorKey(dep)); + } + + const tasks = newDeps.map((dep) => async () => { + try { + const dependencyContext = + serviceContext && dep.workspace !== workspace ? serviceContext : context; + const json = await client.getResource(dependencyContext, dep); + if (json) { + const safeJson = redactSecrets(dep, json); + await store.writeResource(outputDir, dep, safeJson); + logger.info(`Extracted transitive dependency ${buildResourceLabel(dep)}`); + return { dep, json: safeJson }; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.warn( + `Failed to extract transitive dependency ${buildResourceLabel(dep)}: ${message}` + ); + } + return { dep }; + }); + + const taskResults = await runParallel(tasks, DEFAULT_CONCURRENCY); + for (const taskResult of taskResults) { + const value = taskResult.status === 'fulfilled' ? taskResult.value : undefined; + if (!value?.json) { + errorCount++; + continue; + } + + extractedDescriptors.push(value.dep); + resources.push({ + descriptor: value.dep, + json: value.json, + }); + } + } +} diff --git a/src/services/transitive-resolver.ts b/src/services/transitive-resolver.ts index d0b4d4cc..51f961b8 100644 --- a/src/services/transitive-resolver.ts +++ b/src/services/transitive-resolver.ts @@ -8,9 +8,11 @@ */ import { FilterConfig } from '../models/config.js'; -import { ResourceType } from '../models/resource-types.js'; +import { ResourceType, RESOURCE_TYPE_METADATA } from '../models/resource-types.js'; import { ResourceDescriptor } from '../models/types.js'; +import type { IArtifactStore } from '../clients/iartifact-store.js'; import { logger } from '../lib/logger.js'; +import { getResourceDescriptorKey } from '../lib/resource-path.js'; /** * Reference detection patterns for policy XML content. @@ -27,6 +29,14 @@ export interface TransitiveDependency { name: string; } +const POLICY_RESOURCE_TYPES = new Set([ + ResourceType.ServicePolicy, + ResourceType.ApiPolicy, + ResourceType.ApiOperationPolicy, + ResourceType.ProductPolicy, + ResourceType.GraphQLResolverPolicy, +]); + /** * Scan policy XML content for references to other resources. * @@ -87,10 +97,7 @@ export function scanApiVersionSetReference( return undefined; } - // Extract version set name from ARM resource ID - // Format: /subscriptions/.../apiVersionSets/{name} - const parts = versionSetId.split('/'); - const name = parts[parts.length - 1]; + const name = extractResourceNameFromId(versionSetId, 'apiVersionSets'); if (!name) { return undefined; } @@ -196,33 +203,177 @@ function addToFilter( */ export function findTransitiveDependencies( policies: Map, - apis: Map> + apis: Map>, + workspace?: string, + resources: ReadonlyArray<{ + descriptor: ResourceDescriptor; + json: Record; + }> = [] ): ResourceDescriptor[] { const dependencies: ResourceDescriptor[] = []; - const seen = new Set(); - // Scan all policies for (const [, policyXml] of policies) { for (const dep of scanPolicyReferences(policyXml)) { - const key = `${dep.type}:${dep.name.toLowerCase()}`; - if (!seen.has(key)) { - seen.add(key); - dependencies.push({ type: dep.type, nameParts: [dep.name] }); - } + dependencies.push({ type: dep.type, nameParts: [dep.name], workspace }); } } - // Scan API version set references for (const [, apiJson] of apis) { const dep = scanApiVersionSetReference(apiJson); if (dep) { - const key = `${dep.type}:${dep.name.toLowerCase()}`; - if (!seen.has(key)) { - seen.add(key); - dependencies.push({ type: dep.type, nameParts: [dep.name] }); + dependencies.push({ type: dep.type, nameParts: [dep.name], workspace }); + } + } + + for (const { descriptor, json } of resources) { + const properties = json.properties as Record | undefined; + + if (descriptor.type === ResourceType.Backend) { + const pool = isRecord(properties?.pool) ? properties.pool : undefined; + const services = pool?.services; + if (Array.isArray(services)) { + for (const service of services) { + if (isRecord(service) && typeof service.id === 'string') { + const name = extractResourceNameFromId(service.id, 'backends'); + if (name) { + dependencies.push({ + type: ResourceType.Backend, + nameParts: [name], + workspace: workspaceFromReference(service.id, descriptor.workspace), + }); + } + } + } + } + } + + if (descriptor.type === ResourceType.PolicyFragment) { + for (const value of [properties?.value, properties?.policyContent]) { + if (typeof value !== 'string') { + continue; + } + for (const dep of scanPolicyReferences(value)) { + dependencies.push({ + type: dep.type, + nameParts: [dep.name], + workspace: descriptor.workspace, + }); + } } } } - return dependencies; + return deduplicateDescriptors(dependencies); +} + +/** + * Read intrinsic dependencies from one on-disk artifact. + * + * Association and subscription targets are links to independently selected + * composite resources, not transitive dependencies. + */ +export async function scanArtifactReferences( + store: IArtifactStore, + sourceDir: string, + descriptor: ResourceDescriptor +): Promise { + const references: ResourceDescriptor[] = []; + const policies = new Map(); + const apis = new Map>(); + + if (POLICY_RESOURCE_TYPES.has(descriptor.type)) { + const content = await store.readContent(sourceDir, descriptor, 'policy'); + if (content) { + policies.set(descriptor.nameParts.join('/'), content.content); + } + } + + const infoFile = RESOURCE_TYPE_METADATA[descriptor.type]?.infoFile; + const json = POLICY_RESOURCE_TYPES.has(descriptor.type) || !infoFile?.endsWith('.json') + ? undefined + : await store.readResource(sourceDir, descriptor); + if (json) { + if (descriptor.type === ResourceType.Api) { + apis.set(descriptor.nameParts.join('/'), json); + } + + } + + references.push( + ...findTransitiveDependencies( + policies, + apis, + descriptor.workspace, + json ? [{ descriptor, json }] : [] + ) + ); + + return deduplicateDescriptors(references); +} + +/** + * Find API or Product targets referenced by a subscription payload. + * + * These targets are used to gate link publication; they must not be fed into + * transitive expansion because APIs and Products are composite resources. + */ +export function findSubscriptionTargets( + json: Record, + workspace?: string +): ResourceDescriptor[] { + const references: ResourceDescriptor[] = []; + const properties = json.properties as Record | undefined; + for (const value of [properties?.scope, properties?.apiId]) { + if (typeof value !== 'string') { + continue; + } + for (const [segment, type] of [ + ['apis', ResourceType.Api], + ['products', ResourceType.Product], + ] as const) { + const name = extractResourceNameFromId(value, segment); + if (name) { + references.push({ + type, + nameParts: [name], + workspace: workspaceFromReference(value, workspace), + }); + } + } + } + + return deduplicateDescriptors(references); +} + +function extractResourceNameFromId(value: string, segment: string): string | undefined { + const match = value.match(new RegExp(`(?:^|/)${segment}/([^/]+)(?:/|$)`, 'i')); + return match?.[1] ? decodeArmSegment(match[1]) : undefined; +} + +function workspaceFromReference(value: string, fallback?: string): string | undefined { + const match = value.match(/\/workspaces\/([^/]+)/i); + return match?.[1] ? decodeArmSegment(match[1]) : (value.startsWith('/') ? undefined : fallback); +} + +function decodeArmSegment(value: string): string { + try { + return decodeURIComponent(value); + } catch (error) { + logger.warn(`Unable to decode ARM resource ID segment; using the raw value: ${String(error)}`); + return value; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function deduplicateDescriptors(descriptors: ResourceDescriptor[]): ResourceDescriptor[] { + const seen = new Set(); + return descriptors.filter((descriptor) => { + const key = getResourceDescriptorKey(descriptor); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); } diff --git a/src/services/workspace-extractor.ts b/src/services/workspace-extractor.ts index b3aa3d9e..4118f75d 100644 --- a/src/services/workspace-extractor.ts +++ b/src/services/workspace-extractor.ts @@ -7,15 +7,25 @@ */ import { IApimClient } from '../clients/iapim-client.js'; import { IArtifactStore } from '../clients/iartifact-store.js'; -import { ApimServiceContext } from '../models/types.js'; +import { ApimServiceContext, ResourceDescriptor } from '../models/types.js'; import { ResourceType, RESOURCE_TYPE_METADATA } from '../models/resource-types.js'; -import { FilterConfig, WorkspaceSubFilter } from '../models/config.js'; +import { FilterConfig } from '../models/config.js'; import { extractResourceType, ExtractedResource } from './resource-extractor.js'; import { extractApiResources, extractWorkspaceApiTags } from './api-extractor.js'; import { extractProductResources, extractWorkspaceProductTags } from './product-extractor.js'; import { logger } from '../lib/logger.js'; import { getNamePart } from '../lib/resource-path.js'; -import { isWildcardPattern, wildcardMatch } from './filter-service.js'; +import { + isWildcardPattern, + resolveWorkspaceFilter, + shouldIncludeResource, +} from './filter-service.js'; +import { + extractTransitiveDependencies, + type TransitiveResourceArtifact, +} from './transitive-extractor.js'; + +export { resolveWorkspaceFilter }; /** * Types that can exist at the workspace level, derived from RESOURCE_TYPE_METADATA. @@ -47,7 +57,8 @@ export async function extractWorkspaces( store: IArtifactStore, context: ApimServiceContext, outputDir: string, - filter?: FilterConfig + filter?: FilterConfig, + includeTransitive: boolean = false ): Promise { const results: WorkspaceExtractionResult[] = []; let workspaceNames: string[]; @@ -60,21 +71,23 @@ export async function extractWorkspaces( return results; } - const hasWildcards = filter.workspaces.some(isWildcardPattern); - if (hasWildcards) { - // Wildcard patterns require discovery so we can match against real names + const requiresDiscovery = filter.workspaces.some( + (entry) => isWildcardPattern(entry) || entry.startsWith('!') + ); + if (requiresDiscovery) { + // Wildcards and exclusions require discovery so shared filter semantics + // can be applied against actual workspace names. const discovered = await discoverWorkspaceNames(client, context); workspaceNames = discovered.filter((name) => - filter.workspaces!.some((pattern) => - isWildcardPattern(pattern) - ? wildcardMatch(pattern, name) - : pattern.toLowerCase() === name.toLowerCase() + shouldIncludeResource( + { type: ResourceType.Workspace, nameParts: [name] }, + { workspaces: filter.workspaces } ) ); // Warn about exact (non-wildcard) entries that didn't match any discovered workspace for (const entry of filter.workspaces) { - if (!isWildcardPattern(entry)) { + if (!entry.startsWith('!') && !isWildcardPattern(entry)) { const matched = discovered.some((d) => d.toLowerCase() === entry.toLowerCase()); if (!matched) { logger.warn(`Workspace filter entry "${entry}" did not match any discovered workspace`); @@ -111,7 +124,8 @@ export async function extractWorkspaces( const wsResult = await extractWorkspace( client, store, context, wsName, outputDir, - resolveWorkspaceFilter(wsName, filter) + resolveWorkspaceFilter(wsName, filter), + includeTransitive ); results.push(wsResult); } @@ -125,7 +139,8 @@ async function extractWorkspace( context: ApimServiceContext, workspaceName: string, outputDir: string, - filter?: FilterConfig + filter?: FilterConfig, + includeTransitive: boolean = false ): Promise { logger.info(`Extracting workspace "${workspaceName}"...`); @@ -143,6 +158,10 @@ async function extractWorkspace( let extractedTagNames: string[] = []; const extractedApiNames = new Set(); let extractedProducts: ExtractedResource[] = []; + const extractedDescriptors: ResourceDescriptor[] = []; + const resources: TransitiveResourceArtifact[] = []; + const policies = new Map(); + const apis = new Map>(); for (const type of WORKSPACE_SUPPORTED_TYPES) { try { @@ -152,6 +171,15 @@ async function extractWorkspace( ); resourceCount += result.extracted.filter((r) => r.status === 'success').length; errorCount += result.errorCount; + for (const extracted of result.extracted) { + if (extracted.status === 'success') { + extractedDescriptors.push(extracted.descriptor); + resources.push({ + descriptor: extracted.descriptor, + json: extracted.json, + }); + } + } // Track extracted tags for later ApiTag/ProductTag extraction if (type === ResourceType.Tag) { @@ -170,6 +198,14 @@ async function extractWorkspace( client, store, wsContext, api.descriptor, api.json, outputDir, filter, workspaceName ); + const apiName = getNamePart(api.descriptor.nameParts, 0); + apis.set(apiName, api.json); + for (let index = 0; index < apiResult.policies.length; index++) { + const policy = apiResult.policies[index]; + if (policy !== undefined) { + policies.set(`api:${apiName}:policy:${index}`, policy); + } + } resourceCount += apiResult.operations.length + apiResult.tags.length + apiResult.schemas.length; @@ -185,10 +221,17 @@ async function extractWorkspace( extractedProducts = result.extracted.filter((r) => r.status === 'success'); for (const product of extractedProducts) { try { - await extractProductResources( + const productResult = await extractProductResources( client, store, wsContext, product.descriptor, outputDir, filter, workspaceName ); + const productName = getNamePart(product.descriptor.nameParts, 0); + for (let index = 0; index < productResult.policies.length; index++) { + const policy = productResult.policies[index]; + if (policy !== undefined) { + policies.set(`product:${productName}:policy:${index}`, policy); + } + } resourceCount++; } catch (error) { logger.warn(`Failed to extract product details for workspace "${workspaceName}": ${(error as Error).message}`); @@ -233,6 +276,24 @@ async function extractWorkspace( } } + if (includeTransitive && filter) { + logger.info(`Resolving transitive dependencies for workspace "${workspaceName}"...`); + const transitiveResult = await extractTransitiveDependencies( + client, + store, + wsContext, + outputDir, + policies, + apis, + resources, + extractedDescriptors, + workspaceName, + context + ); + resourceCount += transitiveResult.extractedDescriptors.length; + errorCount += transitiveResult.errorCount; + } + logger.info(`Workspace "${workspaceName}": extracted ${resourceCount} resources, ${errorCount} errors`); return { workspaceName, resourceCount, errorCount }; @@ -254,52 +315,3 @@ async function discoverWorkspaceNames( } return names; } - -/** - * Resolve the effective FilterConfig for a workspace. - * If the workspace has a sub-filter in workspaceSubFilters, convert it to a FilterConfig. - * Otherwise return undefined (no filter = extract everything in the workspace). - */ -export function resolveWorkspaceFilter( - workspaceName: string, - filter?: FilterConfig -): FilterConfig | undefined { - if (!filter?.workspaceSubFilters) { - return undefined; - } - - // Case-insensitive lookup of workspace sub-filter - const lowerName = workspaceName.toLowerCase(); - const matchingKey = Object.keys(filter.workspaceSubFilters).find( - (k) => k.toLowerCase() === lowerName - ); - - if (!matchingKey) { - return undefined; - } - - const sub = filter.workspaceSubFilters[matchingKey]; - return workspaceSubFilterToFilterConfig(sub); -} - -/** - * Convert a WorkspaceSubFilter to a FilterConfig so the standard - * filter-service matching logic can be applied to workspace-scoped resources. - */ -function workspaceSubFilterToFilterConfig(sub: WorkspaceSubFilter): FilterConfig { - return { - apis: sub.apis, - apiSubFilters: sub.apiSubFilters, - backends: sub.backends, - diagnostics: sub.diagnostics, - groups: sub.groups, - loggers: sub.loggers, - namedValues: sub.namedValues, - policyFragments: sub.policyFragments, - products: sub.products, - schemas: sub.schemas, - subscriptions: sub.subscriptions, - tags: sub.tags, - versionSets: sub.versionSets, - }; -} diff --git a/tests/unit/cli/publish-command.test.ts b/tests/unit/cli/publish-command.test.ts index ff7c1346..abf2ad02 100644 --- a/tests/unit/cli/publish-command.test.ts +++ b/tests/unit/cli/publish-command.test.ts @@ -48,6 +48,18 @@ describe('publish-command', () => { expect(overridesOpt).toBeDefined(); }); + it('should have --filter option', () => { + const cmd = createPublishCommand(); + expect(cmd.options.find((o) => o.long === '--filter')).toBeDefined(); + }); + + it('should have a negated --no-transitive flag', () => { + const cmd = createPublishCommand(); + const transitiveOpt = cmd.options.find((o) => o.long === '--no-transitive'); + expect(transitiveOpt).toBeDefined(); + expect(transitiveOpt?.negate).toBe(true); + }); + it('should have --commit-id option', () => { const cmd = createPublishCommand(); const opts = cmd.options; @@ -137,5 +149,13 @@ describe('publish-command', () => { it('should allow commit-id incremental mode without delete-unmatched', () => { expect(hasMutuallyExclusivePublishOptions(false, 'abc123')).toBe(false); }); + + it('should reject filter with delete-unmatched', () => { + expect(hasMutuallyExclusivePublishOptions(true, undefined, true)).toBe(true); + }); + + it('should allow filter without delete-unmatched', () => { + expect(hasMutuallyExclusivePublishOptions(false, undefined, true)).toBe(false); + }); }); }); diff --git a/tests/unit/lib/resource-path.test.ts b/tests/unit/lib/resource-path.test.ts index 5ba3899b..174e0f21 100644 --- a/tests/unit/lib/resource-path.test.ts +++ b/tests/unit/lib/resource-path.test.ts @@ -9,6 +9,8 @@ import { parseTemplatePath, getNamePart, getNameFromNameParts, + getResourceDescriptorKey, + sameResourceDescriptor, buildArtifactDirectory, buildArtifactFilePath, buildPolicyFilePath, @@ -650,6 +652,36 @@ describe('getNamePart', () => { }); }); +describe('resource descriptor identity', () => { + const descriptor: ResourceDescriptor = { + type: ResourceType.ApiOperation, + nameParts: ['Orders', 'Get-Order'], + workspace: 'Team-A', + }; + + it('creates a case-insensitive key containing type, workspace, and name parts', () => { + expect(getResourceDescriptorKey(descriptor)).toBe( + 'apioperation:team-a:orders/get-order' + ); + }); + + it('matches equivalent descriptors case-insensitively without crossing scopes', () => { + expect( + sameResourceDescriptor(descriptor, { + type: ResourceType.ApiOperation, + nameParts: ['orders', 'get-order'], + workspace: 'team-a', + }) + ).toBe(true); + expect( + sameResourceDescriptor(descriptor, { + type: ResourceType.ApiOperation, + nameParts: ['orders', 'get-order'], + }) + ).toBe(false); + }); +}); + describe('getNameFromNameParts', () => { it('returns the last element for a 1-part array', () => { expect(getNameFromNameParts(['petstore'])).toBe('petstore'); diff --git a/tests/unit/services/api-publisher.test.ts b/tests/unit/services/api-publisher.test.ts index ea9d4df6..a5cdd469 100644 --- a/tests/unit/services/api-publisher.test.ts +++ b/tests/unit/services/api-publisher.test.ts @@ -94,6 +94,224 @@ describe('api-publisher', () => { }); describe('publishApi', () => { + it('should publish only API children included in the filtered target set', async () => { + const client = createMockClient(); + const operation: ResourceDescriptor = { + type: ResourceType.ApiOperation, + nameParts: ['orders-api', 'get-orders'], + }; + const diagnostic: ResourceDescriptor = { + type: ResourceType.ApiDiagnostic, + nameParts: ['orders-api', 'application-insights'], + }; + const store = createMockStore([operation, diagnostic]); + const apiDescriptor: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['orders-api'], + }; + + await publishApi(client, store, testContext, apiDescriptor, testConfig, [ + apiDescriptor, + operation, + ]); + + const publishedTasks = mockRunParallel.mock.calls.reduce((sum, call) => { + return sum + (call[0] as unknown[]).length; + }, 0); + expect(publishedTasks).toBe(1); + }); + + it('should not import a full specification when filtered children are excluded', async () => { + const client = createMockClient(); + const operation: ResourceDescriptor = { + type: ResourceType.ApiOperation, + nameParts: ['orders-api', 'get-orders'], + }; + const schema: ResourceDescriptor = { + type: ResourceType.ApiSchema, + nameParts: ['orders-api', 'order-schema'], + }; + const store = createMockStore([operation, schema]); + store.readContent.mockResolvedValue({ content: 'openapi: 3.0.0', format: 'yaml' }); + const apiDescriptor: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['orders-api'], + }; + + await publishApi(client, store, testContext, apiDescriptor, testConfig, [ + apiDescriptor, + operation, + ]); + + expect(client.putResource).toHaveBeenCalledWith( + testContext, + apiDescriptor, + expect.not.objectContaining({ + properties: expect.objectContaining({ format: expect.anything() }), + }) + ); + }); + + it('should retain specification import when filtered APIs have no child artifacts', async () => { + const client = createMockClient(); + const store = createMockStore([]); + store.readContent.mockResolvedValue({ content: 'openapi: 3.0.0', format: 'yaml' }); + const apiDescriptor: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['orders-api'], + }; + + await publishApi(client, store, testContext, apiDescriptor, testConfig, [apiDescriptor]); + + expect(client.putResource).toHaveBeenCalledWith( + testContext, + apiDescriptor, + expect.objectContaining({ + properties: expect.objectContaining({ format: 'openapi' }), + }) + ); + }); + + it('should restore filter-eligible children after an incremental specification import', async () => { + const client = createMockClient(); + const operation: ResourceDescriptor = { + type: ResourceType.ApiOperation, + nameParts: ['orders-api', 'get-orders'], + }; + const schema: ResourceDescriptor = { + type: ResourceType.ApiSchema, + nameParts: ['orders-api', 'order-schema'], + }; + const operationPolicy: ResourceDescriptor = { + type: ResourceType.ApiOperationPolicy, + nameParts: ['orders-api', 'get-orders'], + }; + const store = createMockStore([operation, schema, operationPolicy]); + store.readContent.mockResolvedValue({ content: 'openapi: 3.0.0', format: 'yaml' }); + const apiDescriptor: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['orders-api'], + }; + const config: PublishConfig = { + ...testConfig, + commitId: 'abc123', + filter: { apis: ['orders-api'] }, + }; + + await publishApi(client, store, testContext, apiDescriptor, config, [apiDescriptor]); + + const totalTasks = mockRunParallel.mock.calls.reduce((sum, call) => { + return sum + (call[0] as unknown[]).length; + }, 0); + expect(totalTasks).toBe(3); + }); + + it('should republish an unchanged, filter-eligible child when its API changes in incremental mode', async () => { + const client = createMockClient(); + const tag: ResourceDescriptor = { + type: ResourceType.ApiTag, + nameParts: ['orders-api', 'production'], + }; + const store = createMockStore([tag]); + const apiDescriptor: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['orders-api'], + }; + const config: PublishConfig = { + ...testConfig, + commitId: 'abc123', + filter: { apis: ['orders-api'] }, + }; + + // Only the API itself is in the incremental diff/expansion set — the + // tag association did not change in this commit. + await publishApi(client, store, testContext, apiDescriptor, config, [apiDescriptor]); + + const publishedTasks = mockRunParallel.mock.calls.reduce((sum, call) => { + return sum + (call[0] as unknown[]).length; + }, 0); + expect(publishedTasks).toBe(1); + }); + + it('should not republish a child excluded by an API sub-filter in incremental mode', async () => { + const client = createMockClient(); + const diagnostic: ResourceDescriptor = { + type: ResourceType.ApiDiagnostic, + nameParts: ['orders-api', 'application-insights'], + }; + const store = createMockStore([diagnostic]); + const apiDescriptor: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['orders-api'], + }; + const config: PublishConfig = { + ...testConfig, + commitId: 'abc123', + filter: { + apis: ['orders-api'], + apiSubFilters: { 'orders-api': { diagnostics: [] } }, + }, + }; + + await publishApi(client, store, testContext, apiDescriptor, config, [apiDescriptor]); + + const publishedTasks = mockRunParallel.mock.calls.reduce((sum, call) => { + return sum + (call[0] as unknown[]).length; + }, 0); + expect(publishedTasks).toBe(0); + }); + + it('should republish an unchanged API revision when the root API changes in incremental mode', async () => { + const client = createMockClient(); + const revision: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['orders-api;rev=2'], + }; + const store = createMockStore([revision]); + const apiDescriptor: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['orders-api'], + }; + const config: PublishConfig = { + ...testConfig, + commitId: 'abc123', + filter: { apis: ['orders-api'] }, + }; + + // Only the root API is in the incremental diff/expansion set — the + // revision itself did not change in this commit. + await publishApi(client, store, testContext, apiDescriptor, config, [apiDescriptor]); + + expect(mockPublishResource).toHaveBeenCalledTimes(1); + expect(mockPublishResource.mock.calls[0][3]).toEqual(revision); + }); + + it('should ignore managed children from other workspaces when deciding specification import', async () => { + const client = createMockClient(); + const otherWorkspaceSchema: ResourceDescriptor = { + type: ResourceType.ApiSchema, + nameParts: ['orders-api', 'other-schema'], + workspace: 'other', + }; + const store = createMockStore([otherWorkspaceSchema]); + store.readContent.mockResolvedValue({ content: 'openapi: 3.0.0', format: 'yaml' }); + const apiDescriptor: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['orders-api'], + workspace: 'current', + }; + + await publishApi(client, store, testContext, apiDescriptor, testConfig, [apiDescriptor]); + + expect(client.putResource).toHaveBeenCalledWith( + testContext, + apiDescriptor, + expect.objectContaining({ + properties: expect.objectContaining({ format: 'openapi' }), + }) + ); + }); + it('should publish root API first', async () => { const client = createMockClient(); const store = createMockStore([]); @@ -413,6 +631,45 @@ describe('api-publisher', () => { expect(mockPublishResource.mock.calls[0][3].nameParts[0]).toBe('orders-api;rev=2'); }); + it('should publish only allowed revisions from the same workspace', async () => { + const client = createMockClient(); + const allowedRevision: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['orders-api;rev=2'], + workspace: 'team-a', + }; + const revisions: ResourceDescriptor[] = [ + allowedRevision, + { + type: ResourceType.Api, + nameParts: ['orders-api;rev=3'], + workspace: 'team-a', + }, + { + type: ResourceType.Api, + nameParts: ['orders-api;rev=4'], + }, + ]; + const store = createMockStore(revisions); + const apiDescriptor: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['orders-api'], + workspace: 'team-a', + }; + + await publishApi( + client, + store, + testContext, + apiDescriptor, + { ...testConfig, filter: { workspaces: ['team-a'] } }, + [apiDescriptor, allowedRevision] + ); + + expect(mockPublishResource).toHaveBeenCalledTimes(1); + expect(mockPublishResource.mock.calls[0][3]).toEqual(allowedRevision); + }); + it('should publish API child resources in parallel', async () => { const client = createMockClient(); const children = [ diff --git a/tests/unit/services/extract-service.test.ts b/tests/unit/services/extract-service.test.ts index 3aeccc42..5f08f2d3 100644 --- a/tests/unit/services/extract-service.test.ts +++ b/tests/unit/services/extract-service.test.ts @@ -518,6 +518,78 @@ describe('extract-service', () => { expect(result.exitCode).toBe(0); }); + it('should extract backend pool members and policy fragment dependencies transitively', async () => { + const backendId = + '/subscriptions/s/resourceGroups/r/providers/Microsoft.ApiManagement/service/a/backends/member'; + const client = createMockClient({ + [ResourceType.Backend]: [ + { + name: 'pool', + properties: { + type: 'Pool', + pool: { services: [{ id: backendId }] }, + }, + }, + ], + [ResourceType.PolicyFragment]: [ + { + name: 'shared-fragment', + properties: { + value: '{{shared-secret}}', + }, + }, + ], + }); + client.getResource = vi.fn().mockImplementation(async (_ctx, descriptor) => { + if (descriptor.type === ResourceType.Backend && descriptor.nameParts[0] === 'member') { + return { name: 'member', properties: {} }; + } + if ( + descriptor.type === ResourceType.NamedValue && + descriptor.nameParts[0] === 'shared-secret' + ) { + return { name: 'shared-secret', properties: { secret: true, value: 'secret' } }; + } + return undefined; + }); + const store = createMockStore(); + + const result = await runExtraction(client, store, { + service: testContext, + outputDir: '/output', + includeTransitive: true, + filter: { + apis: [], + backends: ['pool'], + namedValues: [], + policyFragments: ['shared-fragment'], + }, + logLevel: LogLevel.INFO, + }); + + expect(result.exitCode).toBe(0); + expect(store.writeResource).toHaveBeenCalledWith( + '/output', + expect.objectContaining({ + type: ResourceType.Backend, + nameParts: ['member'], + }), + expect.anything() + ); + expect(store.writeResource).toHaveBeenCalledWith( + '/output', + expect.objectContaining({ + type: ResourceType.NamedValue, + nameParts: ['shared-secret'], + }), + expect.objectContaining({ + properties: expect.objectContaining({ + value: REDACTION_MARKER, + }), + }) + ); + }); + it('should handle transitive dependency not found (getResource returns null)', async () => { const client = createMockClient({}); diff --git a/tests/unit/services/product-publisher.test.ts b/tests/unit/services/product-publisher.test.ts index a54ad722..8145738c 100644 --- a/tests/unit/services/product-publisher.test.ts +++ b/tests/unit/services/product-publisher.test.ts @@ -178,6 +178,94 @@ describe('product-publisher', () => { ); }); + it('does not let resolved targets override explicit API exclusions', async () => { + const client = createMockClient(); + const store = createMockStore(); + store.readAssociation + .mockResolvedValueOnce([{ name: 'legacy-api' }]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + const legacyApi: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['legacy-api'], + }; + const config: PublishConfig = { + ...testConfig, + filter: { + products: ['my-product'], + apis: ['!legacy-api', '*'], + }, + }; + + await publishProduct( + client, + store, + testContext, + productDescriptor, + config, + [productDescriptor, legacyApi] + ); + + expect(client.putResource).not.toHaveBeenCalled(); + }); + + it('publishes links to unchanged targets allowed during incremental publish', async () => { + const client = createMockClient(); + const store = createMockStore(); + store.readAssociation + .mockResolvedValueOnce([{ name: 'orders-api' }]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + const config: PublishConfig = { + ...testConfig, + commitId: 'abc123', + filter: { + products: ['my-product'], + apis: ['orders-api'], + }, + }; + + await publishProduct( + client, + store, + testContext, + productDescriptor, + config, + [productDescriptor] + ); + + expect(client.putResource).toHaveBeenCalledWith( + testContext, + expect.objectContaining({ + type: ResourceType.ProductApi, + nameParts: ['my-product', 'orders-api'], + }), + {} + ); + }); + + it('republishes an unchanged product policy when the product changes in incremental mode', async () => { + const client = createMockClient(); + const store = createMockStore(); + store.readAssociation.mockResolvedValue([]); + store.readContent.mockResolvedValue({ content: '', format: 'xml' }); + const config: PublishConfig = { + ...testConfig, + commitId: 'abc123', + filter: { products: ['my-product'] }, + }; + + // Only the product itself is in the incremental diff/expansion set — + // policy.xml did not change in this commit. + await publishProduct(client, store, testContext, productDescriptor, config, [productDescriptor]); + + expect(mockPublishResource).toHaveBeenCalledWith( + client, store, testContext, + expect.objectContaining({ type: ResourceType.ProductPolicy, nameParts: ['my-product'] }), + config + ); + }); + it('groups association: calls putResource with ProductGroup descriptor', async () => { const client = createMockClient(); const store = createMockStore(); diff --git a/tests/unit/services/publish-service.test.ts b/tests/unit/services/publish-service.test.ts index 2f842263..ff615e23 100644 --- a/tests/unit/services/publish-service.test.ts +++ b/tests/unit/services/publish-service.test.ts @@ -137,6 +137,345 @@ describe('publish-service', () => { expect(result.totalErrors).toBe(0); }); + it('should publish only resources matched by a filter', async () => { + const resources = [ + { type: ResourceType.NamedValue, nameParts: ['keep'] }, + { type: ResourceType.NamedValue, nameParts: ['skip'] }, + ]; + const client = createMockClient(); + const store = createMockStore(resources); + + const result = await runPublish(client, store, { + service: testContext, + sourceDir: '/source', + filter: { namedValues: ['keep'] }, + includeTransitive: false, + dryRun: false, + deleteUnmatched: false, + logLevel: LogLevel.INFO, + }); + + expect(result.totalPuts).toBe(1); + expect(client.putResource).toHaveBeenCalledTimes(1); + expect(client.putResource.mock.calls[0]?.[1].nameParts).toEqual(['keep']); + }); + + it('should include an artifact-backed version set transitively', async () => { + const resources = [ + { type: ResourceType.Api, nameParts: ['orders'] }, + { type: ResourceType.VersionSet, nameParts: ['orders-v1'] }, + ]; + const client = createMockClient(); + const store = createMockStore(resources); + store.readResource.mockImplementation(async (_sourceDir: string, descriptor: ResourceDescriptor) => { + if (descriptor.type === ResourceType.Api) { + return { + name: 'orders', + properties: { + apiVersionSetId: '/subscriptions/s/resourceGroups/r/providers/Microsoft.ApiManagement/service/a/apiVersionSets/orders-v1', + }, + }; + } + return { name: descriptor.nameParts[0] ?? '', properties: {} }; + }); + + const result = await runPublish(client, store, { + service: testContext, + sourceDir: '/source', + filter: { apis: ['orders'], versionSets: [] }, + dryRun: false, + deleteUnmatched: false, + logLevel: LogLevel.INFO, + }); + + expect(result.totalPuts).toBe(2); + expect(client.putResource.mock.calls.some((call) => + call[1].type === ResourceType.VersionSet && + call[1].nameParts[0] === 'orders-v1' + )).toBe(true); + }); + + it('should include every dependency type supported by extract', async () => { + const resources: ResourceDescriptor[] = [ + { type: ResourceType.Api, nameParts: ['orders'] }, + { type: ResourceType.ApiPolicy, nameParts: ['orders'] }, + { type: ResourceType.NamedValue, nameParts: ['orders-key'] }, + { type: ResourceType.Backend, nameParts: ['orders-backend'] }, + { type: ResourceType.PolicyFragment, nameParts: ['shared-auth'] }, + { type: ResourceType.VersionSet, nameParts: ['orders-v1'] }, + ]; + const client = createMockClient(); + const store = createMockStore(resources); + store.readResource.mockImplementation(async (_sourceDir, descriptor) => + descriptor.type === ResourceType.Api + ? { + name: 'orders', + properties: { + apiVersionSetId: + '/subscriptions/s/resourceGroups/r/providers/Microsoft.ApiManagement/service/a/apiVersionSets/orders-v1', + }, + } + : { name: descriptor.nameParts[0] ?? '', properties: {} } + ); + store.readContent.mockImplementation(async (_sourceDir, descriptor) => + descriptor.type === ResourceType.ApiPolicy + ? { + content: [ + '', + '{{orders-key}}', + '', + '', + '', + ].join(''), + format: 'xml', + } + : undefined + ); + + const result = await runPublish(client, store, { + service: testContext, + sourceDir: '/source', + filter: { + apis: ['orders'], + namedValues: [], + backends: [], + policyFragments: [], + versionSets: [], + }, + dryRun: false, + deleteUnmatched: false, + logLevel: LogLevel.INFO, + }); + + expect(result.totalPuts).toBe(5); + expect(publishApi).toHaveBeenCalledWith( + client, + store, + testContext, + expect.objectContaining({ type: ResourceType.Api, nameParts: ['orders'] }), + expect.anything(), + expect.arrayContaining([ + expect.objectContaining({ type: ResourceType.ApiPolicy, nameParts: ['orders'] }), + expect.objectContaining({ type: ResourceType.NamedValue, nameParts: ['orders-key'] }), + expect.objectContaining({ type: ResourceType.Backend, nameParts: ['orders-backend'] }), + expect.objectContaining({ type: ResourceType.PolicyFragment, nameParts: ['shared-auth'] }), + expect.objectContaining({ type: ResourceType.VersionSet, nameParts: ['orders-v1'] }), + ]) + ); + }); + + it('should not scan unrelated resources that share the selected parent name', async () => { + const resources = [ + { type: ResourceType.Api, nameParts: ['orders'] }, + { type: ResourceType.Product, nameParts: ['orders'] }, + { type: ResourceType.Api, nameParts: ['shipping'] }, + ]; + const client = createMockClient(); + const store = createMockStore(resources); + store.readAssociation.mockImplementation( + async (_sourceDir: string, descriptor: ResourceDescriptor, associationType: string) => + descriptor.type === ResourceType.Product && associationType === 'apis' + ? [{ name: 'shipping' }] + : [] + ); + + await runPublish(client, store, { + service: testContext, + sourceDir: '/source', + filter: { apis: ['orders'], products: [] }, + dryRun: false, + deleteUnmatched: false, + logLevel: LogLevel.INFO, + }); + + expect(publishApi).toHaveBeenCalledTimes(1); + expect(publishApi).toHaveBeenCalledWith( + client, + store, + testContext, + expect.objectContaining({ nameParts: ['orders'] }), + expect.anything(), + expect.anything() + ); + }); + + it('should not pull composite API targets from product associations', async () => { + const resources = [ + { type: ResourceType.Product, nameParts: ['starter'] }, + { type: ResourceType.Api, nameParts: ['legacy-api'] }, + ]; + const client = createMockClient(); + const store = createMockStore(resources); + store.readAssociation.mockResolvedValue([{ name: 'legacy-api' }]); + + const result = await runPublish(client, store, { + service: testContext, + sourceDir: '/source', + filter: { products: ['starter'], apis: [] }, + dryRun: false, + deleteUnmatched: false, + logLevel: LogLevel.INFO, + }); + + expect(result.totalPuts).toBe(1); + expect(publishProduct).toHaveBeenCalledTimes(1); + expect(publishApi).not.toHaveBeenCalled(); + }); + + it('should include backend pool members transitively', async () => { + const resources = [ + { type: ResourceType.Backend, nameParts: ['pool'] }, + { type: ResourceType.Backend, nameParts: ['member'] }, + ]; + const client = createMockClient(); + const store = createMockStore(resources); + store.readResource.mockImplementation(async (_sourceDir, descriptor) => + descriptor.nameParts[0] === 'pool' + ? { + properties: { + type: 'Pool', + pool: { + services: [{ + id: '/subscriptions/s/resourceGroups/r/providers/Microsoft.ApiManagement/service/a/backends/member', + }], + }, + }, + } + : { properties: {} } + ); + + const result = await runPublish(client, store, { + service: testContext, + sourceDir: '/source', + filter: { backends: ['pool'] }, + dryRun: false, + deleteUnmatched: false, + logLevel: LogLevel.INFO, + }); + + expect(result.totalPuts).toBe(2); + expect(client.putResource.mock.calls.some((call) => + call[1].type === ResourceType.Backend && + call[1].nameParts[0] === 'member' + )).toBe(true); + }); + + it('should not include transitive dependencies when disabled', async () => { + const resources = [ + { type: ResourceType.Api, nameParts: ['orders'] }, + { type: ResourceType.VersionSet, nameParts: ['orders-v1'] }, + ]; + const client = createMockClient(); + const store = createMockStore(resources); + store.readResource.mockResolvedValue({ + name: 'orders', + properties: { + apiVersionSetId: '/subscriptions/s/resourceGroups/r/providers/Microsoft.ApiManagement/service/a/apiVersionSets/orders-v1', + }, + }); + + const result = await runPublish(client, store, { + service: testContext, + sourceDir: '/source', + filter: { apis: ['orders'], versionSets: [] }, + includeTransitive: false, + dryRun: false, + deleteUnmatched: false, + logLevel: LogLevel.INFO, + }); + + expect(result.totalPuts).toBe(1); + expect(client.putResource.mock.calls.some((call) => call[1].type === ResourceType.VersionSet)).toBe(false); + }); + + it('should intersect incremental changes with a filter', async () => { + const changedDescriptors = [ + { type: ResourceType.NamedValue, nameParts: ['keep'] }, + { type: ResourceType.NamedValue, nameParts: ['skip'] }, + ]; + vi.mocked(computeGitDiff).mockResolvedValue({ + changedDescriptors, + deletedDescriptors: [], + }); + const client = createMockClient(); + const store = createMockStore(changedDescriptors); + + const result = await runPublish(client, store, { + service: testContext, + sourceDir: '/source', + filter: { namedValues: ['keep'] }, + includeTransitive: false, + commitId: 'abc123', + dryRun: false, + deleteUnmatched: false, + logLevel: LogLevel.INFO, + }); + + expect(result.totalPuts).toBe(1); + expect(client.putResource.mock.calls[0]?.[1].nameParts).toEqual(['keep']); + }); + + it('should resolve policy dependencies from unchanged child artifacts in incremental mode', async () => { + const api = { type: ResourceType.Api, nameParts: ['orders'] }; + const apiPolicy = { type: ResourceType.ApiPolicy, nameParts: ['orders'] }; + const backend = { type: ResourceType.Backend, nameParts: ['orders-backend'] }; + vi.mocked(computeGitDiff).mockResolvedValue({ + changedDescriptors: [api], + deletedDescriptors: [], + }); + const client = createMockClient(); + const store = createMockStore([api, apiPolicy, backend]); + store.readContent.mockImplementation(async (_sourceDir: string, descriptor: ResourceDescriptor) => + descriptor.type === ResourceType.ApiPolicy + ? { content: '' } + : undefined + ); + + const result = await runPublish(client, store, { + service: testContext, + sourceDir: '/source', + filter: { apis: ['orders'], backends: [] }, + commitId: 'abc123', + dryRun: false, + deleteUnmatched: false, + logLevel: LogLevel.INFO, + }); + + expect(result.totalPuts).toBe(2); + expect(client.putResource.mock.calls.some((call) => + call[1].type === ResourceType.Backend && + call[1].nameParts[0] === 'orders-backend' + )).toBe(true); + }); + + it('should report filtered targets in dry-run mode', async () => { + const resources = [ + { type: ResourceType.NamedValue, nameParts: ['keep'] }, + { type: ResourceType.NamedValue, nameParts: ['skip'] }, + ]; + const client = createMockClient(); + const store = createMockStore(resources); + + await runPublish(client, store, { + service: testContext, + sourceDir: '/source', + filter: { namedValues: ['keep'] }, + includeTransitive: false, + dryRun: true, + deleteUnmatched: false, + logLevel: LogLevel.INFO, + }); + + expect(generateDryRunReport).toHaveBeenCalledWith( + store, + client, + testContext, + expect.objectContaining({ filter: { namedValues: ['keep'] } }), + [{ type: ResourceType.NamedValue, nameParts: ['keep'] }], + [] + ); + }); + it('should return exit code 1 when some fail', async () => { const resources = [ { type: ResourceType.NamedValue, nameParts: ['nv1'] }, diff --git a/tests/unit/services/resource-publisher.test.ts b/tests/unit/services/resource-publisher.test.ts index 79f05637..5f67af11 100644 --- a/tests/unit/services/resource-publisher.test.ts +++ b/tests/unit/services/resource-publisher.test.ts @@ -350,6 +350,125 @@ describe('resource-publisher', () => { ); }); + it('should skip GatewayApi entries whose API target is excluded', async () => { + const client = createMockClient(); + const store = createMockStore(); + store.readAssociation.mockResolvedValue([{ name: 'legacy-api' }]); + + const descriptor: ResourceDescriptor = { + type: ResourceType.GatewayApi, + nameParts: ['my-gateway'], + }; + const config: PublishConfig = { + ...testConfig, + filter: { + gateways: ['my-gateway'], + apis: ['!legacy-api', '*'], + }, + }; + + const result = await publishResource(client, store, testContext, descriptor, config); + + expect(result.status).toBe('success'); + expect(client.putResource).not.toHaveBeenCalled(); + }); + + it('should skip ApiTag links whose Tag target is excluded', async () => { + const client = createMockClient(); + const store = createMockStore(); + const descriptor: ResourceDescriptor = { + type: ResourceType.ApiTag, + nameParts: ['orders', 'internal'], + }; + const config: PublishConfig = { + ...testConfig, + filter: { + apis: ['orders'], + tags: ['!internal', '*'], + }, + }; + + const result = await publishResource(client, store, testContext, descriptor, config); + + expect(result.status).toBe('skipped'); + expect(store.readResource).not.toHaveBeenCalled(); + expect(client.putResource).not.toHaveBeenCalled(); + }); + + it('should publish a GatewayApi association when its target passes an explicit filter and was extracted', async () => { + const client = createMockClient(); + const store = createMockStore(); + store.readAssociation.mockResolvedValue([{ name: 'orders-api' }]); + store.readResource.mockResolvedValue({ name: 'orders-api', properties: {} }); + + const descriptor: ResourceDescriptor = { + type: ResourceType.GatewayApi, + nameParts: ['my-gateway'], + }; + const config: PublishConfig = { + ...testConfig, + filter: { + gateways: ['my-gateway'], + apis: ['orders-api'], + }, + }; + + const result = await publishResource(client, store, testContext, descriptor, config); + + expect(result.status).toBe('success'); + expect(client.putResource).toHaveBeenCalledWith( + testContext, + expect.objectContaining({ type: ResourceType.GatewayApi, nameParts: ['my-gateway', 'orders-api'] }), + {} + ); + }); + + it('should skip a GatewayApi association whose target passes the filter but was never extracted', async () => { + const client = createMockClient(); + const store = createMockStore(); + store.readAssociation.mockResolvedValue([{ name: 'orders-api' }]); + // readResource left unmocked (resolves undefined) — simulates a dangling + // reference to an API name that matches the filter but has no artifact. + + const descriptor: ResourceDescriptor = { + type: ResourceType.GatewayApi, + nameParts: ['my-gateway'], + }; + const config: PublishConfig = { + ...testConfig, + filter: { + gateways: ['my-gateway'], + apis: ['orders-api'], + }, + }; + + const result = await publishResource(client, store, testContext, descriptor, config); + + expect(result.status).toBe('success'); + expect(client.putResource).not.toHaveBeenCalled(); + }); + + it('should skip an ApiTag link whose Tag target passes the filter but was never extracted', async () => { + const client = createMockClient(); + const store = createMockStore(); + const descriptor: ResourceDescriptor = { + type: ResourceType.ApiTag, + nameParts: ['orders', 'production'], + }; + const config: PublishConfig = { + ...testConfig, + filter: { + apis: ['orders'], + tags: ['production'], + }, + }; + + const result = await publishResource(client, store, testContext, descriptor, config); + + expect(result.status).toBe('skipped'); + expect(client.putResource).not.toHaveBeenCalled(); + }); + it('should strip properties.value from KeyVault-backed NamedValue PUT payload', async () => { const client = createMockClient(); const store = createMockStore(); @@ -612,6 +731,71 @@ describe('resource-publisher', () => { expect(props.scope).toBe('/products/my-product'); }); + it('should skip a Subscription whose Product target is excluded', async () => { + const client = createMockClient(); + const store = createMockStore(); + const armScopePrefix = + '/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.ApiManagement/service/apim-1'; + store.readResource.mockResolvedValue({ + name: 'sub-1', + properties: { + scope: `${armScopePrefix}/products/legacy-product`, + }, + }); + + const descriptor: ResourceDescriptor = { + type: ResourceType.Subscription, + nameParts: ['sub-1'], + }; + const config: PublishConfig = { + ...testConfig, + filter: { + subscriptions: ['sub-1'], + products: ['!legacy-product', '*'], + }, + }; + + const result = await publishResource(client, store, testContext, descriptor, config); + + expect(result.status).toBe('skipped'); + expect(client.putResource).not.toHaveBeenCalled(); + }); + + it('should skip a Subscription whose Product target passes the filter but was never extracted', async () => { + const client = createMockClient(); + const store = createMockStore(); + const armScopePrefix = + '/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.ApiManagement/service/apim-1'; + const subscriptionJson = { + name: 'sub-1', + properties: { + scope: `${armScopePrefix}/products/orders-product`, + }, + }; + // Only the subscription's own artifact exists; the referenced product + // ("orders-product") was never extracted, even though it passes the filter. + store.readResource.mockImplementation(async (_dir: string, desc: ResourceDescriptor) => + desc.type === ResourceType.Subscription ? subscriptionJson : undefined + ); + + const descriptor: ResourceDescriptor = { + type: ResourceType.Subscription, + nameParts: ['sub-1'], + }; + const config: PublishConfig = { + ...testConfig, + filter: { + subscriptions: ['sub-1'], + products: ['orders-product'], + }, + }; + + const result = await publishResource(client, store, testContext, descriptor, config); + + expect(result.status).toBe('skipped'); + expect(client.putResource).not.toHaveBeenCalled(); + }); + it('should leave scope unchanged when it is already a relative APIM path', async () => { const client = createMockClient(); const store = createMockStore(); diff --git a/tests/unit/services/transitive-resolver.test.ts b/tests/unit/services/transitive-resolver.test.ts index 34f16248..f111fa33 100644 --- a/tests/unit/services/transitive-resolver.test.ts +++ b/tests/unit/services/transitive-resolver.test.ts @@ -4,7 +4,7 @@ * Unit tests for Transitive dependency resolver */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { ResourceType } from '../../../src/models/resource-types.js'; import { FilterConfig } from '../../../src/models/config.js'; import { @@ -12,6 +12,8 @@ import { scanApiVersionSetReference, resolveTransitiveDependencies, findTransitiveDependencies, + findSubscriptionTargets, + scanArtifactReferences, } from '../../../src/services/transitive-resolver.js'; describe('transitive-resolver', () => { @@ -105,6 +107,19 @@ describe('transitive-resolver', () => { const apiJson = { name: 'my-api' }; expect(scanApiVersionSetReference(apiJson)).toBeUndefined(); }); + + it('should preserve malformed encoded names instead of throwing', () => { + const apiJson = { + properties: { + apiVersionSetId: '/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.ApiManagement/service/svc1/apiVersionSets/version%', + }, + }; + + expect(scanApiVersionSetReference(apiJson)).toEqual({ + type: ResourceType.VersionSet, + name: 'version%', + }); + }); }); describe('resolveTransitiveDependencies', () => { @@ -194,5 +209,105 @@ describe('transitive-resolver', () => { const deps = findTransitiveDependencies(policies, apis); expect(deps).toHaveLength(0); }); + + describe('scanArtifactReferences', () => { + it('scans policy references without parsing policy XML as JSON', async () => { + const store = { + readResource: vi.fn().mockRejectedValue(new SyntaxError('Unexpected token <')), + readContent: vi.fn().mockResolvedValue({ + content: '{{shared-secret}}', + format: 'xml', + }), + readAssociation: vi.fn(), + }; + + await expect( + scanArtifactReferences(store, '/source', { + type: ResourceType.ApiPolicy, + nameParts: ['orders'], + workspace: 'team-a', + }) + ).resolves.toContainEqual({ + type: ResourceType.NamedValue, + nameParts: ['shared-secret'], + workspace: 'team-a', + }); + expect(store.readResource).not.toHaveBeenCalled(); + }); + + it('should scan backend pools without treating links as transitive dependencies', async () => { + const store = { + readResource: vi.fn() + .mockResolvedValueOnce({ + properties: { + type: 'Pool', + pool: { + services: [{ id: '/subscriptions/s/resourceGroups/r/providers/Microsoft.ApiManagement/service/a/backends/backend-1' }], + }, + }, + }), + readContent: vi.fn().mockResolvedValue(undefined), + readAssociation: vi.fn(), + }; + + const backendRefs = await scanArtifactReferences( + store, + '/source', + { type: ResourceType.Backend, nameParts: ['pool'] } + ); + expect(backendRefs).toContainEqual({ + type: ResourceType.Backend, + nameParts: ['backend-1'], + workspace: undefined, + }); + + await expect(scanArtifactReferences( + store, + '/source', + { type: ResourceType.Subscription, nameParts: ['sub'] } + )).resolves.toEqual([]); + + await expect(scanArtifactReferences( + store, + '/source', + { type: ResourceType.Product, nameParts: ['starter'] } + )).resolves.toEqual([]); + expect(store.readAssociation).not.toHaveBeenCalled(); + }); + + it('should identify subscription targets separately from transitive dependencies', () => { + const targets = findSubscriptionTargets({ + properties: { + scope: '/subscriptions/s/resourceGroups/r/providers/Microsoft.ApiManagement/service/a/apis/orders', + apiId: '/subscriptions/s/resourceGroups/r/providers/Microsoft.ApiManagement/service/a/products/starter', + }, + }); + + expect(targets).toEqual([ + { + type: ResourceType.Api, + nameParts: ['orders'], + workspace: undefined, + }, + { + type: ResourceType.Product, + nameParts: ['starter'], + workspace: undefined, + }, + ]); + }); + + it('should preserve service scope for absolute subscription targets', () => { + expect(findSubscriptionTargets({ + properties: { + scope: '/subscriptions/s/resourceGroups/r/providers/Microsoft.ApiManagement/service/a/apis/shared-api', + }, + }, 'team-a')).toContainEqual({ + type: ResourceType.Api, + nameParts: ['shared-api'], + workspace: undefined, + }); + }); + }); }); }); diff --git a/tests/unit/services/workspace-extractor.test.ts b/tests/unit/services/workspace-extractor.test.ts index c1aff29f..9b5e34c9 100644 --- a/tests/unit/services/workspace-extractor.test.ts +++ b/tests/unit/services/workspace-extractor.test.ts @@ -284,6 +284,55 @@ describe('workspace-extractor', () => { ]); }); + it('should apply workspace exclusions with shared filter semantics', async () => { + const client = createMockClient(); + client.listResources = async function* ( + _ctx: ApimServiceContext, + type: ResourceType + ) { + if (type === ResourceType.Workspace) { + yield { name: 'team-a', properties: {} }; + yield { name: 'team-b', properties: {} }; + } + }; + const store = createMockStore(); + + const results = await extractWorkspaces( + client, + store, + testContext, + '/output', + { workspaces: ['!team-b'] } + ); + + expect(results.map((result) => result.workspaceName)).toEqual(['team-a']); + }); + + it('should apply exclusions after wildcard workspace inclusions', async () => { + const client = createMockClient(); + client.listResources = async function* ( + _ctx: ApimServiceContext, + type: ResourceType + ) { + if (type === ResourceType.Workspace) { + yield { name: 'team-a', properties: {} }; + yield { name: 'team-b', properties: {} }; + yield { name: 'shared', properties: {} }; + } + }; + const store = createMockStore(); + + const results = await extractWorkspaces( + client, + store, + testContext, + '/output', + { workspaces: ['team-*', '!team-b'] } + ); + + expect(results.map((result) => result.workspaceName)).toEqual(['team-a']); + }); + it('should apply sub-filter with wildcard workspace name patterns', async () => { const client = createMockClient(); let firstCall = true; @@ -317,6 +366,97 @@ describe('workspace-extractor', () => { // Only nv-1 should be extracted due to sub-filter expect(results[0]?.resourceCount).toBe(1); }); + + it('should extract workspace policy fragment dependencies transitively', async () => { + const client = createMockClient(); + client.listResources = async function* ( + _ctx: ApimServiceContext, + type: ResourceType + ) { + if (type === ResourceType.PolicyFragment) { + yield { + name: 'shared-fragment', + properties: { + value: '{{workspace-secret}}', + }, + }; + } + if (type === ResourceType.Backend) { + yield { + name: 'workspace-pool', + properties: { + type: 'Pool', + pool: { + services: [{ + id: '/subscriptions/s/resourceGroups/r/providers/Microsoft.ApiManagement/service/a/backends/service-member', + }], + }, + }, + }; + } + }; + client.getResource.mockImplementation(async (_ctx, descriptor) => { + if (descriptor.type === ResourceType.Workspace) { + return { name: 'team-a', properties: {} }; + } + if ( + descriptor.type === ResourceType.NamedValue && + descriptor.nameParts[0] === 'workspace-secret' + ) { + return { name: 'workspace-secret', properties: { secret: true, value: 'secret' } }; + } + if ( + descriptor.type === ResourceType.Backend && + descriptor.nameParts[0] === 'service-member' + ) { + return { name: 'service-member', properties: {} }; + } + return undefined; + }); + const store = createMockStore(); + const filter: FilterConfig = { + workspaces: ['team-a'], + workspaceSubFilters: { + 'team-a': { + backends: ['workspace-pool'], + namedValues: [], + policyFragments: ['shared-fragment'], + }, + }, + }; + + const results = await extractWorkspaces( + client, + store, + testContext, + '/output', + filter, + true + ); + + expect(results[0]?.resourceCount).toBe(4); + expect(client.getResource).toHaveBeenCalledWith( + testContext, + expect.objectContaining({ + type: ResourceType.Backend, + nameParts: ['service-member'], + workspace: undefined, + }) + ); + expect(store.writeResource).toHaveBeenCalledWith( + '/output', + expect.objectContaining({ + type: ResourceType.NamedValue, + nameParts: ['workspace-secret'], + workspace: 'team-a', + }), + expect.objectContaining({ + properties: expect.objectContaining({ + value: '*** REDACTED ***', + }), + }) + ); + }); }); describe('resolveWorkspaceFilter', () => { From c7221d311441569f441a51031056016d4f41170f Mon Sep 17 00:00:00 2001 From: Chris Dayne Date: Sat, 29 Aug 2026 21:52:01 +1000 Subject: [PATCH 2/3] docs: enhance filtering resources guide with publishing details and examples --- docs/guides/filtering-resources.md | 48 ++++++++++++++---------------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/docs/guides/filtering-resources.md b/docs/guides/filtering-resources.md index e75d67fc..8a7676f9 100644 --- a/docs/guides/filtering-resources.md +++ b/docs/guides/filtering-resources.md @@ -1,6 +1,6 @@ # Filtering Resources -By default, `apiops extract` pulls every resource from your APIM instance. For large instances or multi-team setups, you can filter extraction to specific resources using a YAML filter file. +By default, `apiops extract` pulls every resource from your APIM instance and `apiops publish` publishes every artifact in the source directory. For large instances or multi-team setups, you can use the same YAML filter file to limit either operation to specific resources. ## Why Filter? @@ -34,6 +34,18 @@ apiops extract \ `petstore-api`, `orders-api`, and their transitive dependencies are extracted — along with every backend, named value, product, tag, workspace, and every other resource type, because those keys are omitted and therefore default to "include all". To narrow the extract to just these APIs, see [How To: Extract Just One API](#how-to-extract-just-one-api) below. +The same filter can limit publishing to a subset of the extracted artifacts: + +```bash +apiops publish \ + --resource-group my-rg \ + --service-name my-apim \ + --filter configuration.extractor.yaml +``` + +Referenced dependencies are included by default; add `--no-transitive` to publish only direct filter +matches. + --- ## How To: Extract Just One API @@ -64,7 +76,7 @@ documentations: [] workspaces: [] ``` -With transitive resolution enabled (the default), any version set, backend, named value, or policy fragment directly referenced by `my-own-api` or its policies is still pulled in automatically — even though those keys are set to `[]`. Use `--no-transitive` to disable that behavior. +With transitive resolution enabled (the default), any version set, backend, named value, policy fragment, or tag directly referenced by `my-own-api` or its policies is still pulled in automatically — even though those keys are set to `[]`. Use `--no-transitive` to disable that behavior. The three states for every key are: @@ -293,9 +305,10 @@ flowchart TD A[Filtered API] --> B[Backends referenced in policies] A --> C[Named Values referenced in policies] A --> D[Policy Fragments included in policies] - A --> E[Version Set the API belongs to] - B --> F[Backend pool members] - D --> G[Resources referenced by fragment content] + A --> E[Tags attached to the API] + A --> F[Products containing the API] + A --> G[Diagnostics configured on the API] + A --> H[Version Set the API belongs to] ``` ### Example @@ -312,23 +325,11 @@ If `petstore-api` has a policy that references: - Named value `petstore-api-key` → **auto-included** - Policy fragment `rate-limit-fragment` → **auto-included** -The extract output includes these dependencies even though only `apis` was specified in the filter. -Product, gateway, tag, and subscription links do not reverse the dependency direction or -auto-include composite API or Product targets. - -### API Children Are Still Extracted +And `petstore-api` is assigned to: +- Product `starter` → **auto-included** +- Tag `production` → **auto-included** -Tags and diagnostics were not removed from API extraction. They are API child artifacts, not -transitive dependencies: - -- Selecting an API also extracts its API tag associations and API diagnostics. -- Use a nested API `diagnostics` filter to narrow or exclude API diagnostics. -- Product selection remains independent. Selecting an API does not find and include Products that - contain it; omit `products` to include all Products, list specific Products, or use `products: []` - to include none. - -Top-level `tags` and `diagnostics` filter their resource definitions independently from API child -artifacts. This follows the general rule that every top-level filter key is independent. +The extract output includes all of these, even though only `apiNames` was specified in the filter. ### Why Transitive Matters @@ -338,7 +339,7 @@ Without transitive resolution, publishing the extracted artifacts to a new APIM ## Disabling Transitive Dependencies -Use `--no-transitive` to disable referenced dependency expansion: +Use `--no-transitive` to extract **only** the explicitly listed resources: ```bash apiops extract \ @@ -354,9 +355,6 @@ apiops extract \ - You want a minimal extract and will handle missing references manually - Debugging — to see exactly what was explicitly filtered -Selected parent resources still include their normal child artifacts. For example, selecting an API -still extracts its tags and diagnostics unless nested API sub-filters exclude them. - > ⚠️ **Caution:** Extracted artifacts without transitive dependencies may not be publishable standalone. You'll need to ensure all referenced resources exist in the target APIM instance. --- From 9f38143281bcaac8ef07137fe45cce2c1578b2c7 Mon Sep 17 00:00:00 2001 From: Chris Dayne Date: Tue, 1 Sep 2026 12:29:26 +1000 Subject: [PATCH 3/3] feat: complete filtered publish parity Align live and dry-run publish planning, incremental deletion safety, workspace associations, environment mapping, and concrete action results. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- docs/architecture/overview.md | 2 +- docs/commands/publish.md | 8 +- docs/guides/dry-run-workflow.md | 8 +- docs/guides/incremental-publish.md | 14 +- docs/reference/configuration.md | 2 +- docs/troubleshooting/common-errors.md | 18 +- src/cli/publish-command.ts | 14 +- src/lib/resource-path.ts | 17 +- src/models/types.ts | 2 + src/services/api-publisher.ts | 678 +++++++++++------- src/services/dry-run-reporter.ts | 377 ++++++++-- src/services/env-mapper.ts | 36 +- src/services/git-diff-service.ts | 191 ++++- src/services/product-publisher.ts | 366 +++++----- src/services/publish-service.ts | 120 +++- src/services/resource-publisher.ts | 673 +++++++++++++---- src/services/transitive-resolver.ts | 10 +- tests/unit/cli/publish-command.test.ts | 4 +- tests/unit/lib/resource-path.test.ts | 26 +- tests/unit/services/api-publisher.test.ts | 419 ++++++++++- tests/unit/services/dry-run-reporter.test.ts | 614 +++++++++++++++- tests/unit/services/env-mapper.test.ts | 12 +- tests/unit/services/git-diff-service.test.ts | 117 +++ tests/unit/services/product-publisher.test.ts | 214 +++++- tests/unit/services/publish-service.test.ts | 495 ++++++++++++- .../resource-publisher.env-mapping.test.ts | 66 ++ .../unit/services/resource-publisher.test.ts | 332 ++++++++- .../unit/services/transitive-resolver.test.ts | 29 + 29 files changed, 4120 insertions(+), 746 deletions(-) diff --git a/README.md b/README.md index de68e6d3..b438e654 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Publish local artifact files to an Azure APIM service. | `--no-transitive` | | Publish only filter matches, without referenced dependencies | | `--commit-id ` | | Git commit SHA for incremental publish | | `--dry-run` | | Preview changes without applying | -| `--delete-unmatched` | | Delete resources not in artifacts (mutually exclusive with `--filter` and `--commit-id`) | +| `--delete-unmatched` | | Delete resources absent from artifacts, or removed by an incremental commit (mutually exclusive with `--filter`) | ```bash apiops publish --help diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index b2d6d2fd..5e6f31f1 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -161,7 +161,7 @@ flowchart TD - **Dry-run mode** — `--dry-run` compares local artifacts against live APIM state and outputs a change report without modifying anything. - **Override merging** — Environment-specific override files are merged into artifact JSON before publishing, enabling promotion across environments (dev → staging → prod). - **Topological ordering** — Resources are published in dependency order (e.g., named values before APIs that reference them). -- **Delete-unmatched** — Optionally removes APIM resources not present in the artifact source. Requires explicit opt-in and is mutually exclusive with `--commit-id`. +- **Delete-unmatched** — Optionally removes APIM resources not present in the artifact source. With `--commit-id`, it deletes only resources removed by the selected commit. All deletion requires explicit opt-in. ## Design Principles diff --git a/docs/commands/publish.md b/docs/commands/publish.md index ef58ffbe..8d14dc32 100644 --- a/docs/commands/publish.md +++ b/docs/commands/publish.md @@ -91,9 +91,9 @@ apiops publish \ | `--no-transitive` | boolean | `false` | No | Publish only exact filter matches | | `--commit-id ` | string | env: `COMMIT_ID` | No | Git commit SHA for incremental publish | | `--dry-run` | boolean | `false` | No | Preview changes without applying | -| `--delete-unmatched` | boolean | `false` | No | Delete APIM resources not present in source | +| `--delete-unmatched` | boolean | `false` | No | Delete APIM resources absent from source, or removed by an incremental commit | -> **Note:** `--filter` and `--commit-id` can be combined; `--delete-unmatched` cannot be combined with either option. +> **Note:** `--filter` and `--commit-id` can be combined. `--delete-unmatched` cannot be combined with `--filter`; with `--commit-id`, it explicitly enables commit-scoped deletions. ### Global flags @@ -184,7 +184,7 @@ In CI/CD pipelines, this is typically set automatically: - run: npx apiops publish --commit-id ${{ github.event.before }} ``` -> **Tip:** Incremental publish cannot be combined with `--delete-unmatched` because delete-unmatched requires a full comparison between source and APIM. +> **Tip:** Incremental publish is non-destructive by default. Add `--delete-unmatched` to delete resources removed by the selected commit; omit `--commit-id` for a full unmatched-resource cleanup. ## Dry run @@ -201,7 +201,7 @@ The output lists each resource and the planned action (create, update, or delete ## Delete unmatched -When `--delete-unmatched` is set, resources that exist in the APIM instance but are **not** present in the source artifacts are deleted. This enforces the source directory as the single source of truth. +For a full publish, `--delete-unmatched` deletes APIM resources that are **not** present in the source artifacts. With `--commit-id`, it deletes only resources and Product associations removed by the selected commit. > **Warning:** Use with caution. Resources created manually in the Azure portal that are not in your artifact directory will be removed. diff --git a/docs/guides/dry-run-workflow.md b/docs/guides/dry-run-workflow.md index cc7e2b67..d8ed2d82 100644 --- a/docs/guides/dry-run-workflow.md +++ b/docs/guides/dry-run-workflow.md @@ -73,6 +73,7 @@ apiops publish \ ``` --- Dry-Run Report --- 3 creates/updates +0 patches 1 deletes 0 skipped @@ -83,11 +84,12 @@ Planned actions: DELETE NamedValue/old-key ``` -Each action shows the operation (`PUT`, `DELETE`, `SKIP`), the resource type, and the resource name. +Each action shows the operation (`PUT`, `PATCH`, `DELETE`, `SKIP`), the resource type, and the resource name. | Operation | Meaning | |-----------|---------| | `PUT` | Resource would be created (new) or updated (existing) | +| `PATCH` | Resource would be partially updated | | `DELETE` | Resource would be removed from APIM | | `SKIP` | Resource could not be checked (error reading from APIM) | @@ -104,6 +106,7 @@ Each action shows the operation (`PUT`, `DELETE`, `SKIP`), the resource type, an ], "summary": { "creates": 3, + "patches": 0, "deletes": 1, "skips": 0 } @@ -219,11 +222,12 @@ This lets reviewers see _"this PR will create 2 APIs and update 1 backend"_ dire | `--dry-run` | Preview full publish | | `--dry-run --delete-unmatched` | Preview full publish + unmatched resource deletions | | `--dry-run --commit-id ` | Preview incremental publish (changed files only) | +| `--dry-run --commit-id --delete-unmatched` | Preview incremental publish including commit-scoped deletions | | `--dry-run --overrides config.yaml` | Preview publish with environment overrides applied | | `--dry-run --format json` | Machine-readable preview output | | `--dry-run --log-level debug` | Preview with verbose diagnostic logging | -> **Note:** `--commit-id` and `--delete-unmatched` remain mutually exclusive, even in dry-run mode. +> **Note:** Incremental deletion is disabled unless `--delete-unmatched` is explicitly provided. --- diff --git a/docs/guides/incremental-publish.md b/docs/guides/incremental-publish.md index a4af7934..c4c29358 100644 --- a/docs/guides/incremental-publish.md +++ b/docs/guides/incremental-publish.md @@ -140,17 +140,17 @@ Force a full publish (omit `--commit-id`) when: - **Configuration drift** — someone changed APIM directly in the portal and you want to overwrite everything from git. - **Major refactoring** — renaming many APIs or restructuring directories. A full publish ensures nothing is missed. - **Override-only changes** — you updated an override file but no artifact files changed. See [Gotcha: Override-only changes are not published incrementally](environment-overrides.md#gotcha-override-only-changes-are-not-published-incrementally). -- **You need `--delete-unmatched`** — see below. +- **You need a full unmatched-resource cleanup** — incremental deletion only covers resources removed by the selected commit. -### `--commit-id` and `--delete-unmatched` are mutually exclusive +### Incremental deletion requires explicit opt-in -You cannot combine incremental publish with `--delete-unmatched`. The CLI exits with an error if both are specified. +By default, incremental publish does not delete resources. Add `--delete-unmatched` to delete resources whose artifacts or Product associations were removed by the selected commit: -``` -Options --commit-id (or COMMIT_ID) and --delete-unmatched are mutually exclusive. +```bash +apiops publish --commit-id abc123 --delete-unmatched ... ``` -**Why?** `--delete-unmatched` removes APIM resources that don't exist in the artifact directory — it requires a full view of all artifacts. Incremental publish only sees one commit's diff. +This does not perform a full unmatched-resource scan. Omit `--commit-id` when you need to remove every APIM resource absent from the complete artifact source. --- @@ -162,7 +162,7 @@ Options --commit-id (or COMMIT_ID) and --delete-unmatched are mutually exclusive | `Commit not found; skipping incremental diff` | Shallow clone doesn't include the commit | Use `fetch-depth: 2` (or more) in your checkout step to include at least the parent commit. | | Nothing published, no errors | Commit diff returned no artifact file changes | Verify the commit actually touches files in the `--source` directory. Use `git diff --name-status HEAD~1 HEAD` locally to check. | | Nothing published after override change | Override file changed but no artifact files changed | Override files are not artifact files — they don't trigger resource selection. Run a full publish (omit `--commit-id`) or include an artifact file change in the same commit. See [Gotcha: Override-only changes](environment-overrides.md#gotcha-override-only-changes-are-not-published-incrementally). | -| `mutually exclusive` error | Both `--commit-id` and `--delete-unmatched` specified | Remove one. Use `--commit-id` for incremental or `--delete-unmatched` for full sync — not both. | +| Removed resources remain in APIM | Incremental deletion was not enabled | Add `--delete-unmatched` after reviewing a dry-run. | ### GitHub Actions: fetch depth diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 1f929ebc..f2ff060a 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -63,7 +63,7 @@ Available on all commands (`extract`, `publish`, `init`): | `--dry-run` | Preview changes without applying | `false` | | `--delete-unmatched` | Delete resources not in artifacts | `false` | -> ⚠️ `--commit-id` and `--delete-unmatched` are **mutually exclusive**. You cannot use both. +> ⚠️ Incremental deletion is disabled unless `--delete-unmatched` is combined with `--commit-id`. The combination deletes only resources removed by the selected commit. ### `apiops init` Flags diff --git a/docs/troubleshooting/common-errors.md b/docs/troubleshooting/common-errors.md index c9767ef0..91f2724e 100644 --- a/docs/troubleshooting/common-errors.md +++ b/docs/troubleshooting/common-errors.md @@ -141,23 +141,15 @@ apiops init --environments dev prod ## Publish Errors -### "Options --commit-id and --delete-unmatched are mutually exclusive" +### Removed resources remain after incremental publish -**Cause:** Both `--commit-id` and `--delete-unmatched` were specified. These flags conflict because: +**Cause:** Incremental publishing is non-destructive by default. -- `--commit-id` publishes only changed resources (partial set) -- `--delete-unmatched` deletes resources not in the source (requires full set) - -Deleting based on a partial set would remove resources that were simply unchanged. - -**Solution:** Use one or the other: +**Solution:** Preview and then enable commit-scoped deletion: ```bash -# Incremental publish (changed resources only) -apiops publish --commit-id abc123 ... - -# Full publish with cleanup (all resources, delete extras) -apiops publish --delete-unmatched ... +apiops publish --commit-id abc123 --delete-unmatched --dry-run ... +apiops publish --commit-id abc123 --delete-unmatched ... ``` --- diff --git a/src/cli/publish-command.ts b/src/cli/publish-command.ts index 469da08c..7caf3abc 100644 --- a/src/cli/publish-command.ts +++ b/src/cli/publish-command.ts @@ -142,7 +142,7 @@ async function executePublish( if (hasMutuallyExclusivePublishOptions(options.deleteUnmatched, commitId, Boolean(options.filter))) { logger.error( - 'Option --delete-unmatched cannot be combined with --filter or --commit-id (or COMMIT_ID).' + 'Option --delete-unmatched cannot be combined with --filter.' ); process.exit(2); } @@ -182,10 +182,10 @@ async function executePublish( */ export function hasMutuallyExclusivePublishOptions( deleteUnmatched: boolean, - commitId?: string, + _commitId?: string, hasFilter = false ): boolean { - return deleteUnmatched && (Boolean(commitId) || hasFilter); + return deleteUnmatched && hasFilter; } /** @@ -198,6 +198,7 @@ function outputJson(result: PublishResult): void { exitCode: number; summary: { totalPuts: number; + totalPatches: number; totalDeletes: number; totalErrors: number; totalSkipped: number; @@ -214,9 +215,11 @@ function outputJson(result: PublishResult): void { operation: string; type: string; name: string; + error?: string; }>; summary: { creates: number; + patches: number; deletes: number; skips: number; }; @@ -231,6 +234,7 @@ function outputJson(result: PublishResult): void { exitCode: result.exitCode, summary: { totalPuts: result.totalPuts, + totalPatches: result.totalPatches, totalDeletes: result.totalDeletes, totalErrors: result.totalErrors, totalSkipped: result.totalSkipped, @@ -251,6 +255,7 @@ function outputJson(result: PublishResult): void { operation: a.operation, type: a.type, name: a.name, + error: a.error, })), summary: result.dryRunReport.summary, }; @@ -273,6 +278,7 @@ function outputText(result: PublishResult, dryRun: boolean): void { process.stdout.write( `${result.dryRunReport.summary.creates} creates/updates\n` ); + process.stdout.write(`${result.dryRunReport.summary.patches} patches\n`); process.stdout.write(`${result.dryRunReport.summary.deletes} deletes\n`); process.stdout.write(`${result.dryRunReport.summary.skips} skipped\n`); @@ -288,7 +294,7 @@ function outputText(result: PublishResult, dryRun: boolean): void { // Regular publish mode summary process.stdout.write('\n--- Summary ---\n'); process.stdout.write( - `${result.totalPuts} creates/updates, ${result.totalDeletes} deletes, ${result.totalSkipped} skipped\n` + `${result.totalPuts} creates/updates, ${result.totalPatches} patches, ${result.totalDeletes} deletes, ${result.totalSkipped} skipped\n` ); if (result.totalErrors > 0) { diff --git a/src/lib/resource-path.ts b/src/lib/resource-path.ts index b8032224..b5f76a6e 100644 --- a/src/lib/resource-path.ts +++ b/src/lib/resource-path.ts @@ -147,7 +147,8 @@ export function getNamePart(nameParts: string[], index: number): string { * Create a case-insensitive identity key for a resource descriptor. */ export function getResourceDescriptorKey(descriptor: ResourceDescriptor): string { - return `${descriptor.type}:${descriptor.workspace ?? ''}:${descriptor.nameParts.join('/')}`.toLowerCase(); + const scopeSuffix = descriptor.targetScope ? `:${descriptor.targetScope}` : ''; + return `${descriptor.type}:${descriptor.workspace ?? ''}:${descriptor.nameParts.join('/')}${scopeSuffix}`.toLowerCase(); } /** @@ -417,6 +418,20 @@ export function parseArtifactPath( return workspaceContainer; } + if (['apis.json', 'groups.json', 'tags.json'].includes(fileName)) { + const productNameParts = parseTemplatePath( + RESOURCE_TYPE_METADATA[ResourceType.Product].artifactDirectory, + parts.slice(startIndex, -1).join('/') + ); + if (productNameParts !== undefined) { + return { + type: ResourceType.Product, + nameParts: productNameParts, + workspace, + }; + } + } + // Try to match against each resource type's pattern for (const [typeKey, metadata] of Object.entries(RESOURCE_TYPE_METADATA)) { const type = typeKey as ResourceType; diff --git a/src/models/types.ts b/src/models/types.ts index e935a467..798d7ee7 100644 --- a/src/models/types.ts +++ b/src/models/types.ts @@ -22,6 +22,8 @@ export interface ResourceDescriptor { nameParts: string[]; /** Workspace name if workspace-scoped */ workspace?: string; + /** Scope of an association target when required to identify an opaque link. */ + targetScope?: AssociationScope; } export interface ResourcePayload { diff --git a/src/services/api-publisher.ts b/src/services/api-publisher.ts index 2cf7b318..951a2f13 100644 --- a/src/services/api-publisher.ts +++ b/src/services/api-publisher.ts @@ -14,10 +14,13 @@ import type { PublishConfig } from '../models/config.js'; import * as yaml from 'js-yaml'; import { ResourceType } from '../models/resource-types.js'; import { + applyApiPathPrefix, normalizeMcpToolOperationIds, + normalizeApiVersionSetId, publishResource, type ResourcePublishResult, } from './resource-publisher.js'; +import { mapDescriptor } from './env-mapper.js'; import { runParallel } from '../lib/parallel-runner.js'; import { applyOverrides } from './override-merger.js'; import { logger } from '../lib/logger.js'; @@ -32,7 +35,7 @@ import { resolveWorkspaceFilter, shouldIncludeResource } from './filter-service. /** * API child resource types that should be published after the API itself */ -const API_CHILD_TYPES: ResourceType[] = [ +export const API_CHILD_TYPES: ResourceType[] = [ ResourceType.ApiPolicy, ResourceType.ApiTag, ResourceType.ApiDiagnostic, @@ -46,6 +49,20 @@ const API_CHILD_TYPES: ResourceType[] = [ ResourceType.GraphQLResolverPolicy, ]; +export interface ApiPatchPlan { + descriptor: ResourceDescriptor; + payload: Record; +} + +export interface ApiPublicationPlan { + importSpecification: boolean; + revisions: ResourceDescriptor[]; + alignActiveRevision: boolean; + childPuts: ResourceDescriptor[]; + operationDescriptionPuts: ResourceDescriptor[]; + operationPatches: ApiPatchPlan[]; +} + /** * Publish an API with all its revisions and child resources. * Creates root API first, then revisions in numeric order. @@ -59,117 +76,274 @@ export async function publishApi( config: PublishConfig, allowedDescriptors?: ResourceDescriptor[] ): Promise { + let publicationPlan: ApiPublicationPlan; + let rootPublished = false; try { - const includeSpecification = await shouldImportSpecification( + publicationPlan = await planApiPublication( store, descriptor, config, allowedDescriptors ); + } catch (error) { + return { + descriptor, + status: 'failed', + action: 'noop', + error: error instanceof Error ? error : new Error(String(error)), + }; + } + + try { // Step 1: Publish root API (with spec import if available) const rootResult = await publishRootApi(client, store, context, descriptor, config, { - includeSpecification, + includeSpecification: publicationPlan.importSpecification, }); if (rootResult.status !== 'success') { return rootResult; } + rootPublished = true; - if (rootResult.specImported && rootResult.operationIdsWithNullDescription?.length) { - await alignImportedOperationDescriptions( + const relatedResults: ResourcePublishResult[] = []; + if (rootResult.specImported && publicationPlan.operationDescriptionPuts.length > 0) { + relatedResults.push(...await alignImportedOperationDescriptions( client, context, - descriptor, - rootResult.operationIdsWithNullDescription - ); + publicationPlan.operationDescriptionPuts, + config + )); } // Step 2: Find and publish revisions in numeric order - const publishedRevisionCount = await publishApiRevisions( + const revisionResults = await publishApiRevisions( client, store, context, - descriptor, config, - allowedDescriptors + publicationPlan.revisions ); + relatedResults.push(...revisionResults); // Step 2b: Align root API only when source marks it as current. // Source of truth is properties.isCurrent in root apiInformation.json. - if (publishedRevisionCount > 0 && rootResult.isCurrent === true) { - const alignResult = await alignActiveRevisionWithSource( - client, - store, - context, - descriptor, - config - ); + if (publicationPlan.alignActiveRevision && rootResult.isCurrent === true) { + let alignResult: ResourcePublishResult; + try { + alignResult = await alignActiveRevisionWithSource( + client, + store, + context, + descriptor, + config + ); + } catch (error) { + alignResult = { + descriptor, + status: 'failed', + action: 'put', + error: error instanceof ApiPutAttemptError + ? error.originalError + : error instanceof Error + ? error + : new Error(String(error)), + }; + } + relatedResults.push(alignResult); if (alignResult.status !== 'success') { - return alignResult; + return { + descriptor, + status: 'success', + action: 'put', + relatedResults, + }; } } // Step 3: Publish child resources in parallel // When a spec was imported, operations and schemas are auto-created by APIM - await publishApiChildren( + relatedResults.push(...await publishApiChildren( client, store, context, - descriptor, config, - rootResult.specImported, - allowedDescriptors - ); + publicationPlan + )); return { descriptor, status: 'success', action: 'put', + relatedResults, }; } catch (error) { return { descriptor, status: 'failed', - action: 'noop', - error: error instanceof Error ? error : new Error(String(error)), + action: rootPublished || error instanceof ApiPutAttemptError ? 'put' : 'noop', + error: error instanceof ApiPutAttemptError + ? error.originalError + : error instanceof Error + ? error + : new Error(String(error)), }; } } -async function shouldImportSpecification( +export async function planApiPublication( store: IArtifactStore, apiDescriptor: ResourceDescriptor, config: PublishConfig, allowedDescriptors?: ResourceDescriptor[] -): Promise { - if (!allowedDescriptors) { - return true; - } - +): Promise { + const allDescriptors = await store.listResources(config.sourceDir); const apiName = getNamePart(apiDescriptor.nameParts, 0); - const managedChildren = (await store.listResources(config.sourceDir)).filter( + const managedChildren = allDescriptors.filter( (descriptor) => SPEC_MANAGED_CHILD_TYPES.has(descriptor.type) && - getNamePart(descriptor.nameParts, 0) === apiName && + getNamePart(descriptor.nameParts, 0).toLowerCase() === apiName.toLowerCase() && descriptor.workspace === apiDescriptor.workspace ); const effectiveFilter = apiDescriptor.workspace ? resolveWorkspaceFilter(apiDescriptor.workspace, config.filter) : config.filter; - if (managedChildren.length === 0) { - const subFilter = Object.entries(effectiveFilter?.apiSubFilters ?? {}).find( - ([name]) => name.toLowerCase() === apiName.toLowerCase() - )?.[1]; - return subFilter?.operations === undefined && subFilter?.schemas === undefined; - } + const subFilter = Object.entries(effectiveFilter?.apiSubFilters ?? {}).find( + ([name]) => name.toLowerCase() === apiName.toLowerCase() + )?.[1]; + const allowed = allowedDescriptors + ? new Set(allowedDescriptors.map(getResourceDescriptorKey)) + : undefined; + const rootName = apiName.toLowerCase(); + const revisions = allDescriptors + .filter( + (descriptor) => + descriptor.type === ResourceType.Api && + descriptor.workspace === apiDescriptor.workspace && + getNamePart(descriptor.nameParts, 0).toLowerCase().startsWith(`${rootName};rev=`) && + ( + !allowed || + allowed.has(getResourceDescriptorKey(descriptor)) || + (config.commitId !== undefined && shouldIncludeResource(descriptor, effectiveFilter)) + ) + ) + .sort( + (left, right) => + extractRevisionNumber(getNamePart(left.nameParts, 0)) - + extractRevisionNumber(getNamePart(right.nameParts, 0)) + ); + const apiJson = await store.readResource(config.sourceDir, apiDescriptor); + const mergedApiJson = apiJson + ? applyOverrides(apiDescriptor, apiJson, config.overrides) + : undefined; + const specificationAllowed = + subFilter?.operations === undefined && + subFilter?.schemas === undefined && + ( + !allowed || + managedChildren.length === 0 || + (config.filter + ? managedChildren.every((descriptor) => + shouldIncludeResource(descriptor, effectiveFilter) + ) + : managedChildren.every((descriptor) => + allowed.has(getResourceDescriptorKey(descriptor)) + )) + ); - if (!config.filter) { - const allowed = new Set(allowedDescriptors.map(getResourceDescriptorKey)); - return managedChildren.every((descriptor) => allowed.has(getResourceDescriptorKey(descriptor))); + let importSpecification = false; + let operationDescriptionPuts: ResourceDescriptor[] = []; + if (specificationAllowed) { + const specification = await store.readContent(config.sourceDir, apiDescriptor, 'specification'); + if (mergedApiJson && specification) { + const properties = mergedApiJson.properties as Record | undefined; + const apiType = properties?.type as string | undefined; + const dialect = detectSpecDialect(specification.content, specification.format); + importSpecification = + getImportFormat(specification.format ?? 'yaml', apiType, dialect) !== undefined; + if (importSpecification) { + operationDescriptionPuts = getOpenApiOperationIdsWithNullDescription( + specification.content, + specification.format + ).map((operationName) => ({ + type: ResourceType.ApiOperation, + nameParts: [apiName, operationName], + workspace: apiDescriptor.workspace, + })); + } + } } - return managedChildren.every((descriptor) => - shouldIncludeResource(descriptor, effectiveFilter) + let childPuts = allDescriptors.filter( + (descriptor) => + API_CHILD_TYPES.includes(descriptor.type) && + getNamePart(descriptor.nameParts, 0).toLowerCase() === apiName.toLowerCase() && + descriptor.workspace === apiDescriptor.workspace && + !(importSpecification && SPEC_MANAGED_CHILD_TYPES.has(descriptor.type)) ); + if (allowed) { + childPuts = childPuts.filter( + (descriptor) => + allowed.has(getResourceDescriptorKey(descriptor)) || + (config.commitId !== undefined && shouldIncludeResource(descriptor, effectiveFilter)) + ); + } + + if (importSpecification) { + const explicitSchemas = allDescriptors.filter( + (descriptor) => + descriptor.type === ResourceType.ApiSchema && + getNamePart(descriptor.nameParts, 0).toLowerCase() === apiName.toLowerCase() && + descriptor.workspace === apiDescriptor.workspace && + !isAutoGeneratedId(getNamePart(descriptor.nameParts, 1)) + ); + const filteredExplicitSchemas = config.filter + ? explicitSchemas.filter((descriptor) => + shouldIncludeResource(descriptor, effectiveFilter) + ) + : allowed + ? explicitSchemas.filter((descriptor) => + allowed.has(getResourceDescriptorKey(descriptor)) + ) + : explicitSchemas; + childPuts = [...childPuts, ...filteredExplicitSchemas]; + } + + let operationDescriptors = importSpecification + ? managedChildren.filter( + (descriptor) => + descriptor.type === ResourceType.ApiOperation && + !isAutoGeneratedId(getNamePart(descriptor.nameParts, 1)) + ) + : []; + if (config.filter) { + operationDescriptors = operationDescriptors.filter((descriptor) => + shouldIncludeResource(descriptor, effectiveFilter) + ); + } else if (allowed) { + operationDescriptors = operationDescriptors.filter((descriptor) => + allowed.has(getResourceDescriptorKey(descriptor)) + ); + } + + const operationPatches = ( + await Promise.all( + operationDescriptors.map(async (descriptor): Promise => { + const payload = await buildOperationPatchPayload(store, descriptor, config); + return payload ? { descriptor, payload } : undefined; + }) + ) + ).filter((plan): plan is ApiPatchPlan => plan !== undefined); + + return { + importSpecification, + revisions, + alignActiveRevision: + revisions.length > 0 && + mergedApiJson !== undefined && + getApiIsCurrent(mergedApiJson) === true, + childPuts: deduplicateDescriptors(childPuts), + operationDescriptionPuts, + operationPatches, + }; } /** @@ -208,7 +382,6 @@ function getImportFormat(specFormat: string, _apiType?: string, specDialect?: Ap interface RootApiResult { status: 'success' | 'skipped'; specImported: boolean; - operationIdsWithNullDescription?: string[]; isCurrent?: boolean; } @@ -243,7 +416,7 @@ async function publishRootApi( // Root APIs publish through api-publisher rather than publishResource, so // they need the same pre-override MCP tool normalization here that revision // APIs receive in resource-publisher. - json = normalizeMcpToolOperationIds(json, context); + json = normalizeMcpToolOperationIds(json, context, config.envMapping); // Apply overrides json = applyOverrides(descriptor, json, config.overrides); @@ -251,7 +424,6 @@ async function publishRootApi( // Try to read the specification file for this API let specImported = false; - let operationIdsWithNullDescription: string[] = []; const includeSpecification = options?.includeSpecification ?? true; const specResult = includeSpecification ? await store.readContent(config.sourceDir, descriptor, 'specification') @@ -292,27 +464,34 @@ async function publishRootApi( }, }; - if (importFormat === 'openapi' || importFormat === 'openapi+json' || importFormat === 'swagger-json') { - operationIdsWithNullDescription = getOpenApiOperationIdsWithNullDescription( - specResult.content, - specResult.format - ); - } - specImported = true; logger.info(`Including ${specResult.format} specification in API import for "${getNamePart(descriptor.nameParts, 0)}"`); } } + json = applyApiPathPrefix(json, descriptor, config); + json = normalizeApiVersionSetId( + json, + context, + descriptor.workspace, + config.envMapping + ); + const deployedDescriptor = config.envMapping + ? mapDescriptor(descriptor, config.envMapping) + : descriptor; + // PUT the API resource to APIM - await client.putResource(context, descriptor, json); + try { + await client.putResource(context, deployedDescriptor, json); + } catch (error) { + throw new ApiPutAttemptError(error); + } return { descriptor, status: 'success', action: 'put', specImported, - operationIdsWithNullDescription, isCurrent, }; } @@ -340,48 +519,16 @@ async function publishApiRevisions( client: IApimClient, store: IArtifactStore, context: ApimServiceContext, - apiDescriptor: ResourceDescriptor, config: PublishConfig, - allowedDescriptors?: ResourceDescriptor[] -): Promise { - // List all resources from store - const allDescriptors = await store.listResources(config.sourceDir); - const allowed = allowedDescriptors - ? new Set(allowedDescriptors.map(getResourceDescriptorKey)) - : undefined; - const effectiveFilter = apiDescriptor.workspace - ? resolveWorkspaceFilter(apiDescriptor.workspace, config.filter) - : config.filter; - const rootName = getNamePart(apiDescriptor.nameParts, 0).toLowerCase(); - - // Find revision descriptors for this API - const revisionDescriptors = allDescriptors.filter( - (d) => - d.type === ResourceType.Api && - d.workspace === apiDescriptor.workspace && - getNamePart(d.nameParts, 0).toLowerCase().startsWith(`${rootName};rev=`) && - ( - !allowed || - allowed.has(getResourceDescriptorKey(d)) || - // Incremental mode only diffs changed files; fall back to the filter - // so an unchanged revision still republishes with its root API. - (config.commitId !== undefined && shouldIncludeResource(d, effectiveFilter)) - ) - ); - - // Sort revisions by revision number - const sortedRevisions = revisionDescriptors.sort((a, b) => { - const revA = extractRevisionNumber(getNamePart(a.nameParts, 0)); - const revB = extractRevisionNumber(getNamePart(b.nameParts, 0)); - return revA - revB; - }); - + revisionDescriptors: ResourceDescriptor[] +): Promise { // Publish each revision in order - for (const revDescriptor of sortedRevisions) { - await publishResource(client, store, context, revDescriptor, config); + const results: ResourcePublishResult[] = []; + for (const revDescriptor of revisionDescriptors) { + results.push(await publishResource(client, store, context, revDescriptor, config)); } - return sortedRevisions.length; + return results; } /** @@ -423,73 +570,15 @@ async function publishApiChildren( client: IApimClient, store: IArtifactStore, context: ApimServiceContext, - apiDescriptor: ResourceDescriptor, config: PublishConfig, - specImported: boolean = false, - allowedDescriptors?: ResourceDescriptor[] -): Promise { - // List all resources from store - const allDescriptors = await store.listResources(config.sourceDir); - const effectiveFilter = apiDescriptor.workspace - ? resolveWorkspaceFilter(apiDescriptor.workspace, config.filter) - : config.filter; - - // Find child descriptors for this API - let childDescriptors = allDescriptors.filter( - (d) => - API_CHILD_TYPES.includes(d.type) && - getNamePart(d.nameParts, 0).toLowerCase() === getNamePart(apiDescriptor.nameParts, 0).toLowerCase() && - d.workspace === apiDescriptor.workspace && - !(specImported && SPEC_MANAGED_CHILD_TYPES.has(d.type)) - ); - - if (allowedDescriptors) { - const allowed = new Set(allowedDescriptors.map(getResourceDescriptorKey)); - childDescriptors = childDescriptors.filter( - (descriptor) => - allowed.has(getResourceDescriptorKey(descriptor)) || - // Incremental mode only diffs changed files; fall back to the filter - // so an unchanged child still republishes with its parent API. - (config.commitId !== undefined && shouldIncludeResource(descriptor, effectiveFilter)) - ); - } - - if (specImported) { - // Re-include explicitly named schemas (non-auto-generated IDs). - // Auto-generated schemas have 24-char hex names and are recreated by spec import. - // Explicitly named schemas (like "src-rest-schema-item") must be published. - const explicitSchemas = allDescriptors.filter( - (d) => - d.type === ResourceType.ApiSchema && - getNamePart(d.nameParts, 0).toLowerCase() === getNamePart(apiDescriptor.nameParts, 0).toLowerCase() && - d.workspace === apiDescriptor.workspace && - !isAutoGeneratedId(getNamePart(d.nameParts, 1)) - ); - - const filteredExplicitSchemas = config.filter - ? explicitSchemas.filter((descriptor) => - shouldIncludeResource(descriptor, effectiveFilter) - ) - : allowedDescriptors - ? explicitSchemas.filter((d) => - allowedDescriptors.some( - (allowed) => getResourceDescriptorKey(allowed) === getResourceDescriptorKey(d) - ) - ) - : explicitSchemas; - if (filteredExplicitSchemas.length > 0) { - logger.debug( - `Re-publishing ${filteredExplicitSchemas.length} explicit schema(s) after spec import for "${getNamePart(apiDescriptor.nameParts, 0)}"` - ); - childDescriptors = [...childDescriptors, ...filteredExplicitSchemas]; - } - } - + plan: ApiPublicationPlan +): Promise { + const results: ResourcePublishResult[] = []; // Group resources by publish tier for dependency ordering. // Lower tiers are published first (parents before children, operations before policies). // Resources in the same tier can be published in parallel. const tierMap = new Map(); - for (const d of childDescriptors) { + for (const d of plan.childPuts) { const tier = getPublishTier(d.type); const existing = tierMap.get(tier) ?? []; existing.push(d); @@ -501,29 +590,35 @@ async function publishApiChildren( for (const tier of sortedTiers) { const descriptors = tierMap.get(tier) ?? []; if (descriptors.length > 0) { - const tasks = descriptors.map( - (descriptor) => () => - publishResource(client, store, context, descriptor, config) - ); - await runParallel(tasks, 5); + const tasks = descriptors.map((descriptor) => async () => { + return publishResource(client, store, context, descriptor, config); + }); + const taskResults = await runParallel(tasks, 5); + for (const [index, taskResult] of (taskResults ?? []).entries()) { + if (taskResult.status === 'fulfilled' && taskResult.value) { + results.push(taskResult.value); + } else if (taskResult.status === 'rejected') { + const childDescriptor = descriptors[index]; + if (childDescriptor) { + results.push({ + descriptor: childDescriptor, + status: 'failed', + action: 'put', + error: taskResult.reason, + }); + } + } + } } } - // After spec import, reconcile all operations by PATCHing with persisted metadata. - // This ensures APIM retains the original operation state (description, schema refs, etc.) - // regardless of what the importer defaulted. PATCH is idempotent and only updates - // the fields present in the persisted JSON. - if (specImported) { - await reconcileOperationsAfterSpecImport( - client, - store, - context, - apiDescriptor, - config, - allDescriptors, - allowedDescriptors - ); - } + results.push(...await reconcileOperationsAfterSpecImport( + client, + context, + plan.operationPatches, + config + )); + return results; } /** @@ -537,85 +632,87 @@ async function publishApiChildren( */ async function reconcileOperationsAfterSpecImport( client: IApimClient, - store: IArtifactStore, context: ApimServiceContext, - apiDescriptor: ResourceDescriptor, - config: PublishConfig, - allDescriptors: ResourceDescriptor[], - allowedDescriptors?: ResourceDescriptor[] -): Promise { - let operationDescriptors = allDescriptors.filter( - (d) => - d.type === ResourceType.ApiOperation && - getNamePart(d.nameParts, 0).toLowerCase() === getNamePart(apiDescriptor.nameParts, 0).toLowerCase() && - d.workspace === apiDescriptor.workspace && - !isAutoGeneratedId(getNamePart(d.nameParts, 1)) - ); - if (config.filter) { - const effectiveFilter = apiDescriptor.workspace - ? resolveWorkspaceFilter(apiDescriptor.workspace, config.filter) - : config.filter; - operationDescriptors = operationDescriptors.filter((descriptor) => - shouldIncludeResource(descriptor, effectiveFilter) - ); - } else if (allowedDescriptors) { - const allowed = new Set(allowedDescriptors.map(getResourceDescriptorKey)); - operationDescriptors = operationDescriptors.filter((d) => - allowed.has(getResourceDescriptorKey(d)) - ); - } - - if (operationDescriptors.length === 0) return; - - const tasks = operationDescriptors.map((descriptor) => async () => { - const json = await store.readResource(config.sourceDir, descriptor); - if (!json) return; - const mergedJson = applyOverrides(descriptor, json, config.overrides); - - const props = mergedJson.properties as Record | undefined; - if (!props) return; - - // Build a PATCH body with only allow-listed properties present in the persisted JSON. - const patchProps: Record = {}; - for (const key of OPERATION_PATCH_ALLOWLIST) { - if (Object.hasOwn(props, key)) { - patchProps[key] = props[key]; - } - } - - // The spec import already bound request/responses representations to the - // schemas it created. PATCH replaces those arrays wholesale, so re-sending - // them without schemaId/typeName (the source IDs don't exist on the target) - // would wipe the binding the importer just created. Drop them and reconcile - // only importer-agnostic metadata. - if (hasSchemaBoundRepresentations(patchProps)) { - delete patchProps.request; - delete patchProps.responses; - } - - // Strip source schema refs; APIM rebinds on import and drops stale IDs. - stripRepresentationSchemaRefs(patchProps); - - if (Object.keys(patchProps).length === 0) return; - - const patchBody: Record = { properties: patchProps }; - + operationPatches: ApiPatchPlan[], + config: PublishConfig +): Promise { + const tasks = operationPatches.map(({ descriptor, payload }) => async () => { try { - await client.patchResource(context, descriptor, patchBody); + const deployedDescriptor = config.envMapping + ? mapDescriptor(descriptor, config.envMapping) + : descriptor; + await client.patchResource(context, deployedDescriptor, payload); logger.debug(`Reconciled operation "${getNamePart(descriptor.nameParts, 1)}" after spec import`); + const result: ResourcePublishResult = { + descriptor, + status: 'success', + action: 'patch', + }; + return result; } catch (error) { logger.warn( `Failed to reconcile operation "${getNamePart(descriptor.nameParts, 1)}" after spec import: ${(error as Error).message}` ); + const result: ResourcePublishResult = { + descriptor, + status: 'failed', + action: 'patch', + error: error instanceof Error ? error : new Error(String(error)), + }; + return result; } }); if (tasks.length > 0) { logger.debug( - `Reconciling ${tasks.length} operation(s) after spec import for "${getNamePart(apiDescriptor.nameParts, 0)}"` + `Reconciling ${tasks.length} operation(s) after spec import` ); - await runParallel(tasks, 5); + const taskResults = await runParallel(tasks, 5); + return (taskResults ?? []).flatMap((taskResult, index) => { + if (taskResult.status === 'fulfilled' && taskResult.value) { + return [taskResult.value]; + } + const patch = operationPatches[index]; + return patch + ? [{ + descriptor: patch.descriptor, + status: 'failed' as const, + action: 'patch' as const, + error: taskResult.reason, + }] + : []; + }); + } + return []; +} + +async function buildOperationPatchPayload( + store: IArtifactStore, + descriptor: ResourceDescriptor, + config: PublishConfig +): Promise | undefined> { + const json = await store.readResource(config.sourceDir, descriptor); + if (!json) return undefined; + const mergedJson = applyOverrides(descriptor, json, config.overrides); + const props = mergedJson.properties as Record | undefined; + if (!props) return undefined; + + const patchProps: Record = {}; + for (const key of OPERATION_PATCH_ALLOWLIST) { + if (Object.hasOwn(props, key)) { + patchProps[key] = props[key]; + } } + + if (hasSchemaBoundRepresentations(patchProps)) { + delete patchProps.request; + delete patchProps.responses; + } + stripRepresentationSchemaRefs(patchProps); + + return Object.keys(patchProps).length > 0 + ? { properties: patchProps } + : undefined; } /** @@ -714,38 +811,91 @@ function getApiIsCurrent(json: Record): boolean | undefined { async function alignImportedOperationDescriptions( client: IApimClient, context: ApimServiceContext, - apiDescriptor: ResourceDescriptor, - operationIdsWithNullDescription: string[] -): Promise { - const apiName = getNamePart(apiDescriptor.nameParts, 0); - - for (const operationName of operationIdsWithNullDescription) { - const operationDescriptor: ResourceDescriptor = { - type: ResourceType.ApiOperation, - nameParts: [apiName, operationName], - workspace: apiDescriptor.workspace, - }; - - const operation = await client.getResource(context, operationDescriptor); + operationDescriptors: ResourceDescriptor[], + config: PublishConfig +): Promise { + const results: ResourcePublishResult[] = []; + for (const operationDescriptor of operationDescriptors) { + const deployedDescriptor = config.envMapping + ? mapDescriptor(operationDescriptor, config.envMapping) + : operationDescriptor; + let operation: Record | undefined; + try { + operation = await client.getResource(context, deployedDescriptor); + } catch (error) { + results.push({ + descriptor: operationDescriptor, + status: 'failed', + action: 'noop', + error: error instanceof Error ? error : new Error(String(error)), + }); + continue; + } if (!operation) { + results.push({ + descriptor: operationDescriptor, + status: 'skipped', + action: 'noop', + }); continue; } const props = operation.properties as Record | undefined; - if (!props || props.description === null) { + if (!props) { + results.push({ + descriptor: operationDescriptor, + status: 'skipped', + action: 'noop', + }); continue; } - await client.putResource(context, operationDescriptor, { - ...operation, - properties: { - ...props, - description: null, - }, - }); + try { + await client.putResource(context, deployedDescriptor, { + ...operation, + properties: { + ...props, + description: null, + }, + }); + results.push({ + descriptor: operationDescriptor, + status: 'success', + action: 'put', + }); + } catch (error) { + results.push({ + descriptor: operationDescriptor, + status: 'failed', + action: 'put', + error: error instanceof Error ? error : new Error(String(error)), + }); + } + } + return results; +} + +class ApiPutAttemptError extends Error { + readonly originalError: Error; + + constructor(error: unknown) { + const originalError = error instanceof Error ? error : new Error(String(error)); + super(originalError.message, { cause: originalError }); + this.name = 'ApiPutAttemptError'; + this.originalError = originalError; } } +function deduplicateDescriptors(descriptors: ResourceDescriptor[]): ResourceDescriptor[] { + const seen = new Set(); + return descriptors.filter((descriptor) => { + const key = getResourceDescriptorKey(descriptor); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + function getOpenApiOperationIdsWithNullDescription( specContent: string, specFormat?: string diff --git a/src/services/dry-run-reporter.ts b/src/services/dry-run-reporter.ts index ff9ad071..3eaca2a4 100644 --- a/src/services/dry-run-reporter.ts +++ b/src/services/dry-run-reporter.ts @@ -10,23 +10,38 @@ import type { IApimClient } from '../clients/iapim-client.js'; import type { IArtifactStore } from '../clients/iartifact-store.js'; import type { ApimServiceContext, ResourceDescriptor } from '../models/types.js'; import type { PublishConfig } from '../models/config.js'; +import { ResourceType, RESOURCE_TYPE_METADATA } from '../models/resource-types.js'; import { getTopologicalOrder } from '../lib/dependency-graph.js'; import { buildResourceLabel } from '../lib/resource-uri.js'; -import { getNamePart } from '../lib/resource-path.js'; -import { ResourceType } from '../models/resource-types.js'; +import { getResourceDescriptorKey } from '../lib/resource-path.js'; import { logger } from '../lib/logger.js'; import { computeDeleteActions } from './delete-unmatched-service.js'; +import { applyOverrides } from './override-merger.js'; +import { + evaluateResourceEligibility, + planAssociationPublications, + resolveAssociationDeleteDescriptor, + type PublishEligibility, +} from './resource-publisher.js'; +import { + planProductAssociationPublications, + planProductPolicyPublication, +} from './product-publisher.js'; +import { API_CHILD_TYPES, planApiPublication } from './api-publisher.js'; +import { mapDescriptor } from './env-mapper.js'; export interface DryRunAction { - operation: 'PUT' | 'DELETE' | 'SKIP'; + operation: 'PUT' | 'PATCH' | 'DELETE' | 'SKIP'; type: string; name: string; descriptor: ResourceDescriptor; + reason?: string; + error?: string; } export interface DryRunReport { actions: DryRunAction[]; - summary: { creates: number; deletes: number; skips: number }; + summary: { creates: number; patches: number; deletes: number; skips: number }; } /** @@ -44,58 +59,93 @@ export async function generateDryRunReport( ): Promise { const actions: DryRunAction[] = []; let creates = 0; + let patches = 0; let deletes = 0; let skips = 0; + const publicationPlans = await planDryRunPublications( + store, + context, + config, + targetDescriptors, + incrementalDeletedDescriptors + ); + // Process in topological order const orderedTypes = getTopologicalOrder(); - const descriptorsByType = groupDescriptorsByType(targetDescriptors); + const plansByType = groupPlansByType(publicationPlans); for (const resourceType of orderedTypes) { - let descriptors = descriptorsByType.get(resourceType) || []; + const plans = plansByType.get(resourceType) || []; - // GatewayApi is discovered as an aggregate descriptor (nameParts = [gateway]) - // from gateways/{gw}/apis.json. Expand to one descriptor per associated API - // so labels and counts mirror what publish would actually PUT. - if (resourceType === ResourceType.GatewayApi) { - descriptors = await expandGatewayApiDescriptors(store, config, descriptors); - } + for (const plan of plans) { + const descriptor = plan.descriptor; + if (!plan.eligible) { + const action: DryRunAction = { + operation: 'SKIP', + type: descriptor.type, + name: formatResourceName(descriptor), + descriptor, + reason: plan.reason, + }; + actions.push(action); + skips++; + logger.info( + `[DRY RUN] SKIP ${buildResourceLabel(descriptor)} (${plan.reason ?? 'ineligible'})` + ); + continue; + } - for (const descriptor of descriptors) { try { - // Check if resource exists in APIM - const existsInApim = await client.getResource(context, descriptor); + const supportsGet = RESOURCE_TYPE_METADATA[descriptor.type].supportsGet; + const deployedDescriptor = config.envMapping + ? mapDescriptor(descriptor, config.envMapping) + : descriptor; + const existsInApim = supportsGet + ? await client.getResource(context, deployedDescriptor) + : undefined; + const operation = plan.operation ?? 'PUT'; if (existsInApim) { // Resource exists - would be updated const action: DryRunAction = { - operation: 'PUT', + operation, type: descriptor.type, name: formatResourceName(descriptor), descriptor, }; actions.push(action); - creates++; - logger.info(`[DRY RUN] PUT ${buildResourceLabel(descriptor)}`); + if (operation === 'PATCH') { + patches++; + } else { + creates++; + } + logger.info(`[DRY RUN] ${operation} ${buildResourceLabel(descriptor)}`); } else { // Resource doesn't exist - would be created const action: DryRunAction = { - operation: 'PUT', + operation, type: descriptor.type, name: formatResourceName(descriptor), descriptor, }; actions.push(action); - creates++; - logger.info(`[DRY RUN] PUT ${buildResourceLabel(descriptor)} (new)`); + if (operation === 'PATCH') { + patches++; + } else { + creates++; + } + logger.info(`[DRY RUN] ${operation} ${buildResourceLabel(descriptor)} (new)`); } - } catch { - // Error checking - skip + } catch (error) { + const errorMessage = `existence check failed: ${String(error)}`; const action: DryRunAction = { operation: 'SKIP', type: descriptor.type, name: formatResourceName(descriptor), descriptor, + reason: errorMessage, + error: errorMessage, }; actions.push(action); skips++; @@ -109,7 +159,20 @@ export async function generateDryRunReport( if (incrementalDeletedDescriptors.length > 0) { for (const descriptor of incrementalDeletedDescriptors) { try { - const existing = await client.getResource(context, descriptor); + const deployedDescriptor = config.envMapping + ? mapDescriptor(descriptor, config.envMapping) + : descriptor; + const resolvedDescriptor = await resolveAssociationDeleteDescriptor( + client, + context, + deployedDescriptor + ); + const supportsGet = RESOURCE_TYPE_METADATA[descriptor.type].supportsGet; + const existing = resolvedDescriptor + ? supportsGet + ? await client.getResource(context, resolvedDescriptor) + : {} + : undefined; if (existing) { const action: DryRunAction = { @@ -127,24 +190,28 @@ export async function generateDryRunReport( type: descriptor.type, name: formatResourceName(descriptor), descriptor, + reason: 'resource is already absent', }; actions.push(action); skips++; logger.info(`[DRY RUN] SKIP ${buildResourceLabel(descriptor)} (already absent)`); } - } catch { + } catch (error) { + const errorMessage = `existence check failed: ${String(error)}`; const action: DryRunAction = { operation: 'SKIP', type: descriptor.type, name: formatResourceName(descriptor), descriptor, + reason: errorMessage, + error: errorMessage, }; actions.push(action); skips++; logger.info(`[DRY RUN] SKIP ${buildResourceLabel(descriptor)} (error)`); } } - } else if (config.deleteUnmatched) { + } else if (config.deleteUnmatched && !config.commitId) { const deleteActions = await computeDeleteActionsForDryRun( client, store, @@ -166,8 +233,8 @@ export async function generateDryRunReport( } } - const summary = { creates, deletes, skips }; - logger.info(`[DRY RUN] Summary: ${creates} creates/updates, ${deletes} deletes, ${skips} skips`); + const summary = { creates, patches, deletes, skips }; + logger.info(`[DRY RUN] Summary: ${creates} creates/updates, ${patches} patches, ${deletes} deletes, ${skips} skips`); return { actions, summary }; } @@ -175,49 +242,233 @@ export async function generateDryRunReport( /** * Group descriptors by resource type */ -function groupDescriptorsByType( - descriptors: ResourceDescriptor[] -): Map { - const map = new Map(); - for (const descriptor of descriptors) { - const existing = map.get(descriptor.type) || []; - existing.push(descriptor); - map.set(descriptor.type, existing); - } - return map; +interface DryRunPublicationPlan extends PublishEligibility { + descriptor: ResourceDescriptor; + operation?: 'PUT' | 'PATCH'; } -/** - * Expand aggregate GatewayApi descriptors (nameParts = [gateway]) into one - * descriptor per associated API by reading gateways/{gw}/apis.json. - * Descriptors that already carry both name parts pass through unchanged. - */ -async function expandGatewayApiDescriptors( +async function planDryRunPublications( store: IArtifactStore, + context: ApimServiceContext, config: PublishConfig, - descriptors: ResourceDescriptor[] -): Promise { - const expanded: ResourceDescriptor[] = []; - for (const descriptor of descriptors) { - if (descriptor.nameParts.length >= 2) { - expanded.push(descriptor); + targetDescriptors: ResourceDescriptor[], + deletedDescriptors: ResourceDescriptor[] +): Promise { + const plans: DryRunPublicationPlan[] = []; + const seen = new Set(); + const deletedKeys = new Set(deletedDescriptors.map(getResourceDescriptorKey)); + const productParents = new Set( + targetDescriptors + .filter((descriptor) => descriptor.type === ResourceType.Product) + .map(parentScopeKey) + ); + const apiParents = new Set( + targetDescriptors + .filter( + (descriptor) => + descriptor.type === ResourceType.Api && + !/;rev=\d+$/i.test(descriptor.nameParts[0] ?? '') + ) + .map(parentScopeKey) + ); + + const addPlan = (plan: DryRunPublicationPlan, allowDuplicate = false): void => { + const key = `${plan.operation ?? 'PUT'}:${getResourceDescriptorKey(plan.descriptor)}`; + if (!allowDuplicate && seen.has(key)) return; + seen.add(key); + plans.push(plan); + }; + + for (const descriptor of targetDescriptors) { + if ( + descriptor.type === ResourceType.Api && + /;rev=\d+$/i.test(descriptor.nameParts[0] ?? '') && + apiParents.has(apiRootScopeKey(descriptor)) + ) { continue; } - const gatewayName = getNamePart(descriptor.nameParts, 0); - const entries = await store.readAssociation( - config.sourceDir, - { type: ResourceType.Gateway, nameParts: [gatewayName], workspace: descriptor.workspace }, - 'apis' - ); - for (const entry of entries) { - expanded.push({ - type: descriptor.type, - nameParts: [gatewayName, entry.name], - workspace: descriptor.workspace, - }); + + if ( + API_CHILD_TYPES.includes(descriptor.type) && + apiParents.has(parentScopeKey(descriptor)) + ) { + continue; + } + + if ( + isProductAssociation(descriptor.type) && + productParents.has(parentScopeKey(descriptor)) + ) { + continue; + } + + if (descriptor.type === ResourceType.Product) { + if (deletedKeys.has(getResourceDescriptorKey(descriptor))) { + addPlan({ + descriptor, + eligible: false, + reason: 'artifact not found', + }); + continue; + } + addPlan({ descriptor, eligible: true }); + const associations = await planProductAssociationPublications( + store, + context, + descriptor, + config, + targetDescriptors + ); + for (const association of associations) { + addPlan(association); + } + + const policy = await planProductPolicyPublication( + store, + descriptor, + config, + targetDescriptors + ); + if (policy?.eligible) { + addPlan({ descriptor: policy.descriptor, eligible: true }); + } + continue; } + + if ( + descriptor.type === ResourceType.Api && + !/;rev=\d+$/i.test(descriptor.nameParts[0] ?? '') + ) { + addPlan({ descriptor, eligible: true, operation: 'PUT' }); + const apiPlan = await planApiPublication( + store, + descriptor, + config, + targetDescriptors + ); + for (const revision of apiPlan.revisions) { + addPlan({ descriptor: revision, eligible: true, operation: 'PUT' }); + } + if (apiPlan.alignActiveRevision) { + addPlan({ descriptor, eligible: true, operation: 'PUT' }, true); + } + for (const child of apiPlan.childPuts) { + if (child.type === ResourceType.ApiTag) { + const eligibility = await evaluateResourceEligibility(store, child, config); + if ( + eligibility.eligible && + !child.workspace && + !(await store.readResource(config.sourceDir, child)) + ) { + addPlan({ + descriptor: child, + eligible: false, + reason: 'association artifact is missing', + operation: 'PUT', + }); + } else { + addPlan({ descriptor: child, ...eligibility, operation: 'PUT' }); + } + } else { + addPlan({ descriptor: child, eligible: true, operation: 'PUT' }); + } + } + for (const operation of apiPlan.operationDescriptionPuts) { + addPlan({ descriptor: operation, eligible: true, operation: 'PUT' }); + } + for (const patch of apiPlan.operationPatches) { + addPlan({ descriptor: patch.descriptor, eligible: true, operation: 'PATCH' }); + } + continue; + } + + if ( + descriptor.type === ResourceType.ProductApi || + descriptor.type === ResourceType.ProductGroup || + descriptor.type === ResourceType.GatewayApi + ) { + const associations = await planAssociationPublications( + store, + context, + descriptor, + config, + undefined, + targetDescriptors + ); + for (const association of associations) { + addPlan(association); + } + continue; + } + + if (descriptor.type === ResourceType.ApiTag) { + const eligibility = await evaluateResourceEligibility( + store, + descriptor, + config + ); + if ( + eligibility.eligible && + !descriptor.workspace && + !(await store.readResource(config.sourceDir, descriptor)) + ) { + addPlan({ descriptor, eligible: false, reason: 'association artifact is missing' }); + } else { + addPlan({ descriptor, ...eligibility }); + } + continue; + } + + if (descriptor.type === ResourceType.Subscription) { + const artifact = await store.readResource(config.sourceDir, descriptor); + if (!artifact) { + addPlan({ descriptor, eligible: false, reason: 'resource artifact is missing' }); + continue; + } + const json = applyOverrides(descriptor, artifact, config.overrides); + const eligibility = await evaluateResourceEligibility( + store, + descriptor, + config, + json + ); + addPlan({ descriptor, ...eligibility }); + continue; + } + + addPlan({ descriptor, eligible: true }); } - return expanded; + + return plans; +} + +function groupPlansByType( + plans: DryRunPublicationPlan[] +): Map { + const map = new Map(); + for (const plan of plans) { + const existing = map.get(plan.descriptor.type) || []; + existing.push(plan); + map.set(plan.descriptor.type, existing); + } + return map; +} + +function isProductAssociation(type: ResourceType): boolean { + return [ + ResourceType.ProductApi, + ResourceType.ProductGroup, + ResourceType.ProductTag, + ].includes(type); +} + +function parentScopeKey(descriptor: ResourceDescriptor): string { + return `${descriptor.workspace ?? ''}:${descriptor.nameParts[0] ?? ''}`.toLowerCase(); +} + +function apiRootScopeKey(descriptor: ResourceDescriptor): string { + const apiName = (descriptor.nameParts[0] ?? '').replace(/;rev=\d+$/i, ''); + return `${descriptor.workspace ?? ''}:${apiName}`.toLowerCase(); } /** diff --git a/src/services/env-mapper.ts b/src/services/env-mapper.ts index 1f6bbe25..3871ca3f 100644 --- a/src/services/env-mapper.ts +++ b/src/services/env-mapper.ts @@ -139,6 +139,14 @@ export function buildEnvMappingFromOverrides(overrides: OverrideConfig | undefin * - When type ∉ appliesTo: namespace scoping does not apply → returned unchanged. */ export function toCanonicalDescriptor(d: ResourceDescriptor, m: EnvMapping): ResourceDescriptor | null { + let canonicalWorkspace = d.workspace; + if (d.workspace && m.appliesTo.has(ResourceType.Workspace)) { + if (!isInEnvNamespace(d.workspace, ResourceType.Workspace, m)) { + return null; + } + canonicalWorkspace = + toCanonicalName(d.workspace, ResourceType.Workspace, m) ?? d.workspace; + } const segTypes = SEGMENT_TYPES.get(d.type); if (segTypes !== undefined) { @@ -156,7 +164,7 @@ export function toCanonicalDescriptor(d: ResourceDescriptor, m: EnvMapping): Res if (segType === null || segType === undefined) return part; // positional sub-resource key return toCanonicalName(part, segType, m) ?? part; // defensive fallback }); - return { ...d, nameParts: newParts }; + return { ...d, nameParts: newParts, workspace: canonicalWorkspace }; } // Top-level type @@ -164,10 +172,15 @@ export function toCanonicalDescriptor(d: ResourceDescriptor, m: EnvMapping): Res if (!isInEnvNamespace(d.nameParts[0], d.type, m)) return null; const canonicalFirst = toCanonicalName(d.nameParts[0], d.type, m); if (canonicalFirst === undefined) return null; // defensive - return { ...d, nameParts: [canonicalFirst, ...d.nameParts.slice(1)] }; + return { + ...d, + nameParts: [canonicalFirst, ...d.nameParts.slice(1)], + workspace: canonicalWorkspace, + }; } export function toDeployedName(name: string, type: ResourceType, m: EnvMapping): string { + if (type === ResourceType.Group && isBuiltInGroup(name)) return name; if (!m.appliesTo.has(type)) return name; return `${m.prefix}${name}${m.suffix}`; } @@ -178,6 +191,7 @@ export function toDeployedName(name: string, type: ResourceType, m: EnvMapping): * Returns input unchanged when type ∉ appliesTo. */ export function toCanonicalName(deployedName: string, type: ResourceType, m: EnvMapping): string | undefined { + if (type === ResourceType.Group && isBuiltInGroup(deployedName)) return deployedName; if (!m.appliesTo.has(type)) return deployedName; if (!isInEnvNamespace(deployedName, type, m)) return undefined; @@ -192,20 +206,28 @@ export function toCanonicalName(deployedName: string, type: ResourceType, m: Env * When type ∉ appliesTo → returns true (namespace scoping doesn't apply to this type). */ export function isInEnvNamespace(deployedName: string, type: ResourceType, m: EnvMapping): boolean { + if (type === ResourceType.Group && isBuiltInGroup(deployedName)) return true; if (!m.appliesTo.has(type)) return true; if (deployedName.length < m.prefix.length + m.suffix.length) return false; return deployedName.startsWith(m.prefix) && deployedName.endsWith(m.suffix); } +function isBuiltInGroup(name: string): boolean { + return ['administrators', 'developers', 'guests'].includes(name.toLowerCase()); +} + /** * Rewrite a descriptor by affixing name segments based on the segment's associated ResourceType. * For top-level types: nameParts[0] is affixed if type ∈ appliesTo. * For singleton children (ApiPolicy, ProductPolicy, etc.): nameParts[0] (parent name) is affixed if parent type ∈ appliesTo. * For association types (ProductApi, ApiTag, etc.): each segment is affixed if its type ∈ appliesTo. * For sub-resource children (ApiOperation, ApiSchema, etc.): only nameParts[0] (parent) is affixed; sub-resource keys are unchanged. - * The workspace field is NOT affixed (workspace container rename is handled separately). + * The workspace field is affixed independently from the resource path segments. */ export function mapDescriptor(d: ResourceDescriptor, m: EnvMapping): ResourceDescriptor { + const workspace = d.workspace + ? toDeployedName(d.workspace, ResourceType.Workspace, m) + : undefined; const segTypes = SEGMENT_TYPES.get(d.type); if (segTypes !== undefined) { @@ -214,11 +236,15 @@ export function mapDescriptor(d: ResourceDescriptor, m: EnvMapping): ResourceDes if (segType === null || segType === undefined) return part; return toDeployedName(part, segType, m); }); - return { ...d, nameParts: newParts }; + return { ...d, nameParts: newParts, workspace }; } // Top-level type: affix nameParts[0] if this type ∈ appliesTo if (d.nameParts.length === 0) return d; const [first, ...rest] = d.nameParts; - return { ...d, nameParts: [toDeployedName(first, d.type, m), ...rest] }; + return { + ...d, + nameParts: [toDeployedName(first, d.type, m), ...rest], + workspace, + }; } diff --git a/src/services/git-diff-service.ts b/src/services/git-diff-service.ts index dc37a97c..e31960e2 100644 --- a/src/services/git-diff-service.ts +++ b/src/services/git-diff-service.ts @@ -9,6 +9,7 @@ import { simpleGit, SimpleGit } from 'simple-git'; import * as path from 'node:path'; import { ResourceDescriptor } from '../models/types.js'; +import { ResourceType } from '../models/resource-types.js'; import { parseArtifactChangePath } from '../lib/resource-path.js'; import { logger } from '../lib/logger.js'; @@ -65,13 +66,39 @@ export async function computeGitDiff( const diffTarget = hasParent ? parentCommit : '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; // Git empty tree SHA const diffOutput = await git.diff(['--name-status', '--relative', diffTarget, commitId]); - return parseDiffOutput(diffOutput, sourceDir); + const result = parseDiffOutput(diffOutput, sourceDir); + try { + await addRemovedAssociations( + git, + diffOutput, + sourceDir, + diffTarget, + commitId, + result + ); + } catch (error) { + throw new AssociationDiffError(error); + } + return result; } catch (error) { + if (error instanceof AssociationDiffError) { + throw error.originalError; + } logger.warn(`Git diff failed: ${error instanceof Error ? error.message : String(error)}`); return { changedDescriptors: [], deletedDescriptors: [] }; } } +class AssociationDiffError extends Error { + readonly originalError: Error; + + constructor(error: unknown) { + const originalError = error instanceof Error ? error : new Error(String(error)); + super(originalError.message); + this.originalError = originalError; + } +} + /** * Parse git diff --name-status output into changed and deleted descriptors. * @@ -106,17 +133,26 @@ function parseDiffOutput(diffOutput: string, sourceDir: string): GitDiffResult { } if (status === 'D') { - addDescriptorFromDiffPath(parts[1], sourceDir, deletedDescriptors, seenDeleted); + if (isManagedAssociationPath(parts[1], sourceDir)) { + addDescriptorFromDiffPath(parts[1], sourceDir, changedDescriptors, seenChanged); + } else { + addDescriptorFromDiffPath(parts[1], sourceDir, deletedDescriptors, seenDeleted); + } } else if (status === 'M' || status === 'A') { addDescriptorFromDiffPath(parts[1], sourceDir, changedDescriptors, seenChanged); } else if (status === 'R') { // Renames are effectively delete(old) + add(new) - addDescriptorFromDiffPath(parts[1], sourceDir, deletedDescriptors, seenDeleted); + if (isManagedAssociationPath(parts[1], sourceDir)) { + addDescriptorFromDiffPath(parts[1], sourceDir, changedDescriptors, seenChanged); + } else { + addDescriptorFromDiffPath(parts[1], sourceDir, deletedDescriptors, seenDeleted); + } addDescriptorFromDiffPath(parts[2], sourceDir, changedDescriptors, seenChanged); } else if (status === 'C') { // Copies only introduce/modify the new destination path addDescriptorFromDiffPath(parts[2], sourceDir, changedDescriptors, seenChanged); } + } logger.debug( @@ -126,11 +162,158 @@ function parseDiffOutput(diffOutput: string, sourceDir: string): GitDiffResult { return { changedDescriptors, deletedDescriptors }; } +function isManagedAssociationPath( + diffPath: string | undefined, + sourceDir: string +): boolean { + if (!diffPath || !['apis.json', 'groups.json', 'tags.json'].includes(path.basename(diffPath))) { + return false; + } + + const descriptor = parseDescriptorFromDiffPath(sourceDir, diffPath); + return descriptor?.type === ResourceType.Product || + (path.basename(diffPath) === 'apis.json' && descriptor?.type === ResourceType.GatewayApi); +} + +async function addRemovedAssociations( + git: SimpleGit, + diffOutput: string, + sourceDir: string, + baseCommit: string, + targetCommit: string, + result: GitDiffResult +): Promise { + const seenDeleted = new Set(result.deletedDescriptors.map(descriptorKey)); + const lines = diffOutput.split('\n').filter((line) => line.trim()); + + for (const line of lines) { + const parts = line.split('\t'); + const status = parts[0]?.charAt(0); + const oldPath = parts[1]; + if ( + !status || + !['M', 'D', 'R'].includes(status) || + !oldPath || + !isManagedAssociationPath(oldPath, sourceDir) + ) { + continue; + } + + const newPath = status === 'R' ? parts[2] : oldPath; + const parent = parseDescriptorFromDiffPath(sourceDir, oldPath); + if (!parent) { + continue; + } + + const oldEntries = await readAssociationEntriesAtCommit(git, baseCommit, oldPath); + const sameAssociation = + status !== 'D' && + newPath !== undefined && + path.basename(newPath) === path.basename(oldPath) && + descriptorKey(parseDescriptorFromDiffPath(sourceDir, newPath) ?? parent) === + descriptorKey(parent); + const newEntries = sameAssociation + ? await readAssociationEntriesAtCommit(git, targetCommit, newPath) + : []; + const currentKeys = new Set(newEntries.map(associationEntryKey)); + const associationType = parent.type === ResourceType.GatewayApi + ? ResourceType.GatewayApi + : productAssociationType(path.basename(oldPath)); + + for (const entry of oldEntries) { + if (currentKeys.has(associationEntryKey(entry))) { + continue; + } + const descriptor: ResourceDescriptor = { + type: associationType, + nameParts: [parent.nameParts[0] ?? '', entry.name], + workspace: parent.workspace, + ...(parent.type === ResourceType.Product + ? { targetScope: entry.scope ?? 'workspace' } + : {}), + }; + addUniqueDescriptor( + result.deletedDescriptors, + seenDeleted, + descriptor, + descriptorKey(descriptor) + ); + } + } +} + +interface StoredAssociationEntry { + name: string; + scope?: 'service' | 'workspace'; +} + +async function readAssociationEntriesAtCommit( + git: SimpleGit, + commit: string, + filePath: string +): Promise { + try { + const content = await git.show([`${commit}:./${filePath}`]); + const parsed: unknown = JSON.parse(content); + if (!Array.isArray(parsed)) { + throw new Error(`Association artifact ${filePath} is not a JSON array`); + } + return parsed.map((entry, index): StoredAssociationEntry => { + if (typeof entry !== 'object' || entry === null) { + throw new Error(`Association artifact ${filePath} entry ${index} is not an object`); + } + const candidate = entry as Record; + if (typeof candidate.name !== 'string' || candidate.name.length === 0) { + throw new Error(`Association artifact ${filePath} entry ${index} has an invalid name`); + } + if ( + candidate.scope !== undefined && + candidate.scope !== 'service' && + candidate.scope !== 'workspace' + ) { + throw new Error(`Association artifact ${filePath} entry ${index} has an invalid scope`); + } + return { + name: candidate.name, + ...(candidate.scope ? { scope: candidate.scope } : {}), + }; + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes('does not exist') || message.includes('exists on disk')) { + return []; + } + throw error; + } +} + +function associationEntryKey(entry: StoredAssociationEntry): string { + return `${entry.scope ?? 'workspace'}:${entry.name}`.toLowerCase(); +} + +function productAssociationType(fileName: string): ResourceType { + switch (fileName) { + case 'apis.json': + return ResourceType.ProductApi; + case 'groups.json': + return ResourceType.ProductGroup; + case 'tags.json': + return ResourceType.ProductTag; + default: + throw new Error(`Unsupported Product association artifact: ${fileName}`); + } +} + /** * Create a unique key for a resource descriptor to enable deduplication. */ function descriptorKey(descriptor: ResourceDescriptor): string { - return [descriptor.type, ...descriptor.nameParts, descriptor.workspace ?? ''].join('::'); + return [ + descriptor.type, + ...descriptor.nameParts, + descriptor.workspace ?? '', + descriptor.targetScope ?? '', + ].join('::'); } function addUniqueDescriptor( diff --git a/src/services/product-publisher.ts b/src/services/product-publisher.ts index 3cbed42f..8429561c 100644 --- a/src/services/product-publisher.ts +++ b/src/services/product-publisher.ts @@ -10,13 +10,26 @@ import type { IArtifactStore } from '../clients/iartifact-store.js'; import type { ApimServiceContext, ResourceDescriptor } from '../models/types.js'; import type { PublishConfig } from '../models/config.js'; import { ResourceType, RESOURCE_TYPE_METADATA } from '../models/resource-types.js'; -import { publishResource, type ResourcePublishResult } from './resource-publisher.js'; +import { + evaluateAssociationEligibility, + planAssociationPublications, + publishResource, + type AssociationPublicationPlan, + type ResourcePublishResult, +} from './resource-publisher.js'; import { logger } from '../lib/logger.js'; -import { getNamePart, sameResourceDescriptor } from '../lib/resource-path.js'; +import { getNamePart, getResourceDescriptorKey, sameResourceDescriptor } from '../lib/resource-path.js'; import { parseArmUri } from '../lib/resource-uri.js'; import { isWorkspaceScope, buildLinkPayload } from '../lib/workspace-link.js'; import { isLinkAlreadyExistsError } from '../clients/apim-client.js'; -import { hasExplicitTypeFilter, resolveWorkspaceFilter, shouldIncludeResource } from './filter-service.js'; +import { mapDescriptor, toDeployedName } from './env-mapper.js'; + +export type ProductAssociationPublicationPlan = AssociationPublicationPlan; + +export interface ProductPolicyPublicationPlan { + descriptor: ResourceDescriptor; + eligible: boolean; +} /** * Publish a Product with all its associations (APIs, Groups, Tags). @@ -30,57 +43,55 @@ export async function publishProduct( config: PublishConfig, allowedDescriptors?: ResourceDescriptor[] ): Promise { + const productName = getNamePart(descriptor.nameParts, 0); + const deployedProductDescriptor = config.envMapping + ? mapDescriptor(descriptor, config.envMapping) + : descriptor; + let productExisted: boolean; try { - const productName = getNamePart(descriptor.nameParts, 0); - const productExisted = (await client.getResource(context, descriptor)) !== undefined; - - // Step 1: Publish the Product itself - const productResult = await publishResource(client, store, context, descriptor, config); - if (productResult.status !== 'success') { - return productResult; - } + productExisted = + (await client.getResource(context, deployedProductDescriptor)) !== undefined; + } catch (error) { + return { + descriptor, + status: 'failed', + action: 'noop', + error: error instanceof Error ? error : new Error(String(error)), + }; + } + // Step 1: Publish the Product itself + const productResult = await publishResource(client, store, context, descriptor, config); + if (productResult.status !== 'success') { + return productResult; + } + + try { if (!productExisted) { - await cleanupAutoCreatedProductResources(client, context, descriptor); + await cleanupAutoCreatedProductResources(client, context, deployedProductDescriptor); } - // Step 2: Publish ProductApi associations - await publishProductAssociations( - client, + // Steps 2-4: Publish ProductApi, ProductGroup, and ProductTag associations. + const associationPlans = await planProductAssociationPublications( store, context, descriptor, config, - 'apis', - ResourceType.ProductApi, allowedDescriptors ); + const relatedResults = await publishProductAssociationPlans(client, context, associationPlans); - // Step 3: Publish ProductGroup associations - await publishProductAssociations( - client, + // Step 5: Publish ProductPolicy if exists + const policyPlan = await planProductPolicyPublication( store, - context, descriptor, config, - 'groups', - ResourceType.ProductGroup, allowedDescriptors ); - - // Step 4: Publish ProductTag associations - // Tags are stored in the product directory, need to check for tags - await publishProductTags(client, store, context, descriptor, config, allowedDescriptors); - - // Step 5: Publish ProductPolicy if exists - const policyDescriptor: ResourceDescriptor = { - type: ResourceType.ProductPolicy, - nameParts: [productName], - workspace: descriptor.workspace, - }; - const policyContent = await store.readContent(config.sourceDir, policyDescriptor, 'policy'); - if (policyContent && isDescriptorAllowed(policyDescriptor, config, allowedDescriptors)) { - await publishResource(client, store, context, policyDescriptor, config); + if (policyPlan?.eligible) { + relatedResults.push( + await publishResource(client, store, context, policyPlan.descriptor, config) + ); logger.debug(`Published policy for product: ${productName}`); } @@ -89,13 +100,17 @@ export async function publishProduct( descriptor, status: 'success', action: 'put', + relatedResults, }; } catch (error) { return { - descriptor, - status: 'failed', - action: 'noop', - error: error instanceof Error ? error : new Error(String(error)), + ...productResult, + relatedResults: [{ + descriptor, + status: 'failed', + action: 'noop', + error: error instanceof Error ? error : new Error(String(error)), + }], }; } } @@ -161,146 +176,173 @@ function parseProductGroupDescriptor( * Publish associations (ProductApi or ProductGroup) for a product. * In workspace scope, uses the link endpoint with a link payload body. */ -async function publishProductAssociations( - client: IApimClient, +export async function planProductAssociationPublications( store: IArtifactStore, context: ApimServiceContext, productDescriptor: ResourceDescriptor, config: PublishConfig, - associationType: 'apis' | 'groups', - resourceType: ResourceType, allowedDescriptors?: ResourceDescriptor[] -): Promise { +): Promise { const productName = getNamePart(productDescriptor.nameParts, 0); - - // Read association file - const entries = await store.readAssociation( - config.sourceDir, - productDescriptor, - associationType + const apiPlans = await planAssociationPublications( + store, + context, + { + type: ResourceType.ProductApi, + nameParts: [productName], + workspace: productDescriptor.workspace, + }, + config, + 'apis', + allowedDescriptors ); - - if (entries.length === 0) { - logger.debug(`No ${associationType} associations for product: ${productName}`); - return; - } - - const workspaceScoped = !!productDescriptor.workspace || isWorkspaceScope(context); - const meta = RESOURCE_TYPE_METADATA[resourceType]; - const linkProperty = meta.workspaceLinkIdProperty; - // Map association type to the ARM resource type segment for building ARM IDs - const resourceTypeSegment = associationType === 'apis' ? 'apis' : 'groups'; - - // Create association for each name - for (const entry of entries) { - const name = entry.name; - const assocDescriptor: ResourceDescriptor = { - type: resourceType, - nameParts: [productName, name], + const groupPlans = await planAssociationPublications( + store, + context, + { + type: ResourceType.ProductGroup, + nameParts: [productName], workspace: productDescriptor.workspace, - }; - const targetDescriptor: ResourceDescriptor = { - type: associationType === 'apis' ? ResourceType.Api : ResourceType.Group, - nameParts: [name], - workspace: entry.scope === 'service' ? undefined : productDescriptor.workspace, - }; - if (!isAssociationAllowed(assocDescriptor, targetDescriptor, config, allowedDescriptors)) { - logger.warn( - `Skipping ${resourceType} association "${assocDescriptor.nameParts.join('/')}" because its target is excluded by the filter or unavailable in the publish set` - ); - continue; - } - - try { - // In workspace scope, PUT with link payload; otherwise empty body. - // Honor the stored scope so service-level link targets (e.g. the built-in - // `administrators` group) are referenced at service scope rather than - // being rebuilt as a non-existent workspace resource. - let payload: Record = {}; - if (workspaceScoped && linkProperty) { - payload = buildLinkPayload(context, linkProperty, resourceTypeSegment, name, productDescriptor.workspace, entry.scope); - } - await client.putResource(context, assocDescriptor, payload); - logger.debug(`Created ${resourceType} association: ${productName}/${name}`); - } catch (error) { - if (isLinkAlreadyExistsError(error)) { - logger.debug(`${resourceType} association already exists: ${productName}/${name}`); - continue; - } - logger.warn(`Failed to create ${resourceType} association ${productName}/${name}: ${String(error)}`); - } - } -} - -/** - * Publish ProductTag associations for a product. - * Tags are stored in tags.json similar to apis.json and groups.json. - * In workspace scope, uses `tags/{tag}/productLinks/{linkId}` endpoint. - */ -async function publishProductTags( - client: IApimClient, - store: IArtifactStore, - context: ApimServiceContext, - productDescriptor: ResourceDescriptor, - config: PublishConfig, - allowedDescriptors?: ResourceDescriptor[] -): Promise { - const productName = getNamePart(productDescriptor.nameParts, 0); - - // Read tags from tags.json association file + }, + config, + 'groups', + allowedDescriptors + ); const tagEntries = await store.readAssociation( config.sourceDir, productDescriptor, 'tags' ); - - if (tagEntries.length === 0) { - logger.debug(`No tag associations for product: ${productName}`); - return; - } - const workspaceScoped = !!productDescriptor.workspace || isWorkspaceScope(context); const linkProperty = RESOURCE_TYPE_METADATA[ResourceType.ProductTag].workspaceLinkIdProperty; + const tagPlans: ProductAssociationPublicationPlan[] = []; - // Create association for each tag for (const tagEntry of tagEntries) { const tagName = tagEntry.name; - const tagDescriptor: ResourceDescriptor = { + const descriptor: ResourceDescriptor = { type: ResourceType.ProductTag, nameParts: [productName, tagName], workspace: productDescriptor.workspace, + ...(tagEntry.scope ? { targetScope: tagEntry.scope } : {}), }; const targetDescriptor: ResourceDescriptor = { type: ResourceType.Tag, nameParts: [tagName], workspace: productDescriptor.workspace, }; - if (!isAssociationAllowed(tagDescriptor, targetDescriptor, config, allowedDescriptors)) { + const eligibility = await evaluateAssociationEligibility( + store, + targetDescriptor, + config, + allowedDescriptors + ); + const deployedDescriptor = config.envMapping + ? mapDescriptor(descriptor, config.envMapping) + : descriptor; + const deployedProductName = config.envMapping + ? toDeployedName(productName, ResourceType.Product, config.envMapping) + : productName; + const payload = workspaceScoped && linkProperty + ? buildLinkPayload( + context, + linkProperty, + 'products', + deployedProductName, + deployedDescriptor.workspace + ) + : {}; + tagPlans.push({ + descriptor, + deployedDescriptor, + target: targetDescriptor, + payload, + ...eligibility, + }); + } + + const seen = new Set(); + return [...apiPlans, ...groupPlans, ...tagPlans].filter((plan) => { + const key = getResourceDescriptorKey(plan.descriptor); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +export async function planProductPolicyPublication( + store: IArtifactStore, + productDescriptor: ResourceDescriptor, + config: PublishConfig, + allowedDescriptors?: ResourceDescriptor[] +): Promise { + const descriptor: ResourceDescriptor = { + type: ResourceType.ProductPolicy, + nameParts: [getNamePart(productDescriptor.nameParts, 0)], + workspace: productDescriptor.workspace, + }; + const content = await store.readContent(config.sourceDir, descriptor, 'policy'); + if (!content) { + return undefined; + } + return { + descriptor, + eligible: isDescriptorAllowed(descriptor, config, allowedDescriptors), + }; +} + +async function publishProductAssociationPlans( + client: IApimClient, + context: ApimServiceContext, + plans: ProductAssociationPublicationPlan[] +): Promise { + const results: ResourcePublishResult[] = []; + for (const plan of plans) { + if (!plan.eligible) { logger.warn( - `Skipping ProductTag association "${tagDescriptor.nameParts.join('/')}" because its target is excluded by the filter or unavailable in the publish set` + `Skipping ${plan.descriptor.type} association "${plan.descriptor.nameParts.join('/')}": ${plan.reason}` ); + results.push({ + descriptor: plan.descriptor, + status: 'skipped', + action: 'noop', + }); continue; } - + try { - // The workspace ProductTag link references the product (productId), which - // always lives in the workspace, so the link payload is workspace-scoped. - let payload: Record = {}; - if (workspaceScoped && linkProperty) { - payload = buildLinkPayload(context, linkProperty, 'products', productName, productDescriptor.workspace); - } - await client.putResource(context, tagDescriptor, payload); - logger.debug(`Created ProductTag association: ${productName}/${tagName}`); + await client.putResource(context, plan.deployedDescriptor, plan.payload); + logger.debug( + `Created ${plan.descriptor.type} association: ${plan.descriptor.nameParts.join('/')}` + ); + results.push({ + descriptor: plan.descriptor, + status: 'success', + action: 'put', + }); } catch (error) { if (isLinkAlreadyExistsError(error)) { - logger.debug(`ProductTag association already exists: ${productName}/${tagName}`); + logger.debug( + `${plan.descriptor.type} association already exists: ${plan.descriptor.nameParts.join('/')}` + ); + results.push({ + descriptor: plan.descriptor, + status: 'success', + action: 'put', + }); continue; } - logger.warn(`Failed to create ProductTag association ${productName}/${tagName}: ${String(error)}`); + logger.warn( + `Failed to create ${plan.descriptor.type} association ${plan.descriptor.nameParts.join('/')}: ${String(error)}` + ); + results.push({ + descriptor: plan.descriptor, + status: 'failed', + action: 'put', + error: error instanceof Error ? error : new Error(String(error)), + }); } } - - logger.info(`Published ${tagEntries.length} tags for product: ${productName}`); + return results; } function isDescriptorAllowed( @@ -320,41 +362,3 @@ function isDescriptorAllowed( // can still need republishing when its product does. return config.commitId !== undefined; } - -function isAssociationAllowed( - association: ResourceDescriptor, - target: ResourceDescriptor, - config: PublishConfig, - allowedDescriptors?: ResourceDescriptor[] -): boolean { - if (!allowedDescriptors) { - return true; - } - - const effectiveFilter = target.workspace - ? resolveWorkspaceFilter(target.workspace, config.filter) - : config.filter; - if (effectiveFilter && !shouldIncludeResource(target, effectiveFilter)) { - return false; - } - - if (allowedDescriptors.some((allowed) => sameResourceDescriptor(allowed, association))) { - return true; - } - - if (allowedDescriptors.some((allowed) => sameResourceDescriptor(allowed, target))) { - return true; - } - - // Incremental targets contain only changed resources. An unchanged target - // can still exist in APIM and remain explicitly allowed by the filter. - if (config.commitId) { - return true; - } - - if (hasExplicitTypeFilter(target.type, effectiveFilter)) { - return false; - } - - return true; -} diff --git a/src/services/publish-service.ts b/src/services/publish-service.ts index 1419fae6..b00bfa75 100644 --- a/src/services/publish-service.ts +++ b/src/services/publish-service.ts @@ -27,10 +27,16 @@ import { extractRootApiName, resolveWorkspaceFilter, shouldIncludeResource, + shouldReconcileResource, } from './filter-service.js'; // Import from other agents' files (will be created in parallel) -import { publishResource, ResourcePublishResult, buildKnownArtifactSets } from './resource-publisher.js'; +import { + publishResource, + resolveAssociationDeleteDescriptor, + ResourcePublishResult, + buildKnownArtifactSets, +} from './resource-publisher.js'; import { publishApi } from './api-publisher.js'; import { publishProduct } from './product-publisher.js'; import { generateDryRunReport, DryRunReport } from './dry-run-reporter.js'; @@ -41,6 +47,7 @@ import { hasNamedValueOverride } from './override-merger.js'; import { REDACTION_MARKER } from './secret-redactor.js'; import { scanArtifactReferences } from './transitive-resolver.js'; import { validateAndBuildEnvMapping } from './env-mapping-validator.js'; +import { mapDescriptor } from './env-mapper.js'; /** * The APIM Backend properties.type value that identifies a pool backend. @@ -51,13 +58,14 @@ const POOL_BACKEND_TYPE = 'pool'; export interface PublishActionResult { descriptor: ResourceDescriptor; - action: 'put' | 'delete' | 'noop'; + action: 'put' | 'patch' | 'delete' | 'noop'; status: 'success' | 'failed' | 'skipped'; error?: Error; } export interface PublishResult { totalPuts: number; + totalPatches: number; totalDeletes: number; totalErrors: number; totalSkipped: number; @@ -141,6 +149,7 @@ export async function runPublish( return { totalPuts: 0, + totalPatches: 0, totalDeletes: 0, totalErrors: actions.length, totalSkipped: 0, @@ -157,15 +166,17 @@ export async function runPublish( config.service, config, targetDescriptors, - deletedDescriptors + config.deleteUnmatched ? deletedDescriptors : [] ); + const totalErrors = dryRunReport.actions.filter((action) => action.error).length; return { totalPuts: dryRunReport.summary.creates, + totalPatches: dryRunReport.summary.patches, totalDeletes: dryRunReport.summary.deletes, - totalErrors: 0, + totalErrors, totalSkipped: dryRunReport.summary.skips, - exitCode: EXIT_SUCCESS, + exitCode: totalErrors > 0 ? EXIT_PARTIAL : EXIT_SUCCESS, actions: [], dryRunReport, }; @@ -182,13 +193,14 @@ export async function runPublish( // Step 4: Execute DELETEs in reverse dependency order (tier 4 → tier 1) if requested let deleteResults: PublishActionResult[] = []; - if (deletedDescriptors.length > 0) { + if (config.deleteUnmatched && deletedDescriptors.length > 0) { deleteResults = await executeDeletesForDescriptors( client, config.service, - deletedDescriptors + deletedDescriptors, + config ); - } else if (config.deleteUnmatched) { + } else if (config.deleteUnmatched && !config.commitId) { deleteResults = await executeDeletes( client, store, @@ -199,7 +211,8 @@ export async function runPublish( // Step 5: Combine results and determine exit code const allResults = [...putResults, ...deleteResults]; - const totalPuts = putResults.length; + const totalPuts = putResults.filter((result) => result.action === 'put').length; + const totalPatches = putResults.filter((result) => result.action === 'patch').length; const totalDeletes = deleteResults.length; const totalErrors = allResults.filter((r) => r.status === 'failed').length; const totalSkipped = allResults.filter((r) => r.status === 'skipped').length; @@ -208,6 +221,7 @@ export async function runPublish( return { totalPuts, + totalPatches, totalDeletes, totalErrors, totalSkipped, @@ -218,6 +232,7 @@ export async function runPublish( logger.error('Fatal error during publish:', error); return { totalPuts: 0, + totalPatches: 0, totalDeletes: 0, totalErrors: 1, totalSkipped: 0, @@ -261,7 +276,8 @@ async function determinePublishTargets( store, config.sourceDir, targetDescriptors, - availableDescriptors + availableDescriptors, + config.filter ); } @@ -272,7 +288,8 @@ async function expandTransitivePublishTargets( store: IArtifactStore, sourceDir: string, initialDescriptors: ResourceDescriptor[], - availableDescriptors: ResourceDescriptor[] + availableDescriptors: ResourceDescriptor[], + filter: FilterConfig ): Promise { const availableByKey = new Map( availableDescriptors.map((descriptor) => [getResourceDescriptorKey(descriptor), descriptor]) @@ -288,6 +305,12 @@ async function expandTransitivePublishTargets( if (descriptor.type !== ResourceType.Api && descriptor.type !== ResourceType.Product) { continue; } + if ( + descriptor.type === ResourceType.Api && + isApiRevisionName(descriptor.nameParts[0] ?? '') + ) { + continue; + } const parentName = descriptor.type === ResourceType.Api ? extractRootApiName(descriptor.nameParts[0] ?? '').toLowerCase() : (descriptor.nameParts[0] ?? '').toLowerCase(); @@ -297,16 +320,24 @@ async function expandTransitivePublishTargets( if ( (!isSameResourceType && !isOwnedChild) || candidate.workspace !== descriptor.workspace || - candidate.nameParts.length < 1 + candidate.nameParts.length < 1 || + !shouldReconcileResource(candidate, filter) ) { continue; } const candidateName = candidate.type === ResourceType.Api ? extractRootApiName(candidate.nameParts[0] ?? '').toLowerCase() : (candidate.nameParts[0] ?? '').toLowerCase(); - if (candidateName === parentName && !scanQueued.has(getResourceDescriptorKey(candidate))) { - scanQueue.push(candidate); - scanQueued.add(getResourceDescriptorKey(candidate)); + if (candidateName === parentName) { + const candidateKey = getResourceDescriptorKey(candidate); + if (!included.has(candidateKey)) { + included.add(candidateKey); + expanded.push(candidate); + } + if (!scanQueued.has(candidateKey)) { + scanQueue.push(candidate); + scanQueued.add(candidateKey); + } } } } @@ -737,28 +768,32 @@ async function publishTier( store, context, descriptor, - config + config, + config.filter ? allTargetDescriptors : undefined ); } - return convertToActionResult(publishResult); + const relatedResults = publishResult.relatedResults?.map(convertToActionResult) ?? []; + return publishResult.suppressPrimaryResult + ? relatedResults + : [convertToActionResult(publishResult), ...relatedResults]; } catch (error) { logger.error( `Failed to publish ${buildResourceLabel(descriptor)}:`, error ); - return { + return [{ descriptor, action: 'put' as const, status: 'failed' as const, error: error instanceof Error ? error : new Error(String(error)), - }; + }]; } }); const taskResults = await runParallel(tasks, 5); - return taskResults.map((tr, index) => { + return taskResults.flatMap((tr, index) => { if (tr.status === 'fulfilled' && tr.value) { return tr.value; } else { @@ -767,12 +802,12 @@ async function publishTier( if (!descriptor) { throw new Error('No descriptor found for failed task'); } - return { + return [{ descriptor, action: 'put' as const, status: 'failed' as const, error: tr.reason || new Error('Unknown error'), - }; + }]; } }); } @@ -845,7 +880,13 @@ async function executeDeletes( logger.debug(`Deleting ${deleteDescriptors.length} unmatched resources`); - return executeDeletesForDescriptors(client, context, deleteDescriptors); + return executeDeletesForDescriptors( + client, + context, + deleteDescriptors, + config, + true + ); } /** @@ -854,7 +895,9 @@ async function executeDeletes( async function executeDeletesForDescriptors( client: IApimClient, context: ApimServiceContext, - deleteDescriptors: ResourceDescriptor[] + deleteDescriptors: ResourceDescriptor[], + config: PublishConfig, + descriptorsAreDeployed = false ): Promise { if (deleteDescriptors.length === 0) { logger.debug('No resources to delete'); @@ -880,7 +923,13 @@ async function executeDeletesForDescriptors( logger.debug(`Deleting tier ${tier}: ${descriptors.length} resources`); - const tierResults = await deleteTier(client, context, descriptors); + const tierResults = await deleteTier( + client, + context, + descriptors, + config, + descriptorsAreDeployed + ); results.push(...tierResults); @@ -899,11 +948,28 @@ async function executeDeletesForDescriptors( async function deleteTier( client: IApimClient, context: ApimServiceContext, - descriptors: ResourceDescriptor[] + descriptors: ResourceDescriptor[], + config: PublishConfig, + descriptorsAreDeployed: boolean ): Promise { const tasks = descriptors.map((descriptor) => async () => { try { - const deleted = await client.deleteResource(context, descriptor); + const deployedDescriptor = config.envMapping && !descriptorsAreDeployed + ? mapDescriptor(descriptor, config.envMapping) + : descriptor; + const resolvedDescriptor = await resolveAssociationDeleteDescriptor( + client, + context, + deployedDescriptor + ); + if (!resolvedDescriptor) { + return { + descriptor, + action: 'delete' as const, + status: 'skipped' as const, + }; + } + const deleted = await client.deleteResource(context, resolvedDescriptor); return { descriptor, diff --git a/src/services/resource-publisher.ts b/src/services/resource-publisher.ts index c918906d..66396cbe 100644 --- a/src/services/resource-publisher.ts +++ b/src/services/resource-publisher.ts @@ -9,12 +9,20 @@ import type { IApimClient } from '../clients/iapim-client.js'; import type { IArtifactStore } from '../clients/iartifact-store.js'; -import type { ApimServiceContext, ResourceDescriptor } from '../models/types.js'; +import type { + ApimServiceContext, + AssociationEntry, + ResourceDescriptor, +} from '../models/types.js'; import type { PublishConfig } from '../models/config.js'; import { ResourceType, RESOURCE_TYPE_METADATA } from '../models/resource-types.js'; import { applyOverrides } from './override-merger.js'; import { checkKeyVaultSecretAccess } from './keyvault-checker.js'; -import { getNamePart } from '../lib/resource-path.js'; +import { + getNamePart, + getResourceDescriptorKey, + sameResourceDescriptor, +} from '../lib/resource-path.js'; import { isAutoGeneratedId } from '../lib/auto-generated.js'; import { isWorkspaceScope, buildLinkPayload } from '../lib/workspace-link.js'; import { logger } from '../lib/logger.js'; @@ -34,8 +42,22 @@ export type { KnownArtifactSets } from '../models/config.js'; export interface ResourcePublishResult { descriptor: ResourceDescriptor; status: 'success' | 'failed' | 'skipped'; - action: 'put' | 'delete' | 'noop'; + action: 'put' | 'patch' | 'delete' | 'noop'; error?: Error; + relatedResults?: ResourcePublishResult[]; + suppressPrimaryResult?: boolean; +} + +export interface PublishEligibility { + eligible: boolean; + reason?: string; +} + +export interface AssociationPublicationPlan extends PublishEligibility { + descriptor: ResourceDescriptor; + deployedDescriptor: ResourceDescriptor; + target: ResourceDescriptor; + payload: Record; } /** @@ -122,8 +144,10 @@ export async function publishResource( store: IArtifactStore, context: ApimServiceContext, descriptor: ResourceDescriptor, - config: PublishConfig + config: PublishConfig, + allowedDescriptors?: ResourceDescriptor[] ): Promise { + let attemptedPut = false; try { // Handle association types (ProductApi, ProductGroup, GatewayApi) const associationType = ASSOCIATION_TYPES.get(descriptor.type); @@ -134,19 +158,20 @@ export async function publishResource( context, descriptor, config, - associationType + associationType, + allowedDescriptors ); } if (descriptor.type === ResourceType.ApiTag) { - const targetDescriptor: ResourceDescriptor = { - type: ResourceType.Tag, - nameParts: [getNamePart(descriptor.nameParts, 1)], - workspace: descriptor.workspace, - }; - if (!(await isTargetAllowedByFilter(store, targetDescriptor, config))) { + const eligibility = await evaluateResourceEligibility( + store, + descriptor, + config + ); + if (!eligibility.eligible) { logger.warn( - `Skipping ApiTag association "${descriptor.nameParts.join('/')}" because its target is excluded by the filter or was never extracted` + `Skipping ApiTag association "${descriptor.nameParts.join('/')}": ${eligibility.reason}` ); return { descriptor, @@ -288,31 +313,16 @@ export async function publishResource( // "master" subscription), as APIM treats these as read-only system resources // and returns ValidationError when attempting to update them. if (descriptor.type === ResourceType.Subscription) { - const props = json.properties as Record | undefined; - const scope = props?.scope as string | undefined; const subscriptionName = getNamePart(descriptor.nameParts, 0); - - // Built-in master subscription has scope ending with the service path (no /apis or /products suffix) - // Skip it since APIM doesn't allow updates to built-in subscriptions - if (scope && (scope.endsWith('/') || (!scope.includes('/apis') && !scope.includes('/products')))) { - return { - descriptor, - status: 'skipped', - action: 'noop', - }; - } - - if (isAutoGeneratedProductSubscription(subscriptionName, scope)) { - return { - descriptor, - status: 'skipped', - action: 'noop', - }; - } - - if (!(await areSubscriptionTargetsAllowed(store, json, descriptor, config))) { + const eligibility = await evaluateResourceEligibility( + store, + descriptor, + config, + json + ); + if (!eligibility.eligible) { logger.warn( - `Skipping subscription "${subscriptionName}" because its API or Product target is excluded by the filter or was never extracted` + `Skipping subscription "${subscriptionName}": ${eligibility.reason}` ); return { descriptor, @@ -321,7 +331,12 @@ export async function publishResource( }; } - json = normalizeSubscriptionScope(json, context, config.envMapping); + json = normalizeSubscriptionScope( + json, + context, + descriptor.workspace, + config.envMapping + ); } // ApiRelease: normalize properties.apiId from source ARM path to target ARM path. @@ -334,6 +349,19 @@ export async function publishResource( json = normalizeApiReleaseApiId(json, context, config.envMapping); } + if (descriptor.type === ResourceType.Api) { + json = normalizeApiVersionSetId( + json, + context, + descriptor.workspace, + config.envMapping + ); + } + + if (descriptor.type === ResourceType.Backend) { + json = normalizeBackendPoolServices(json, context, config.envMapping); + } + // API Revisions (e.g., "my-api;rev=2") need sourceApiId so APIM knows which // base API to copy structure from. Also strip null properties that cause // validation errors in APIM's revision creation. @@ -360,24 +388,7 @@ export async function publishResource( } } - // Apply api path prefix when envMapping provides one and no per-API path override - if (descriptor.type === ResourceType.Api && config.envMapping?.apiPathPrefix !== undefined) { - const canonicalApiName = getNamePart(descriptor.nameParts, 0).split(';rev=')[0]; - const hasPathOverride = hasExplicitPropertyOverride(canonicalApiName, 'path', config.overrides?.apis); - if (!hasPathOverride) { - const props = json.properties as Record | undefined; - if (typeof props?.path === 'string') { - const rawPath = props.path; - const prefix = config.envMapping.apiPathPrefix; - // Avoid double slashes - const newPath = - prefix.endsWith('/') && rawPath.startsWith('/') - ? prefix + rawPath.slice(1) - : prefix + rawPath; - json = { ...json, properties: { ...props, path: newPath } }; - } - } - } + json = applyApiPathPrefix(json, descriptor, config); // For PolicyFragment: rewrite cross-resource refs in properties.value (policy XML) if (descriptor.type === ResourceType.PolicyFragment && config.envMapping && config.knownArtifactSets) { @@ -394,6 +405,7 @@ export async function publishResource( : descriptor; // PUT to APIM + attemptedPut = true; await client.putResource(context, deployedDescriptor, json); return { @@ -405,7 +417,7 @@ export async function publishResource( return { descriptor, status: 'failed', - action: 'noop', + action: attemptedPut ? 'put' : 'noop', error: error instanceof Error ? error : new Error(String(error)), }; } @@ -420,65 +432,65 @@ async function publishAssociation( context: ApimServiceContext, descriptor: ResourceDescriptor, config: PublishConfig, - associationType: 'apis' | 'groups' + associationType: 'apis' | 'groups', + allowedDescriptors?: ResourceDescriptor[] ): Promise { try { - // buildAssociationFilePath (called inside readAssociation) only accepts - // Product or Gateway descriptors — not the association child types - // (ProductApi, ProductGroup, GatewayApi). Derive the parent descriptor. - const parentType = ASSOCIATION_PARENT_TYPES.get(descriptor.type)!; - const parentDescriptor: ResourceDescriptor = { - type: parentType, - nameParts: [getNamePart(descriptor.nameParts, 0)], - workspace: descriptor.workspace, - }; - const entries = await store.readAssociation( - config.sourceDir, - parentDescriptor, - associationType + const plans = await planAssociationPublications( + store, + context, + descriptor, + config, + associationType, + allowedDescriptors ); - // Create association for each name - for (const entry of entries) { - const rawAssocDescriptor: ResourceDescriptor = { - type: descriptor.type, - nameParts: [getNamePart(descriptor.nameParts, 0), entry.name], - workspace: descriptor.workspace, - }; - const targetDescriptor: ResourceDescriptor = { - type: descriptor.type === ResourceType.GatewayApi - ? ResourceType.Api - : descriptor.type === ResourceType.ProductApi - ? ResourceType.Api - : ResourceType.Group, - nameParts: [entry.name], - workspace: entry.scope === 'service' ? undefined : descriptor.workspace, - }; - if (!(await isTargetAllowedByFilter(store, targetDescriptor, config))) { + const relatedResults: ResourcePublishResult[] = []; + for (const plan of plans) { + if (!plan.eligible) { logger.warn( - `Skipping ${descriptor.type} association "${rawAssocDescriptor.nameParts.join('/')}" because its target is excluded by the filter or was never extracted` + `Skipping ${descriptor.type} association "${plan.descriptor.nameParts.join('/')}": ${plan.reason}` ); + relatedResults.push({ + descriptor: plan.descriptor, + status: 'skipped', + action: 'noop', + }); continue; } - // Apply env-mapping to affix both the parent and child name segments - const assocDescriptor = config.envMapping - ? mapDescriptor(rawAssocDescriptor, config.envMapping) - : rawAssocDescriptor; try { - // PUT empty body for association (APIM uses PUT to create association) - await client.putResource(context, assocDescriptor, {}); + await client.putResource(context, plan.deployedDescriptor, plan.payload); + relatedResults.push({ + descriptor: plan.descriptor, + status: 'success', + action: 'put', + }); } catch (error) { // 409 means the link already exists — desired state is in place. - if (!isLinkAlreadyExistsError(error)) { - throw error; + if (isLinkAlreadyExistsError(error)) { + relatedResults.push({ + descriptor: plan.descriptor, + status: 'success', + action: 'put', + }); + continue; } + relatedResults.push({ + descriptor: plan.descriptor, + status: 'failed', + action: 'put', + error: error instanceof Error ? error : new Error(String(error)), + }); + break; } } return { descriptor, - status: 'success', - action: 'put', + status: relatedResults.some((result) => result.status === 'failed') ? 'failed' : 'success', + action: 'noop', + relatedResults, + suppressPrimaryResult: true, }; } catch (error) { return { @@ -490,43 +502,281 @@ async function publishAssociation( } } -async function areSubscriptionTargetsAllowed( +export async function planAssociationPublications( store: IArtifactStore, - json: Record, + context: ApimServiceContext, descriptor: ResourceDescriptor, - config: PublishConfig -): Promise { - for (const target of findSubscriptionTargets(json, descriptor.workspace)) { - if (!(await isTargetAllowedByFilter(store, target, config))) { - return false; + config: PublishConfig, + associationType?: 'apis' | 'groups', + allowedDescriptors?: ResourceDescriptor[] +): Promise { + const resolvedAssociationType = associationType ?? ASSOCIATION_TYPES.get(descriptor.type); + const parentType = ASSOCIATION_PARENT_TYPES.get(descriptor.type); + if (!resolvedAssociationType || !parentType) { + return []; + } + + const parentName = getNamePart(descriptor.nameParts, 0); + const parentDescriptor: ResourceDescriptor = { + type: parentType, + nameParts: [parentName], + workspace: descriptor.workspace, + }; + const entries = await store.readAssociation( + config.sourceDir, + parentDescriptor, + resolvedAssociationType + ); + const plans: AssociationPublicationPlan[] = []; + const seen = new Set(); + + for (const entry of entries) { + const plan = await buildAssociationPublicationPlan( + store, + context, + descriptor.type, + parentName, + descriptor.workspace, + entry, + config, + allowedDescriptors + ); + const key = getResourceDescriptorKey(plan.descriptor); + if (!seen.has(key)) { + seen.add(key); + plans.push(plan); } } - return true; + + return plans; +} + +async function buildAssociationPublicationPlan( + store: IArtifactStore, + context: ApimServiceContext, + type: ResourceType, + parentName: string, + workspace: string | undefined, + entry: AssociationEntry, + config: PublishConfig, + allowedDescriptors?: ResourceDescriptor[] +): Promise { + const descriptor: ResourceDescriptor = { + type, + nameParts: [parentName, entry.name], + workspace, + ...(entry.scope ? { targetScope: entry.scope } : {}), + }; + const target: ResourceDescriptor = { + type: type === ResourceType.ProductGroup ? ResourceType.Group : ResourceType.Api, + nameParts: [entry.name], + workspace: entry.scope === 'service' ? undefined : workspace, + }; + const eligibility = await evaluateAssociationEligibility( + store, + target, + config, + allowedDescriptors + ); + const metadata = RESOURCE_TYPE_METADATA[type]; + const workspaceScoped = !!workspace || isWorkspaceScope(context); + const segment = target.type === ResourceType.Group ? 'groups' : 'apis'; + const deployedDescriptor = config.envMapping + ? mapDescriptor(descriptor, config.envMapping) + : descriptor; + const deployedTarget = config.envMapping + ? mapDescriptor(target, config.envMapping) + : target; + const payload = workspaceScoped && metadata.workspaceLinkIdProperty + ? buildLinkPayload( + context, + metadata.workspaceLinkIdProperty, + segment, + getNamePart(deployedTarget.nameParts, 0), + deployedDescriptor.workspace, + entry.scope + ) + : {}; + + return { descriptor, deployedDescriptor, target, payload, ...eligibility }; } -async function isTargetAllowedByFilter( +export async function evaluateAssociationEligibility( store: IArtifactStore, target: ResourceDescriptor, - config: PublishConfig -): Promise { + config: PublishConfig, + allowedDescriptors?: ResourceDescriptor[] +): Promise { if (!config.filter) { - return true; + return { eligible: true }; } const effectiveFilter = target.workspace ? resolveWorkspaceFilter(target.workspace, config.filter) : config.filter; if (effectiveFilter && !shouldIncludeResource(target, effectiveFilter)) { - return false; + return { eligible: false, reason: 'target is excluded by the filter' }; + } + + if (allowedDescriptors) { + if ( + allowedDescriptors.some((allowed) => sameResourceDescriptor(allowed, target)) + ) { + return { eligible: true }; + } + + if (config.commitId) { + const exists = (await store.readResource(config.sourceDir, target)) !== undefined; + return exists + ? { eligible: true } + : { eligible: false, reason: 'target was never extracted' }; + } + + if (hasExplicitTypeFilter(target.type, effectiveFilter)) { + return { eligible: false, reason: 'target is unavailable in the publish set' }; + } + + return { eligible: true }; } - // Explicitly scoped types must actually exist among the artifacts, not - // just pass the filter — catches dangling references (mirrors publishProduct). if (hasExplicitTypeFilter(target.type, effectiveFilter)) { - return (await store.readResource(config.sourceDir, target)) !== undefined; + const exists = (await store.readResource(config.sourceDir, target)) !== undefined; + return exists + ? { eligible: true } + : { eligible: false, reason: 'target was never extracted' }; + } + + return { eligible: true }; +} + +export async function resolveAssociationDeleteDescriptor( + client: IApimClient, + context: ApimServiceContext, + descriptor: ResourceDescriptor +): Promise { + if ( + !descriptor.workspace || + ![ + ResourceType.ProductApi, + ResourceType.ProductGroup, + ResourceType.ProductTag, + ResourceType.ApiTag, + ].includes(descriptor.type) + ) { + return descriptor; + } + + const linkProperty = + RESOURCE_TYPE_METADATA[descriptor.type].workspaceLinkIdProperty; + if (!linkProperty) { + return descriptor; + } + + const isTagParent = + descriptor.type === ResourceType.ProductTag || + descriptor.type === ResourceType.ApiTag; + const parent: ResourceDescriptor = isTagParent + ? { + type: ResourceType.Tag, + nameParts: [getNamePart(descriptor.nameParts, 1)], + workspace: descriptor.workspace, + } + : { + type: ResourceType.Product, + nameParts: [getNamePart(descriptor.nameParts, 0)], + workspace: descriptor.workspace, + }; + const expectedTarget = isTagParent + ? getNamePart(descriptor.nameParts, 0) + : getNamePart(descriptor.nameParts, 1); + + for await (const link of client.listResources(context, descriptor.type, parent)) { + const properties = link.properties as Record | undefined; + const targetId = properties?.[linkProperty]; + const linkName = link.name; + if ( + typeof targetId !== 'string' || + typeof linkName !== 'string' || + getArmResourceName(targetId).toLowerCase() !== expectedTarget.toLowerCase() || + (descriptor.targetScope !== undefined && + getAssociationTargetScope(targetId) !== descriptor.targetScope) + ) { + continue; + } + + return { + type: descriptor.type, + nameParts: isTagParent + ? [linkName, getNamePart(descriptor.nameParts, 1)] + : [getNamePart(descriptor.nameParts, 0), linkName], + workspace: descriptor.workspace, + ...(descriptor.targetScope ? { targetScope: descriptor.targetScope } : {}), + }; + } + + function getAssociationTargetScope(resourceId: string): 'service' | 'workspace' { + return /\/workspaces\/[^/]+\//i.test(resourceId) ? 'workspace' : 'service'; } - return true; + return undefined; +} + +function getArmResourceName(resourceId: string): string { + const segment = resourceId.split('/').filter(Boolean).at(-1) ?? ''; + try { + return decodeURIComponent(segment); + } catch { + return segment; + } +} + +export async function evaluateResourceEligibility( + store: IArtifactStore, + descriptor: ResourceDescriptor, + config: PublishConfig, + json?: Record +): Promise { + if (descriptor.type === ResourceType.ApiTag) { + const target: ResourceDescriptor = { + type: ResourceType.Tag, + nameParts: [getNamePart(descriptor.nameParts, 1)], + workspace: descriptor.workspace, + }; + return evaluateAssociationEligibility(store, target, config); + } + + if (descriptor.type !== ResourceType.Subscription || !json) { + return { eligible: true }; + } + + const properties = json.properties as Record | undefined; + const scope = properties?.scope as string | undefined; + if ( + scope && + (scope.endsWith('/') || (!scope.includes('/apis') && !scope.includes('/products'))) + ) { + return { eligible: false, reason: 'root-scoped subscriptions are managed by APIM' }; + } + + if (isAutoGeneratedProductSubscription(getNamePart(descriptor.nameParts, 0), scope)) { + return { eligible: false, reason: 'auto-generated product subscription' }; + } + + for (const target of findSubscriptionTargets(json, descriptor.workspace)) { + const eligibility = await evaluateAssociationEligibility( + store, + target, + config + ); + if (!eligibility.eligible) { + return { + eligible: false, + reason: `${target.type} target "${target.nameParts.join('/')}" ${eligibility.reason}`, + }; + } + } + + return { eligible: true }; } /** @@ -539,6 +789,7 @@ async function publishWiki( descriptor: ResourceDescriptor, config: PublishConfig ): Promise { + let attemptedPut = false; try { const wikiContent = await store.readContent( config.sourceDir, @@ -569,6 +820,7 @@ async function publishWiki( const deployedDescriptor = config.envMapping ? mapDescriptor(descriptor, config.envMapping) : descriptor; + attemptedPut = true; await client.putResource(context, deployedDescriptor, payload); return { @@ -580,7 +832,7 @@ async function publishWiki( return { descriptor, status: 'failed', - action: 'noop', + action: attemptedPut ? 'put' : 'noop', error: error instanceof Error ? error : new Error(String(error)), }; } @@ -598,6 +850,7 @@ async function publishPolicy( descriptor: ResourceDescriptor, config: PublishConfig ): Promise { + let attemptedPut = false; try { const policyContent = await store.readContent( config.sourceDir, @@ -656,6 +909,7 @@ async function publishPolicy( const deployedDescriptor = config.envMapping ? mapDescriptor(descriptor, config.envMapping) : descriptor; + attemptedPut = true; await client.putResource(context, deployedDescriptor, mergedPayload); return { @@ -667,7 +921,7 @@ async function publishPolicy( return { descriptor, status: 'failed', - action: 'noop', + action: attemptedPut ? 'put' : 'noop', error: error instanceof Error ? error : new Error(String(error)), }; } @@ -690,6 +944,7 @@ async function publishPolicy( function normalizeSubscriptionScope( json: Record, context: ApimServiceContext, + workspace?: string, envMapping?: EnvMapping ): Record { const props = json.properties as Record | undefined; @@ -702,10 +957,34 @@ function normalizeSubscriptionScope( // APIM-relative segment. Derive it from baseUrl by stripping the protocol+host. const armPathPrefix = context.baseUrl.replace(/^https?:\/\/[^/]+/, ''); - if (scope.startsWith(armPathPrefix)) { - let relativeScope = scope.slice(armPathPrefix.length) || '/'; + const localScopeMatch = scope.match(/^\/(apis|products)\/([^/]+)$/i); + if (localScopeMatch?.[1] && localScopeMatch[2]) { + const segment = localScopeMatch[1].toLowerCase(); + const type = segment === 'apis' ? ResourceType.Api : ResourceType.Product; + const name = envMapping + ? toDeployedName(localScopeMatch[2], type, envMapping) + : localScopeMatch[2]; + const relativeScope = `/${segment}/${name}`; + const normalizedScope = workspace + ? `${armPathPrefix}/workspaces/${ + envMapping + ? toDeployedName(workspace, ResourceType.Workspace, envMapping) + : workspace + }${relativeScope}` + : relativeScope; + return { + ...json, + properties: { ...props, scope: normalizedScope }, + }; + } + + const serviceScopeMatch = scope.match( + /^\/subscriptions\/[^/]+\/resourceGroups\/[^/]+\/providers\/Microsoft\.ApiManagement\/service\/[^/]+(\/.*)?$/i + ); + if (serviceScopeMatch) { + let relativeScope = serviceScopeMatch[1] || '/'; - // Affix the trailing resource name when envMapping applies + // Affix the target resource name when envMapping applies. if (envMapping && relativeScope !== '/') { if (relativeScope.startsWith('/apis/')) { const apiName = relativeScope.slice('/apis/'.length); @@ -717,18 +996,67 @@ function normalizeSubscriptionScope( if (productName) { relativeScope = `/products/${toDeployedName(productName, ResourceType.Product, envMapping)}`; } + } else { + const workspaceTarget = relativeScope.match( + /^(\/workspaces\/[^/]+\/)(apis|products)\/([^/]+)$/i + ); + if (workspaceTarget?.[1] && workspaceTarget[2] && workspaceTarget[3]) { + const workspaceName = workspaceTarget[1].split('/')[2] ?? ''; + const deployedWorkspace = toDeployedName( + workspaceName, + ResourceType.Workspace, + envMapping + ); + const segment = workspaceTarget[2].toLowerCase(); + const type = segment === 'apis' ? ResourceType.Api : ResourceType.Product; + relativeScope = + `/workspaces/${deployedWorkspace}/${segment}/${ + toDeployedName(workspaceTarget[3], type, envMapping) + }`; + } } } + const normalizedScope = relativeScope.startsWith('/workspaces/') + ? `${armPathPrefix}${relativeScope}` + : relativeScope; return { ...json, - properties: { ...props, scope: relativeScope }, + properties: { ...props, scope: normalizedScope }, }; } return json; } +export function applyApiPathPrefix( + json: Record, + descriptor: ResourceDescriptor, + config: PublishConfig +): Record { + if (descriptor.type !== ResourceType.Api || config.envMapping?.apiPathPrefix === undefined) { + return json; + } + + const canonicalApiName = getNamePart(descriptor.nameParts, 0).split(';rev=')[0]; + if (hasExplicitPropertyOverride(canonicalApiName, 'path', config.overrides?.apis)) { + return json; + } + + const props = json.properties as Record | undefined; + if (typeof props?.path !== 'string') { + return json; + } + + const rawPath = props.path; + const prefix = config.envMapping.apiPathPrefix; + const path = + prefix.endsWith('/') && rawPath.startsWith('/') + ? prefix + rawPath.slice(1) + : prefix + rawPath; + return { ...json, properties: { ...props, path } }; +} + /** * Normalise the `properties.apiId` field of an ApiRelease resource. * @@ -782,6 +1110,99 @@ function normalizeApiReleaseApiId( return json; } +export function normalizeApiVersionSetId( + json: Record, + context: ApimServiceContext, + workspace?: string, + envMapping?: EnvMapping +): Record { + const props = json.properties as Record | undefined; + const versionSetId = props?.apiVersionSetId; + if (typeof versionSetId !== 'string') { + return json; + } + + const versionSetName = getArmResourceName(versionSetId); + if (!versionSetName) { + return json; + } + + const targetArmPrefix = context.baseUrl.replace(/^https?:\/\/[^/]+/, ''); + const deployedVersionSet = envMapping + ? toDeployedName(versionSetName, ResourceType.VersionSet, envMapping) + : versionSetName; + const deployedWorkspace = workspace + ? envMapping + ? toDeployedName(workspace, ResourceType.Workspace, envMapping) + : workspace + : undefined; + const targetId = deployedWorkspace + ? `${targetArmPrefix}/workspaces/${deployedWorkspace}/apiVersionSets/${deployedVersionSet}` + : `${targetArmPrefix}/apiVersionSets/${deployedVersionSet}`; + + return { + ...json, + properties: { ...props, apiVersionSetId: targetId }, + }; +} + +function normalizeBackendPoolServices( + json: Record, + context: ApimServiceContext, + envMapping?: EnvMapping +): Record { + const props = json.properties as Record | undefined; + const pool = props?.pool as Record | undefined; + if (!pool || !Array.isArray(pool.services)) { + return json; + } + + const targetArmPrefix = context.baseUrl.replace(/^https?:\/\/[^/]+/, ''); + const services = pool.services.map((service): unknown => { + if (!service || typeof service !== 'object') { + return service; + } + + const typedService = service as Record; + const id = typedService.id; + if (typeof id !== 'string') { + return typedService; + } + + const workspaceMatch = id.match(/\/workspaces\/([^/]+)\/backends\/([^/]+)$/i); + const serviceMatch = id.match(/\/backends\/([^/]+)$/i); + const backendName = getArmResourceName(id); + if (!backendName || (!workspaceMatch && !serviceMatch)) { + return typedService; + } + + const deployedBackend = envMapping + ? toDeployedName(backendName, ResourceType.Backend, envMapping) + : backendName; + const sourceWorkspace = workspaceMatch?.[1] + ? getArmResourceName(`/workspaces/${workspaceMatch[1]}`) + : undefined; + const deployedWorkspace = sourceWorkspace + ? envMapping + ? toDeployedName(sourceWorkspace, ResourceType.Workspace, envMapping) + : sourceWorkspace + : undefined; + const targetId = deployedWorkspace + ? `${targetArmPrefix}/workspaces/${deployedWorkspace}/backends/${deployedBackend}` + : `${targetArmPrefix}/backends/${deployedBackend}`; + + return { ...typedService, id: targetId }; + }); + + return { + ...json, + properties: { + ...props, + pool: { ...pool, services }, + }, + }; +} + /** * Normalise MCP tool operationIds for MCP APIs. * @@ -895,6 +1316,7 @@ async function publishWorkspaceApiTagLink( descriptor: ResourceDescriptor, config: PublishConfig ): Promise { + let attemptedPut = false; try { const canonicalApiName = getNamePart(descriptor.nameParts, 0); const deployedApiName = config.envMapping @@ -904,12 +1326,19 @@ async function publishWorkspaceApiTagLink( if (!meta.workspaceLinkIdProperty) { throw new Error(`Missing workspaceLinkIdProperty in metadata for ${ResourceType.ApiTag}`); } - const payload = buildLinkPayload(context, meta.workspaceLinkIdProperty, 'apis', deployedApiName, descriptor.workspace); const deployedDescriptor = config.envMapping ? mapDescriptor(descriptor, config.envMapping) : descriptor; + const payload = buildLinkPayload( + context, + meta.workspaceLinkIdProperty, + 'apis', + deployedApiName, + deployedDescriptor.workspace + ); try { + attemptedPut = true; await client.putResource(context, deployedDescriptor, payload); } catch (error) { // 409 means the tag/api link already exists — desired state is in place. @@ -927,7 +1356,7 @@ async function publishWorkspaceApiTagLink( return { descriptor, status: 'failed', - action: 'noop', + action: attemptedPut ? 'put' : 'noop', error: error instanceof Error ? error : new Error(String(error)), }; } diff --git a/src/services/transitive-resolver.ts b/src/services/transitive-resolver.ts index 51f961b8..1251e823 100644 --- a/src/services/transitive-resolver.ts +++ b/src/services/transitive-resolver.ts @@ -352,7 +352,15 @@ function extractResourceNameFromId(value: string, segment: string): string | und function workspaceFromReference(value: string, fallback?: string): string | undefined { const match = value.match(/\/workspaces\/([^/]+)/i); - return match?.[1] ? decodeArmSegment(match[1]) : (value.startsWith('/') ? undefined : fallback); + if (match?.[1]) { + return decodeArmSegment(match[1]); + } + + const isFullArmId = + /\/subscriptions\/[^/]+\/resourceGroups\/[^/]+\/providers\/Microsoft\.ApiManagement\/service\/[^/]+/i.test( + value + ); + return isFullArmId ? undefined : fallback; } function decodeArmSegment(value: string): string { diff --git a/tests/unit/cli/publish-command.test.ts b/tests/unit/cli/publish-command.test.ts index abf2ad02..7af27632 100644 --- a/tests/unit/cli/publish-command.test.ts +++ b/tests/unit/cli/publish-command.test.ts @@ -138,8 +138,8 @@ describe('publish-command', () => { }); describe('mutually exclusive publish modes', () => { - it('should treat commit-id and delete-unmatched as conflicting', () => { - expect(hasMutuallyExclusivePublishOptions(true, 'abc123')).toBe(true); + it('should allow explicit delete-unmatched in incremental mode', () => { + expect(hasMutuallyExclusivePublishOptions(true, 'abc123')).toBe(false); }); it('should allow delete-unmatched in full publish mode', () => { diff --git a/tests/unit/lib/resource-path.test.ts b/tests/unit/lib/resource-path.test.ts index 174e0f21..f70b8bed 100644 --- a/tests/unit/lib/resource-path.test.ts +++ b/tests/unit/lib/resource-path.test.ts @@ -306,10 +306,30 @@ describe('parseArtifactPath', () => { expect(result!.nameParts).toEqual(['gw1']); }); - it('should ignore ProductApi association files because product publisher handles them', () => { + it.each(['apis.json', 'groups.json', 'tags.json'])( + 'should map Product association file %s to its parent Product', + (associationFile) => { + const filePath = path.join(baseDir, 'products', 'starter', associationFile); + const result = parseArtifactPath(baseDir, filePath); + expect(result).toEqual({ + type: ResourceType.Product, + nameParts: ['starter'], + workspace: undefined, + }); + } + ); + + it('should map workspace Product association files to their parent Product', () => { const filePath = path.join(baseDir, 'products', 'starter', 'apis.json'); - const result = parseArtifactPath(baseDir, filePath); - expect(result).toBeUndefined(); + const result = parseArtifactPath( + baseDir, + path.join(baseDir, 'workspaces', 'team', path.relative(baseDir, filePath)) + ); + expect(result).toEqual({ + type: ResourceType.Product, + nameParts: ['starter'], + workspace: 'team', + }); }); it('should parse workspace-scoped resource', () => { diff --git a/tests/unit/services/api-publisher.test.ts b/tests/unit/services/api-publisher.test.ts index edfee01f..607cf374 100644 --- a/tests/unit/services/api-publisher.test.ts +++ b/tests/unit/services/api-publisher.test.ts @@ -11,6 +11,7 @@ import { ApimServiceContext, ResourceDescriptor } from '../../../src/models/type import { PublishConfig } from '../../../src/models/config.js'; import { LogLevel } from '../../../src/lib/logger.js'; import { applyOverrides } from '../../../src/services/override-merger.js'; +import { DEFAULT_APPLIES_TO } from '../../../src/services/env-mapper.js'; // Mock resource-publisher so we can verify call sequence const mockPublishResource = vi.fn(); @@ -94,6 +95,71 @@ describe('api-publisher', () => { }); describe('publishApi', () => { + it('reports planning failures without a PUT action', async () => { + const client = createMockClient(); + const store = createMockStore(); + store.listResources.mockRejectedValue(new Error('Artifact listing failed')); + const apiDescriptor: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['orders-api'], + }; + + const result = await publishApi( + client, + store, + testContext, + apiDescriptor, + testConfig + ); + + expect(result).toMatchObject({ + descriptor: apiDescriptor, + status: 'failed', + action: 'noop', + error: expect.objectContaining({ message: 'Artifact listing failed' }), + }); + expect(client.putResource).not.toHaveBeenCalled(); + }); + + it('applies environment mapping to the root API descriptor and path', async () => { + const client = createMockClient(); + const store = createMockStore(); + store.readResource.mockResolvedValue({ + name: 'orders-api', + properties: { + path: '/orders', + apiVersionSetId: + '/subscriptions/source/resourceGroups/source/providers/Microsoft.ApiManagement/service/source/apiVersionSets/orders', + }, + }); + const apiDescriptor: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['orders-api'], + }; + const config: PublishConfig = { + ...testConfig, + envMapping: { + prefix: 'dev-', + suffix: '', + apiPathPrefix: 'dev/', + appliesTo: DEFAULT_APPLIES_TO, + }, + }; + + await publishApi(client, store, testContext, apiDescriptor, config); + + expect(client.putResource).toHaveBeenCalledWith( + testContext, + { type: ResourceType.Api, nameParts: ['dev-orders-api'] }, + expect.objectContaining({ + properties: expect.objectContaining({ + path: 'dev/orders', + apiVersionSetId: `${testContext.baseUrl.replace(/^https?:\/\/[^/]+/, '')}/apiVersionSets/dev-orders`, + }), + }) + ); + }); + it('should publish only API children included in the filtered target set', async () => { const client = createMockClient(); const operation: ResourceDescriptor = { @@ -152,6 +218,89 @@ describe('api-publisher', () => { ); }); + it('should not import a full specification when all present children match an API sub-filter', async () => { + const client = createMockClient(); + const operation: ResourceDescriptor = { + type: ResourceType.ApiOperation, + nameParts: ['orders-api', 'get-orders'], + }; + const store = createMockStore([operation]); + store.readContent.mockResolvedValue({ content: 'openapi: 3.0.0', format: 'yaml' }); + const apiDescriptor: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['orders-api'], + }; + const config: PublishConfig = { + ...testConfig, + filter: { + apis: ['orders-api'], + apiSubFilters: { + 'ORDERS-API': { + operations: ['get-orders'], + }, + }, + }, + }; + + await publishApi(client, store, testContext, apiDescriptor, config, [ + apiDescriptor, + operation, + ]); + + expect(client.putResource).toHaveBeenCalledWith( + testContext, + apiDescriptor, + expect.not.objectContaining({ + properties: expect.objectContaining({ format: expect.anything() }), + }) + ); + }); + + it('should not import a full specification when a workspace API constrains schemas', async () => { + const client = createMockClient(); + const schema: ResourceDescriptor = { + type: ResourceType.ApiSchema, + nameParts: ['orders-api', 'order-schema'], + workspace: 'team-a', + }; + const store = createMockStore([schema]); + store.readContent.mockResolvedValue({ content: 'openapi: 3.0.0', format: 'yaml' }); + const apiDescriptor: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['orders-api'], + workspace: 'team-a', + }; + const config: PublishConfig = { + ...testConfig, + filter: { + workspaces: ['team-a'], + workspaceSubFilters: { + 'TEAM-A': { + apis: ['orders-api'], + apiSubFilters: { + 'orders-api': { + schemas: ['order-schema'], + }, + }, + }, + }, + }, + }; + + await publishApi(client, store, testContext, apiDescriptor, config, [ + apiDescriptor, + schema, + ]); + + expect(client.putResource).toHaveBeenCalledWith( + testContext, + apiDescriptor, + expect.not.objectContaining({ + properties: expect.objectContaining({ format: expect.anything() }), + }) + ); + }); + it('should retain specification import when filtered APIs have no child artifacts', async () => { const client = createMockClient(); const store = createMockStore([]); @@ -187,6 +336,15 @@ describe('api-publisher', () => { nameParts: ['orders-api', 'get-orders'], }; const store = createMockStore([operation, schema, operationPolicy]); + store.readResource.mockImplementation(async (_dir: string, descriptor: ResourceDescriptor) => { + if (descriptor.type === ResourceType.ApiOperation) { + return { name: 'get-orders', properties: { displayName: 'Get orders' } }; + } + if (descriptor.type === ResourceType.Api) { + return { name: 'orders-api', properties: {} }; + } + return null; + }); store.readContent.mockResolvedValue({ content: 'openapi: 3.0.0', format: 'yaml' }); const apiDescriptor: ResourceDescriptor = { type: ResourceType.Api, @@ -518,6 +676,18 @@ describe('api-publisher', () => { const client = createMockClient(); const revisions = [{ type: ResourceType.Api, nameParts: ['orders-api;rev=2'] }]; const store = createMockStore(revisions); + mockPublishResource.mockImplementation( + async ( + _client: unknown, + _store: unknown, + _context: unknown, + descriptor: ResourceDescriptor + ) => ({ + descriptor, + status: 'success', + action: 'put', + }) + ); store.readResource.mockResolvedValue({ name: 'orders-api', properties: { path: 'orders', isCurrent: true }, @@ -532,10 +702,10 @@ describe('api-publisher', () => { nameParts: ['orders-api'], }; - await publishApi(client, store, testContext, apiDescriptor, testConfig); + const result = await publishApi(client, store, testContext, apiDescriptor, testConfig); // Spec is only read/injected on the first root publish. - expect(store.readContent).toHaveBeenCalledTimes(1); + expect(store.readContent).toHaveBeenCalledTimes(2); expect(client.putResource).toHaveBeenCalledTimes(2); const firstPayload = client.putResource.mock.calls[0][2] as Record; @@ -547,6 +717,74 @@ describe('api-publisher', () => { expect(firstProps).toHaveProperty('value', 'openapi: "3.0.0"'); expect(secondProps).not.toHaveProperty('format'); expect(secondProps).not.toHaveProperty('value'); + expect(result.relatedResults).toMatchObject([ + { + descriptor: { type: ResourceType.Api, nameParts: ['orders-api;rev=2'] }, + action: 'put', + status: 'success', + }, + { + descriptor: apiDescriptor, + action: 'put', + status: 'success', + }, + ]); + }); + + it('preserves completed actions when active-revision alignment PUT fails', async () => { + const client = createMockClient(); + client.putResource + .mockResolvedValueOnce({ name: 'orders-api' }) + .mockRejectedValueOnce(new Error('Alignment PUT failed')); + const revisions = [{ type: ResourceType.Api, nameParts: ['orders-api;rev=2'] }]; + const store = createMockStore(revisions); + mockPublishResource.mockImplementation( + async ( + _client: unknown, + _store: unknown, + _context: unknown, + descriptor: ResourceDescriptor + ) => ({ + descriptor, + status: 'success', + action: 'put', + }) + ); + store.readResource.mockResolvedValue({ + name: 'orders-api', + properties: { path: 'orders', isCurrent: true }, + }); + const apiDescriptor: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['orders-api'], + }; + + const result = await publishApi( + client, + store, + testContext, + apiDescriptor, + testConfig + ); + + expect(result).toMatchObject({ + descriptor: apiDescriptor, + status: 'success', + action: 'put', + relatedResults: [ + { + descriptor: revisions[0], + status: 'success', + action: 'put', + }, + { + descriptor: apiDescriptor, + status: 'failed', + action: 'put', + error: expect.objectContaining({ message: 'Alignment PUT failed' }), + }, + ], + }); }); it('should not replay root API when source root is not current', async () => { @@ -1233,13 +1471,13 @@ describe('api-publisher', () => { await publishApi(client, store, testContext, apiDescriptor, testConfig); - // ApiPolicy + ApiTag via initial publish (2) + get-pets reconcile task (1) = 3 tasks total. - // Auto-generated ApiSchema is skipped throughout. + // ApiPolicy + ApiTag are published explicitly. The operation has no + // reconcileable persisted properties and the generated schema is importer-managed. const totalTasks = mockRunParallel.mock.calls.reduce((sum, call) => { const tasks = call[0] as unknown[]; return sum + tasks.length; }, 0); - expect(totalTasks).toBe(3); + expect(totalTasks).toBe(2); }); it('should reconcile all operations via PATCH after spec import', async () => { @@ -1263,13 +1501,22 @@ describe('api-publisher', () => { return { name: 'create-item', properties: { + displayName: 'Create item', request: { representations: [{ contentType: 'application/json', schemaId: 'my-schema', typeName: 'Item' }], }, }, }; } - // get-items returns null — no persisted JSON + if ( + descriptor.type === ResourceType.ApiOperation && + (descriptor.nameParts[1] ?? '') === 'get-items' + ) { + return { + name: 'get-items', + properties: { displayName: 'Get items' }, + }; + } return null; }); store.readContent.mockResolvedValue({ @@ -1391,6 +1638,151 @@ describe('api-publisher', () => { ); }); + it('should return concrete PUT and PATCH results while omitting importer-managed schemas', async () => { + mockRunParallel.mockImplementation(async (tasks: Array<() => Promise>) => { + const values = []; + for (const task of tasks) { + values.push({ status: 'fulfilled' as const, value: await task() }); + } + return values; + }); + mockPublishResource.mockImplementation( + async ( + _client: unknown, + _store: unknown, + _context: unknown, + descriptor: ResourceDescriptor + ) => ({ + descriptor, + status: 'success', + action: 'put', + }) + ); + const client = createMockClient(); + const policy: ResourceDescriptor = { + type: ResourceType.ApiPolicy, + nameParts: ['petstore', 'policy'], + }; + const operation: ResourceDescriptor = { + type: ResourceType.ApiOperation, + nameParts: ['petstore', 'get-items'], + }; + const generatedSchema: ResourceDescriptor = { + type: ResourceType.ApiSchema, + nameParts: ['petstore', '69f15c3c10a45d29d855583a'], + }; + const store = createMockStore([policy, operation, generatedSchema]); + store.readResource.mockImplementation(async (_dir: string, descriptor: ResourceDescriptor) => { + if (descriptor.type === ResourceType.Api) { + return { name: 'petstore', properties: { path: 'petstore' } }; + } + if (descriptor.type === ResourceType.ApiOperation) { + return { name: 'get-items', properties: { displayName: 'Get items' } }; + } + return null; + }); + store.readContent.mockResolvedValue({ + content: 'openapi: "3.0.0"', + format: 'yaml', + }); + + const result = await publishApi( + client, + store, + testContext, + { type: ResourceType.Api, nameParts: ['petstore'] }, + testConfig + ); + + expect(result.relatedResults).toMatchObject([ + { descriptor: policy, action: 'put', status: 'success' }, + { descriptor: operation, action: 'patch', status: 'success' }, + ]); + expect(result.relatedResults).not.toContainEqual( + expect.objectContaining({ descriptor: generatedSchema }) + ); + }); + + it('should return revision results without marking a successful root PUT as failed', async () => { + const revision: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['orders;rev=2'], + }; + const failedPolicy: ResourceDescriptor = { + type: ResourceType.ApiPolicy, + nameParts: ['orders', 'policy'], + }; + mockPublishResource.mockImplementation( + async ( + _client: unknown, + _store: unknown, + _context: unknown, + descriptor: ResourceDescriptor + ) => ({ + descriptor, + status: descriptor.type === ResourceType.ApiPolicy ? 'failed' : 'success', + action: descriptor.type === ResourceType.ApiPolicy ? 'noop' : 'put', + error: descriptor.type === ResourceType.ApiPolicy + ? new Error('Policy publish failed') + : undefined, + }) + ); + mockRunParallel.mockImplementation(async (tasks: Array<() => Promise>) => { + const values = []; + for (const task of tasks) { + values.push({ status: 'fulfilled' as const, value: await task() }); + } + return values; + }); + const store = createMockStore([revision, failedPolicy]); + const client = createMockClient(); + + const result = await publishApi( + client, + store, + testContext, + { type: ResourceType.Api, nameParts: ['orders'] }, + testConfig + ); + + expect(result.status).toBe('success'); + expect(result.relatedResults).toMatchObject([ + { descriptor: revision, status: 'success', action: 'put' }, + { descriptor: failedPolicy, status: 'failed', action: 'noop' }, + ]); + }); + + it('should attribute null-description alignment failures to the operation', async () => { + const client = createMockClient(); + client.getResource.mockRejectedValue(new Error('Operation read failed')); + const store = createMockStore([]); + store.readContent.mockResolvedValue({ + content: + 'openapi: "3.0.0"\npaths:\n /items:\n get:\n operationId: get-items\n', + format: 'yaml', + }); + const api: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['orders'], + }; + + const result = await publishApi(client, store, testContext, api, testConfig); + + expect(result.status).toBe('success'); + expect(result.relatedResults).toContainEqual( + expect.objectContaining({ + descriptor: { + type: ResourceType.ApiOperation, + nameParts: ['orders', 'get-items'], + workspace: undefined, + }, + status: 'failed', + action: 'noop', + error: expect.objectContaining({ message: 'Operation read failed' }), + }) + ); + }); + it('should skip operation republish in incremental mode when operation description is null', async () => { const client = createMockClient(); const children = [ @@ -1589,11 +1981,22 @@ describe('api-publisher', () => { store.readContent.mockResolvedValue({ content: 'openapi: "3.0.0"', format: 'yaml' }); const apiDescriptor: ResourceDescriptor = { type: ResourceType.Api, nameParts: ['petstore'] }; - await publishApi(client, store, testContext, apiDescriptor, testConfig); + const config: PublishConfig = { + ...testConfig, + envMapping: { + prefix: 'dev-', + suffix: '', + appliesTo: DEFAULT_APPLIES_TO, + }, + }; + await publishApi(client, store, testContext, apiDescriptor, config); expect(client.patchResource).toHaveBeenCalledWith( testContext, - expect.objectContaining({ type: ResourceType.ApiOperation, nameParts: ['petstore', 'get-pets'] }), + expect.objectContaining({ + type: ResourceType.ApiOperation, + nameParts: ['dev-petstore', 'get-pets'], + }), { properties: { displayName: 'List Pets', diff --git a/tests/unit/services/dry-run-reporter.test.ts b/tests/unit/services/dry-run-reporter.test.ts index c702f930..9884203e 100644 --- a/tests/unit/services/dry-run-reporter.test.ts +++ b/tests/unit/services/dry-run-reporter.test.ts @@ -136,6 +136,30 @@ describe('dry-run-reporter', () => { ); }); + it('checks the deployed descriptor when environment mapping is active', async () => { + const client = createMockClient(); + const store = createMockStore(); + const descriptor: ResourceDescriptor = { + type: ResourceType.NamedValue, + nameParts: ['shared-key'], + }; + const config: PublishConfig = { + ...testConfig, + envMapping: { + prefix: 'dev-', + suffix: '', + appliesTo: new Set([ResourceType.NamedValue]), + }, + }; + + await generateDryRunReport(store, client, testContext, config, [descriptor]); + + expect(client.getResource).toHaveBeenCalledWith(testContext, { + type: ResourceType.NamedValue, + nameParts: ['dev-shared-key'], + }); + }); + it('should expand aggregate GatewayApi descriptors into per-API actions', async () => { const client = createMockClient(); const store = createMockStore(); @@ -217,6 +241,7 @@ describe('dry-run-reporter', () => { expect(report.actions).toHaveLength(1); expect(report.actions[0].operation).toBe('SKIP'); + expect(report.actions[0].error).toContain('Network error'); expect(report.summary.skips).toBe(1); expect(loggerInfoSpy).toHaveBeenCalledWith( expect.stringContaining('SKIP') @@ -257,7 +282,7 @@ describe('dry-run-reporter', () => { expect(report.summary.skips).toBe(0); }); - it('should include commit-scoped deletes even when delete-unmatched is false', async () => { + it('should report commit-scoped deletes supplied after deletion opt-in', async () => { const client = createMockClient(new Map([ ['Tag:old-tag', true], ])); @@ -280,6 +305,68 @@ describe('dry-run-reporter', () => { ); }); + it('should report a workspace association DELETE after resolving its opaque link', async () => { + const client = createMockClient(); + client.listResources = async function* () { + yield { + name: 'opaque-link', + properties: { + apiId: `${testContext.baseUrl}/workspaces/team/apis/orders`, + }, + }; + }; + const store = createMockStore(); + + const report = await generateDryRunReport( + store, + client, + testContext, + testConfig, + [], + [{ + type: ResourceType.ProductApi, + nameParts: ['store', 'orders'], + workspace: 'team', + }] + ); + + expect(report.actions).toMatchObject([{ + operation: 'DELETE', + type: ResourceType.ProductApi, + name: 'store/orders', + }]); + expect(report.summary.deletes).toBe(1); + }); + + it('should not report a PUT when a Product is also deleted incrementally', async () => { + const product: ResourceDescriptor = { + type: ResourceType.Product, + nameParts: ['retired'], + }; + const client = createMockClient(new Map([['Product:retired', true]])); + const store = createMockStore(); + + const report = await generateDryRunReport( + store, + client, + testContext, + testConfig, + [product], + [product] + ); + + expect(report.actions.map((action) => action.operation)).toEqual([ + 'SKIP', + 'DELETE', + ]); + expect(report.summary).toEqual({ + creates: 0, + patches: 0, + deletes: 1, + skips: 1, + }); + }); + it('should format hierarchical resource names correctly', async () => { const client = createMockClient(new Map([ ['ApiOperation:get-user', false], @@ -300,30 +387,537 @@ describe('dry-run-reporter', () => { ); }); - it('should report association endpoints as PUT (not SKIP) when getResource returns undefined due to 405', async () => { + it('should report expanded association endpoints as PUT without issuing unsupported GETs', async () => { // APIM association endpoints (ProductGroup, ProductApi, GatewayApi) return // HTTP 405 on GET. ApimClient.getResource catches 405 and returns undefined, // so the dry-run reporter must treat them as "would be created" (PUT new), // not as errors (SKIP). const client = createMockClient(); - // Simulate getResource returning undefined (as ApimClient does for 405) - client.getResource.mockResolvedValue(undefined); const store = createMockStore(); + store.readAssociation.mockImplementation( + async (_dir: string, descriptor: ResourceDescriptor, type: string) => { + if (descriptor.type === ResourceType.Product && type === 'apis') { + return [{ name: 'my-api' }]; + } + if (descriptor.type === ResourceType.Product && type === 'groups') { + return [{ name: 'my-group' }]; + } + if (descriptor.type === ResourceType.Gateway && type === 'apis') { + return [{ name: 'my-api' }]; + } + return []; + } + ); const descriptors: ResourceDescriptor[] = [ - { type: ResourceType.ProductGroup, nameParts: ['my-product', 'my-group'] }, - { type: ResourceType.ProductApi, nameParts: ['my-product', 'my-api'] }, - { type: ResourceType.GatewayApi, nameParts: ['my-gateway', 'my-api'] }, + { type: ResourceType.Product, nameParts: ['my-product'] }, + { type: ResourceType.GatewayApi, nameParts: ['my-gateway'] }, ]; const report = await generateDryRunReport(store, client, testContext, testConfig, descriptors); - // All three association resources should be reported as PUT (new), not SKIP + const associationActions = report.actions.filter((action) => + [ + ResourceType.ProductGroup, + ResourceType.ProductApi, + ResourceType.GatewayApi, + ].includes(action.descriptor.type) + ); expect(report.summary.skips).toBe(0); - expect(report.actions).toHaveLength(3); - for (const action of report.actions) { + expect(associationActions).toHaveLength(3); + for (const action of associationActions) { expect(action.operation).toBe('PUT'); } + expect(client.getResource).toHaveBeenCalledTimes(1); + }); + + it('expands filtered product associations into concrete PUT and SKIP actions without duplicates', async () => { + const client = createMockClient(); + const store = createMockStore(); + store.readAssociation.mockImplementation( + async (_dir: string, _descriptor: ResourceDescriptor, type: string) => { + if (type === 'apis') { + return [{ name: 'orders' }, { name: 'legacy' }, { name: 'missing-api' }]; + } + if (type === 'groups') { + return [{ name: 'developers' }, { name: 'guests' }]; + } + return [{ name: 'production' }, { name: 'missing-tag' }]; + } + ); + + const product: ResourceDescriptor = { + type: ResourceType.Product, + nameParts: ['store'], + }; + const descriptors: ResourceDescriptor[] = [ + product, + { type: ResourceType.Api, nameParts: ['orders'] }, + { type: ResourceType.Group, nameParts: ['developers'] }, + { type: ResourceType.Tag, nameParts: ['production'] }, + // Parent-managed descriptors can also be present in caller-provided sets. + { type: ResourceType.ProductApi, nameParts: ['store', 'orders'] }, + ]; + const config: PublishConfig = { + ...testConfig, + filter: { + products: ['store'], + apis: ['orders', 'missing-api', '!legacy'], + groups: ['developers', '!guests'], + tags: ['production', 'missing-tag'], + }, + }; + + const report = await generateDryRunReport( + store, + client, + testContext, + config, + descriptors + ); + + const associations = report.actions.filter((action) => + [ + ResourceType.ProductApi, + ResourceType.ProductGroup, + ResourceType.ProductTag, + ].includes(action.descriptor.type) + ); + expect(associations.map((action) => [ + action.operation, + action.type, + action.name, + ])).toEqual([ + ['PUT', ResourceType.ProductGroup, 'store/developers'], + ['SKIP', ResourceType.ProductGroup, 'store/guests'], + ['PUT', ResourceType.ProductTag, 'store/production'], + ['SKIP', ResourceType.ProductTag, 'store/missing-tag'], + ['PUT', ResourceType.ProductApi, 'store/orders'], + ['SKIP', ResourceType.ProductApi, 'store/legacy'], + ['SKIP', ResourceType.ProductApi, 'store/missing-api'], + ]); + expect( + associations.filter((action) => action.name === 'store/orders') + ).toHaveLength(1); + expect(report.summary).toEqual({ creates: 7, patches: 0, deletes: 0, skips: 4 }); + expect(client.putResource).not.toHaveBeenCalled(); + expect(client.patchResource).not.toHaveBeenCalled(); + expect(client.deleteResource).not.toHaveBeenCalled(); + }); + + it('includes an unchanged product policy when an incremental publish selects its product', async () => { + const client = createMockClient(); + const store = createMockStore(); + store.readContent.mockImplementation( + async (_dir: string, descriptor: ResourceDescriptor) => + descriptor.type === ResourceType.ProductPolicy + ? { content: '', format: 'xml' } + : undefined + ); + const product: ResourceDescriptor = { + type: ResourceType.Product, + nameParts: ['store'], + }; + const config: PublishConfig = { + ...testConfig, + commitId: 'abc123', + filter: { products: ['store'] }, + }; + + const report = await generateDryRunReport( + store, + client, + testContext, + config, + [product] + ); + + expect(report.actions).toMatchObject([ + { operation: 'PUT', type: ResourceType.Product, name: 'store' }, + { operation: 'PUT', type: ResourceType.ProductPolicy, name: 'store' }, + ]); + expect(report.summary).toEqual({ creates: 2, patches: 0, deletes: 0, skips: 0 }); + }); + + it('expands GatewayApi artifacts and reports excluded and unavailable API targets', async () => { + const client = createMockClient(); + const store = createMockStore(); + store.readAssociation.mockResolvedValue([ + { name: 'orders' }, + { name: 'legacy' }, + { name: 'missing-api' }, + ]); + const config: PublishConfig = { + ...testConfig, + filter: { + gateways: ['edge'], + apis: ['orders', 'missing-api', '!legacy'], + }, + }; + + const report = await generateDryRunReport( + store, + client, + testContext, + config, + [ + { type: ResourceType.Api, nameParts: ['orders'] }, + { type: ResourceType.GatewayApi, nameParts: ['edge'] }, + ] + ); + + expect(report.actions.filter((action) => action.type === ResourceType.GatewayApi)) + .toMatchObject([ + { operation: 'PUT', name: 'edge/orders' }, + { operation: 'SKIP', name: 'edge/legacy', reason: 'target is excluded by the filter' }, + { operation: 'SKIP', name: 'edge/missing-api', reason: 'target is unavailable in the publish set' }, + ]); + expect(report.summary).toEqual({ creates: 2, patches: 0, deletes: 0, skips: 2 }); + expect(client.putResource).not.toHaveBeenCalled(); + expect(client.patchResource).not.toHaveBeenCalled(); + expect(client.deleteResource).not.toHaveBeenCalled(); + }); + + it('skips a missing incremental association target even when the association changed', async () => { + const client = createMockClient(); + const store = createMockStore(); + store.readAssociation.mockResolvedValue([{ name: 'missing-api' }]); + store.readResource.mockResolvedValue(undefined); + const config: PublishConfig = { + ...testConfig, + commitId: 'base', + filter: { + gateways: ['edge'], + apis: ['missing-api'], + }, + }; + + const report = await generateDryRunReport( + store, + client, + testContext, + config, + [{ type: ResourceType.GatewayApi, nameParts: ['edge'] }] + ); + + expect(report.actions).toMatchObject([ + { + operation: 'SKIP', + type: ResourceType.GatewayApi, + name: 'edge/missing-api', + reason: 'target was never extracted', + }, + ]); + }); + + it('uses shared ApiTag eligibility for allowed, excluded, and missing tags', async () => { + const client = createMockClient(); + const store = createMockStore(); + store.readResource.mockImplementation( + async (_dir: string, descriptor: ResourceDescriptor) => { + if ( + descriptor.type === ResourceType.Tag && + descriptor.nameParts[0] === 'production' + ) { + return { name: 'production', properties: {} }; + } + if ( + descriptor.type === ResourceType.ApiTag && + descriptor.nameParts[1] === 'production' + ) { + return { name: 'production', properties: {} }; + } + return undefined; + } + ); + const config: PublishConfig = { + ...testConfig, + filter: { + apis: ['orders'], + tags: ['production', 'missing', '!internal'], + }, + }; + + const report = await generateDryRunReport( + store, + client, + testContext, + config, + [ + { type: ResourceType.ApiTag, nameParts: ['orders', 'production'] }, + { type: ResourceType.ApiTag, nameParts: ['orders', 'internal'] }, + { type: ResourceType.ApiTag, nameParts: ['orders', 'missing'] }, + ] + ); + + expect(report.actions).toMatchObject([ + { operation: 'PUT', name: 'orders/production' }, + { operation: 'SKIP', name: 'orders/internal', reason: 'target is excluded by the filter' }, + { operation: 'SKIP', name: 'orders/missing', reason: 'target was never extracted' }, + ]); + expect(report.summary).toEqual({ creates: 1, patches: 0, deletes: 0, skips: 2 }); + expect(client.putResource).not.toHaveBeenCalled(); + expect(client.patchResource).not.toHaveBeenCalled(); + expect(client.deleteResource).not.toHaveBeenCalled(); + }); + + it('plans workspace ApiTag links without requiring a link artifact', async () => { + const client = createMockClient(); + const store = createMockStore(); + store.readResource.mockImplementation( + async (_dir: string, descriptor: ResourceDescriptor) => + descriptor.type === ResourceType.Tag && + descriptor.workspace === 'team' && + descriptor.nameParts[0] === 'production' + ? { properties: {} } + : undefined + ); + const config: PublishConfig = { + ...testConfig, + filter: { + workspaces: ['team'], + workspaceSubFilters: { + team: { + apis: ['orders'], + tags: ['production'], + }, + }, + }, + }; + + const report = await generateDryRunReport( + store, + client, + testContext, + config, + [{ + type: ResourceType.ApiTag, + nameParts: ['orders', 'production'], + workspace: 'team', + }] + ); + + expect(report.actions).toMatchObject([ + { operation: 'PUT', type: ResourceType.ApiTag, name: 'orders/production' }, + ]); + expect(store.readResource).toHaveBeenCalledTimes(1); + expect(client.putResource).not.toHaveBeenCalled(); + expect(client.patchResource).not.toHaveBeenCalled(); + expect(client.deleteResource).not.toHaveBeenCalled(); + }); + + it('plans API and Product subscription eligibility across service and workspace scopes', async () => { + const client = createMockClient(); + const store = createMockStore(); + const armPrefix = + '/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.ApiManagement/service/apim-1'; + const resources = new Map>([ + ['Subscription::product-sub', { + properties: { scope: `${armPrefix}/products/store` }, + }], + ['Subscription::excluded-api-sub', { + properties: { scope: `${armPrefix}/apis/legacy` }, + }], + ['Subscription::missing-product-sub', { + properties: { scope: `${armPrefix}/products/missing` }, + }], + ['Subscription:team:workspace-api-sub', { + properties: { scope: '/apis/orders' }, + }], + ['Subscription:team:workspace-product-sub', { + properties: { scope: '/products/team-store' }, + }], + ['Subscription::root-sub', { + properties: { scope: armPrefix }, + }], + ['Product::store', { properties: {} }], + ['Api:team:orders', { properties: {} }], + ['Product:team:team-store', { properties: {} }], + ]); + store.readResource.mockImplementation( + async (_dir: string, descriptor: ResourceDescriptor) => + resources.get( + `${descriptor.type}:${descriptor.workspace ?? ''}:${descriptor.nameParts.join('/')}` + ) + ); + const config: PublishConfig = { + ...testConfig, + filter: { + subscriptions: ['product-sub', 'excluded-api-sub', 'missing-product-sub', 'root-sub'], + products: ['store', 'missing'], + apis: ['!legacy', '*'], + workspaces: ['team'], + workspaceSubFilters: { + team: { + subscriptions: ['workspace-api-sub', 'workspace-product-sub'], + apis: ['orders'], + products: ['team-store'], + }, + }, + }, + }; + + const report = await generateDryRunReport( + store, + client, + testContext, + config, + [ + { type: ResourceType.Subscription, nameParts: ['product-sub'] }, + { type: ResourceType.Subscription, nameParts: ['excluded-api-sub'] }, + { type: ResourceType.Subscription, nameParts: ['missing-product-sub'] }, + { + type: ResourceType.Subscription, + nameParts: ['workspace-api-sub'], + workspace: 'team', + }, + { + type: ResourceType.Subscription, + nameParts: ['workspace-product-sub'], + workspace: 'team', + }, + { type: ResourceType.Subscription, nameParts: ['root-sub'] }, + ] + ); + + expect(report.actions).toMatchObject([ + { operation: 'PUT', name: 'product-sub' }, + { operation: 'SKIP', name: 'excluded-api-sub' }, + { operation: 'SKIP', name: 'missing-product-sub' }, + { operation: 'PUT', name: 'workspace-api-sub' }, + { operation: 'PUT', name: 'workspace-product-sub' }, + { + operation: 'SKIP', + name: 'root-sub', + reason: 'root-scoped subscriptions are managed by APIM', + }, + ]); + expect(report.summary).toEqual({ creates: 3, patches: 0, deletes: 0, skips: 3 }); + expect(client.putResource).not.toHaveBeenCalled(); + expect(client.patchResource).not.toHaveBeenCalled(); + expect(client.deleteResource).not.toHaveBeenCalled(); + }); + + it('plans the concrete API requests used by full specification import', async () => { + const client = createMockClient(); + const store = createMockStore(); + const api: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['orders'], + }; + const policy: ResourceDescriptor = { + type: ResourceType.ApiPolicy, + nameParts: ['orders', 'policy'], + }; + const tag: ResourceDescriptor = { + type: ResourceType.ApiTag, + nameParts: ['orders', 'production'], + }; + const operation: ResourceDescriptor = { + type: ResourceType.ApiOperation, + nameParts: ['orders', 'get-orders'], + }; + const explicitSchema: ResourceDescriptor = { + type: ResourceType.ApiSchema, + nameParts: ['orders', 'order-schema'], + }; + const generatedSchema: ResourceDescriptor = { + type: ResourceType.ApiSchema, + nameParts: ['orders', '69f15c3c10a45d29d855583a'], + }; + const descriptors = [api, policy, tag, operation, explicitSchema, generatedSchema]; + store.listResources.mockResolvedValue(descriptors); + store.readResource.mockImplementation( + async (_dir: string, descriptor: ResourceDescriptor) => { + if (descriptor.type === ResourceType.Api) { + return { name: 'orders', properties: { path: 'orders' } }; + } + if (descriptor.type === ResourceType.ApiOperation) { + return { + name: 'get-orders', + properties: { displayName: 'Get orders' }, + }; + } + return { properties: {} }; + } + ); + store.readContent.mockImplementation( + async (_dir: string, descriptor: ResourceDescriptor, kind: string) => + descriptor.type === ResourceType.Api && kind === 'specification' + ? { + content: + 'openapi: "3.0.0"\npaths:\n /orders:\n get:\n operationId: get-orders\n', + format: 'yaml', + } + : undefined + ); + const config: PublishConfig = { + ...testConfig, + filter: { + apis: ['orders'], + tags: ['production'], + }, + }; + + const report = await generateDryRunReport( + store, + client, + testContext, + config, + descriptors + ); + + expect(report.actions).toEqual(expect.arrayContaining([ + expect.objectContaining({ operation: 'PUT', type: ResourceType.Api, name: 'orders' }), + expect.objectContaining({ operation: 'PUT', type: ResourceType.ApiPolicy, name: 'orders/policy' }), + expect.objectContaining({ operation: 'PUT', type: ResourceType.ApiTag, name: 'orders/production' }), + expect.objectContaining({ operation: 'PUT', type: ResourceType.ApiSchema, name: 'orders/order-schema' }), + expect.objectContaining({ operation: 'PUT', type: ResourceType.ApiOperation, name: 'orders/get-orders' }), + expect.objectContaining({ operation: 'PATCH', type: ResourceType.ApiOperation, name: 'orders/get-orders' }), + ])); + expect(report.actions).not.toContainEqual( + expect.objectContaining({ descriptor: generatedSchema }) + ); + expect(report.summary).toEqual({ creates: 5, patches: 1, deletes: 0, skips: 0 }); + expect(client.putResource).not.toHaveBeenCalled(); + expect(client.patchResource).not.toHaveBeenCalled(); + }); + + it('plans revision and active-revision alignment PUTs exactly once', async () => { + const client = createMockClient(); + const store = createMockStore(); + const api: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['orders'], + }; + const revision: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['orders;rev=2'], + }; + store.listResources.mockResolvedValue([api, revision]); + store.readResource.mockImplementation( + async (_dir: string, descriptor: ResourceDescriptor) => + descriptor.type === ResourceType.Api && + descriptor.nameParts[0] === 'orders' + ? { properties: { isCurrent: true } } + : { properties: {} } + ); + store.readContent.mockResolvedValue(undefined); + + const report = await generateDryRunReport( + store, + client, + testContext, + testConfig, + [api, revision] + ); + + expect(report.actions).toMatchObject([ + { operation: 'PUT', type: ResourceType.Api, name: 'orders' }, + { operation: 'PUT', type: ResourceType.Api, name: 'orders;rev=2' }, + { operation: 'PUT', type: ResourceType.Api, name: 'orders' }, + ]); + expect(report.summary).toEqual({ creates: 3, patches: 0, deletes: 0, skips: 0 }); }); }); }); diff --git a/tests/unit/services/env-mapper.test.ts b/tests/unit/services/env-mapper.test.ts index 536c52d6..ac7938bf 100644 --- a/tests/unit/services/env-mapper.test.ts +++ b/tests/unit/services/env-mapper.test.ts @@ -385,25 +385,29 @@ describe('env-mapper', () => { expect(result).toEqual({ type: ResourceType.ApiSchema, nameParts: ['dev-petstore', 'json'] }); }); - it('workspace-scoped descriptor: workspace field is preserved unchanged', () => { + it('workspace-scoped descriptor: workspace and resource names are affixed', () => { const d: ResourceDescriptor = { type: ResourceType.Api, nameParts: ['petstore'], workspace: 'my-workspace', }; const result = mapDescriptor(d, m); - expect(result.workspace).toBe('my-workspace'); + expect(result.workspace).toBe('dev-my-workspace'); expect(result.nameParts[0]).toBe('dev-petstore'); }); - it('workspace-scoped ApiPolicy: parent Api affixed, workspace preserved', () => { + it('workspace-scoped ApiPolicy: parent Api and workspace are affixed', () => { const d: ResourceDescriptor = { type: ResourceType.ApiPolicy, nameParts: ['petstore'], workspace: 'my-workspace', }; const result = mapDescriptor(d, m); - expect(result).toEqual({ type: ResourceType.ApiPolicy, nameParts: ['dev-petstore'], workspace: 'my-workspace' }); + expect(result).toEqual({ + type: ResourceType.ApiPolicy, + nameParts: ['dev-petstore'], + workspace: 'dev-my-workspace', + }); }); it('explicit appliesTo with only Product: Api segments not affixed in ProductApi', () => { diff --git a/tests/unit/services/git-diff-service.test.ts b/tests/unit/services/git-diff-service.test.ts index f55d5928..ebced39b 100644 --- a/tests/unit/services/git-diff-service.test.ts +++ b/tests/unit/services/git-diff-service.test.ts @@ -13,6 +13,7 @@ const mockGit = { checkIsRepo: vi.fn(), revparse: vi.fn(), diff: vi.fn(), + show: vi.fn(), }; // Mock simple-git @@ -23,6 +24,7 @@ vi.mock('simple-git', () => ({ describe('git-diff-service', () => { beforeEach(() => { vi.clearAllMocks(); + mockGit.show.mockResolvedValue('[]'); }); describe('computeGitDiff', () => { @@ -103,6 +105,99 @@ describe('git-diff-service', () => { ]); }); + it('should map product association changes to the parent Product descriptor', async () => { + mockGit.checkIsRepo.mockResolvedValue(true); + mockGit.revparse.mockResolvedValue('abc123'); + mockGit.diff.mockResolvedValue('M\tproducts/starter/apis.json\n'); + + const result = await computeGitDiff('/source', 'abc123'); + + expect(result.changedDescriptors).toEqual([ + { + type: 'Product', + nameParts: ['starter'], + workspace: undefined, + }, + ]); + }); + + it('should reconcile rather than delete a Product when an association file is deleted', async () => { + mockGit.checkIsRepo.mockResolvedValue(true); + mockGit.revparse.mockResolvedValue('abc123'); + mockGit.diff.mockResolvedValue('D\tproducts/starter/apis.json\n'); + + const result = await computeGitDiff('/source', 'abc123'); + + expect(result.deletedDescriptors).toEqual([]); + expect(result.changedDescriptors).toEqual([ + { + type: 'Product', + nameParts: ['starter'], + workspace: undefined, + }, + ]); + }); + + it('should emit removed Product associations as deleted descriptors', async () => { + mockGit.checkIsRepo.mockResolvedValue(true); + mockGit.revparse.mockResolvedValue('abc123'); + mockGit.diff.mockResolvedValue('M\tproducts/starter/apis.json\n'); + mockGit.show + .mockResolvedValueOnce('[{"name":"orders"},{"name":"legacy"}]') + .mockResolvedValueOnce('[{"name":"orders"}]'); + + const result = await computeGitDiff('/source', 'abc123'); + + expect(result.deletedDescriptors).toEqual([ + { + type: 'ProductApi', + nameParts: ['starter', 'legacy'], + workspace: undefined, + targetScope: 'workspace', + }, + ]); + }); + + it('preserves the removed Product association target scope', async () => { + mockGit.checkIsRepo.mockResolvedValue(true); + mockGit.revparse.mockResolvedValue('abc123'); + mockGit.diff.mockResolvedValue('M\tworkspaces/team/products/starter/apis.json\n'); + mockGit.show + .mockResolvedValueOnce('[{"name":"orders","scope":"service"}]') + .mockResolvedValueOnce('[{"name":"orders","scope":"workspace"}]'); + + const result = await computeGitDiff('/source', 'abc123'); + + expect(result.deletedDescriptors).toEqual([{ + type: 'ProductApi', + nameParts: ['starter', 'orders'], + workspace: 'team', + targetScope: 'service', + }]); + }); + + it('emits removed Gateway API associations as complete descriptors', async () => { + mockGit.checkIsRepo.mockResolvedValue(true); + mockGit.revparse.mockResolvedValue('abc123'); + mockGit.diff.mockResolvedValue('M\tgateways/edge/apis.json\n'); + mockGit.show + .mockResolvedValueOnce('[{"name":"orders"},{"name":"legacy"}]') + .mockResolvedValueOnce('[{"name":"orders"}]'); + + const result = await computeGitDiff('/source', 'abc123'); + + expect(result.changedDescriptors).toEqual([{ + type: 'GatewayApi', + nameParts: ['edge'], + workspace: undefined, + }]); + expect(result.deletedDescriptors).toEqual([{ + type: 'GatewayApi', + nameParts: ['edge', 'legacy'], + workspace: undefined, + }]); + }); + it('should map workspace-scoped api specification changes to Api descriptor', async () => { mockGit.checkIsRepo.mockResolvedValue(true); mockGit.revparse.mockResolvedValue('abc123'); @@ -268,5 +363,27 @@ describe('git-diff-service', () => { expect(result.changedDescriptors).toEqual([]); expect(result.deletedDescriptors).toEqual([]); }); + + it('should surface malformed managed association artifacts', async () => { + mockGit.checkIsRepo.mockResolvedValue(true); + mockGit.revparse.mockResolvedValue('abc123'); + mockGit.diff.mockResolvedValue('M\tproducts/starter/apis.json\n'); + mockGit.show.mockResolvedValue('not-json'); + + await expect(computeGitDiff('/source', 'abc123')).rejects.toThrow( + 'Unexpected token' + ); + }); + + it('should reject malformed entries in managed association artifacts', async () => { + mockGit.checkIsRepo.mockResolvedValue(true); + mockGit.revparse.mockResolvedValue('abc123'); + mockGit.diff.mockResolvedValue('M\tproducts/starter/apis.json\n'); + mockGit.show.mockResolvedValue('[{"name":"orders","scope":"invalid"}]'); + + await expect(computeGitDiff('/source', 'abc123')).rejects.toThrow( + 'products/starter/apis.json entry 0 has an invalid scope' + ); + }); }); }); diff --git a/tests/unit/services/product-publisher.test.ts b/tests/unit/services/product-publisher.test.ts index 8145738c..d9bf7984 100644 --- a/tests/unit/services/product-publisher.test.ts +++ b/tests/unit/services/product-publisher.test.ts @@ -5,15 +5,20 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { publishProduct } from '../../../src/services/product-publisher.js'; +import { + planProductAssociationPublications, + publishProduct, +} from '../../../src/services/product-publisher.js'; import { ResourceType } from '../../../src/models/resource-types.js'; import { ApimServiceContext, ResourceDescriptor } from '../../../src/models/types.js'; import { PublishConfig } from '../../../src/models/config.js'; import { LogLevel } from '../../../src/lib/logger.js'; +import { DEFAULT_APPLIES_TO } from '../../../src/services/env-mapper.js'; // Mock resource-publisher so product-publisher tests don't run full resource-publisher logic const mockPublishResource = vi.fn(); -vi.mock('../../../src/services/resource-publisher.js', () => ({ +vi.mock('../../../src/services/resource-publisher.js', async (importOriginal) => ({ + ...(await importOriginal()), publishResource: (...args: unknown[]) => mockPublishResource(...args), })); @@ -141,6 +146,28 @@ describe('product-publisher', () => { expect(store.readAssociation).not.toHaveBeenCalled(); }); + it('reports a preliminary existence-check failure without a PUT action', async () => { + const client = createMockClient(); + const store = createMockStore(); + client.getResource.mockRejectedValue(new Error('Lookup failed')); + + const result = await publishProduct( + client, + store, + testContext, + productDescriptor, + testConfig + ); + + expect(result).toMatchObject({ + descriptor: productDescriptor, + status: 'failed', + action: 'noop', + error: expect.objectContaining({ message: 'Lookup failed' }), + }); + expect(mockPublishResource).not.toHaveBeenCalled(); + }); + it('no apis.json / groups.json / tags.json: no client.putResource calls for associations', async () => { const client = createMockClient(); const store = createMockStore(); @@ -178,6 +205,69 @@ describe('product-publisher', () => { ); }); + it('publishes environment-mapped product associations to deployed descriptors', async () => { + const client = createMockClient(); + const store = createMockStore(); + store.readAssociation + .mockResolvedValueOnce([{ name: 'petstore' }]) + .mockResolvedValueOnce([{ name: 'developers' }]) + .mockResolvedValueOnce([{ name: 'production' }]); + const config: PublishConfig = { + ...testConfig, + envMapping: { + prefix: 'dev-', + suffix: '', + appliesTo: DEFAULT_APPLIES_TO, + }, + }; + + await publishProduct( + client, + store, + testContext, + productDescriptor, + config, + [ + productDescriptor, + { type: ResourceType.Api, nameParts: ['petstore'] }, + { type: ResourceType.Group, nameParts: ['developers'] }, + { type: ResourceType.Tag, nameParts: ['production'] }, + ] + ); + + expect(client.getResource).toHaveBeenCalledWith( + testContext, + expect.objectContaining({ + type: ResourceType.Product, + nameParts: ['dev-my-product'], + }) + ); + expect(client.putResource).toHaveBeenCalledWith( + testContext, + expect.objectContaining({ + type: ResourceType.ProductApi, + nameParts: ['dev-my-product', 'dev-petstore'], + }), + {} + ); + expect(client.putResource).toHaveBeenCalledWith( + testContext, + expect.objectContaining({ + type: ResourceType.ProductGroup, + nameParts: ['dev-my-product', 'developers'], + }), + {} + ); + expect(client.putResource).toHaveBeenCalledWith( + testContext, + expect.objectContaining({ + type: ResourceType.ProductTag, + nameParts: ['dev-my-product', 'dev-production'], + }), + {} + ); + }); + it('does not let resolved targets override explicit API exclusions', async () => { const client = createMockClient(); const store = createMockStore(); @@ -343,7 +433,7 @@ describe('product-publisher', () => { ); }); - it('association PUT failure is non-fatal: overall result is still success', async () => { + it('returns a concrete failed result when an association PUT fails', async () => { const client = createMockClient(); const store = createMockStore(); store.readAssociation @@ -356,9 +446,20 @@ describe('product-publisher', () => { const result = await publishProduct(client, store, testContext, productDescriptor, testConfig); expect(result.status).toBe('success'); + expect(result.relatedResults).toEqual([ + expect.objectContaining({ + descriptor: expect.objectContaining({ + type: ResourceType.ProductApi, + nameParts: ['my-product', 'petstore'], + }), + status: 'failed', + action: 'put', + error: expect.objectContaining({ message: 'Association PUT failed' }), + }), + ]); }); - it('outer error returns failed with error property', async () => { + it('preserves the successful Product PUT when post-PUT planning fails', async () => { const client = createMockClient(); const store = createMockStore(); // Force a top-level throw by making readAssociation throw unexpectedly @@ -366,10 +467,16 @@ describe('product-publisher', () => { const result = await publishProduct(client, store, testContext, productDescriptor, testConfig); - expect(result.status).toBe('failed'); - expect(result.action).toBe('noop'); - expect(result.error).toBeInstanceOf(Error); - expect(result.error?.message).toBe('Unexpected store error'); + expect(result.status).toBe('success'); + expect(result.action).toBe('put'); + expect(result.relatedResults).toEqual([ + expect.objectContaining({ + descriptor: productDescriptor, + status: 'failed', + action: 'noop', + error: expect.objectContaining({ message: 'Unexpected store error' }), + }), + ]); }); it('does not delete auto-generated product subscriptions after product publish', async () => { @@ -456,5 +563,96 @@ describe('product-publisher', () => { expect(result.status).toBe('success'); expect(client.deleteResource).not.toHaveBeenCalled(); }); + + it('uses the shared plan for workspace and service-scoped association links', async () => { + const store = createMockStore(); + const workspaceProduct: ResourceDescriptor = { + type: ResourceType.Product, + nameParts: ['store'], + workspace: 'team', + }; + store.readAssociation + .mockResolvedValueOnce([{ name: 'orders', scope: 'workspace' }]) + .mockResolvedValueOnce([{ name: 'administrators', scope: 'service' }]) + .mockResolvedValueOnce([{ name: 'production', scope: 'workspace' }]); + const config: PublishConfig = { + ...testConfig, + envMapping: { + prefix: 'dev-', + suffix: '', + appliesTo: DEFAULT_APPLIES_TO, + }, + filter: { + workspaces: ['team'], + workspaceSubFilters: { + team: { + products: ['store'], + apis: ['orders'], + tags: ['production'], + }, + }, + groups: ['administrators'], + }, + }; + const allowed: ResourceDescriptor[] = [ + workspaceProduct, + { type: ResourceType.Api, nameParts: ['orders'], workspace: 'team' }, + { type: ResourceType.Group, nameParts: ['administrators'] }, + { type: ResourceType.Tag, nameParts: ['production'], workspace: 'team' }, + ]; + + const plans = await planProductAssociationPublications( + store, + testContext, + workspaceProduct, + config, + allowed + ); + + expect(plans).toMatchObject([ + { + eligible: true, + descriptor: { type: ResourceType.ProductApi, nameParts: ['store', 'orders'], workspace: 'team' }, + deployedDescriptor: { + type: ResourceType.ProductApi, + nameParts: ['dev-store', 'dev-orders'], + workspace: 'dev-team', + }, + payload: { + properties: { + apiId: expect.stringContaining('/workspaces/dev-team/apis/dev-orders'), + }, + }, + }, + { + eligible: true, + descriptor: { type: ResourceType.ProductGroup, nameParts: ['store', 'administrators'], workspace: 'team' }, + deployedDescriptor: { + type: ResourceType.ProductGroup, + nameParts: ['dev-store', 'administrators'], + workspace: 'dev-team', + }, + payload: { + properties: { + groupId: expect.stringContaining('/groups/administrators'), + }, + }, + }, + { + eligible: true, + descriptor: { type: ResourceType.ProductTag, nameParts: ['store', 'production'], workspace: 'team' }, + deployedDescriptor: { + type: ResourceType.ProductTag, + nameParts: ['dev-store', 'dev-production'], + workspace: 'dev-team', + }, + payload: { + properties: { + productId: expect.stringContaining('/workspaces/dev-team/products/dev-store'), + }, + }, + }, + ]); + }); }); }); diff --git a/tests/unit/services/publish-service.test.ts b/tests/unit/services/publish-service.test.ts index 87e5b1a5..893d1731 100644 --- a/tests/unit/services/publish-service.test.ts +++ b/tests/unit/services/publish-service.test.ts @@ -72,7 +72,7 @@ describe('publish-service', () => { vi.mocked(generateDryRunReport).mockResolvedValue({ actions: [], - summary: { creates: 0, deletes: 0, skips: 0 }, + summary: { creates: 0, patches: 0, deletes: 0, skips: 0 }, }); vi.mocked(computeDeleteActions).mockResolvedValue([]); @@ -264,6 +264,130 @@ describe('publish-service', () => { ); }); + it('should include only filter-eligible API children in transitive scanning and the publish set', async () => { + const api: ResourceDescriptor = { type: ResourceType.Api, nameParts: ['orders'] }; + const includedPolicy: ResourceDescriptor = { + type: ResourceType.ApiOperationPolicy, + nameParts: ['orders', 'get-orders'], + }; + const excludedPolicy: ResourceDescriptor = { + type: ResourceType.ApiOperationPolicy, + nameParts: ['orders', 'delete-orders'], + }; + const includedBackend: ResourceDescriptor = { + type: ResourceType.Backend, + nameParts: ['included-backend'], + }; + const excludedBackend: ResourceDescriptor = { + type: ResourceType.Backend, + nameParts: ['excluded-backend'], + }; + const resources = [ + api, + includedPolicy, + excludedPolicy, + includedBackend, + excludedBackend, + ]; + const client = createMockClient(); + const store = createMockStore(resources); + store.readContent.mockImplementation( + async (_dir: string, descriptor: ResourceDescriptor) => { + if (descriptor === includedPolicy) { + return { + content: '', + format: 'xml', + }; + } + if (descriptor === excludedPolicy) { + return { + content: '', + format: 'xml', + }; + } + return undefined; + } + ); + + await runPublish(client, store, { + service: testContext, + sourceDir: '/source', + filter: { + apis: ['orders'], + apiSubFilters: { + orders: { operations: ['get-orders'] }, + }, + backends: [], + }, + dryRun: false, + deleteUnmatched: false, + logLevel: LogLevel.INFO, + }); + + expect(publishApi).toHaveBeenCalledWith( + client, + store, + testContext, + api, + expect.anything(), + expect.arrayContaining([includedPolicy, includedBackend]) + ); + const allowed = vi.mocked(publishApi).mock.calls[0]?.[5] as ResourceDescriptor[]; + expect(allowed).not.toContainEqual(excludedPolicy); + expect(allowed).not.toContainEqual(excludedBackend); + }); + + it('should not expand root API children from a revision-only incremental change', async () => { + const revision: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['orders;rev=2'], + }; + const policy: ResourceDescriptor = { + type: ResourceType.ApiPolicy, + nameParts: ['orders'], + }; + const backend: ResourceDescriptor = { + type: ResourceType.Backend, + nameParts: ['orders-backend'], + }; + vi.mocked(computeGitDiff).mockResolvedValueOnce({ + changedDescriptors: [revision], + deletedDescriptors: [], + }); + const client = createMockClient(); + const store = createMockStore([revision, policy, backend]); + store.readContent.mockImplementation( + async (_dir: string, descriptor: ResourceDescriptor) => + descriptor.type === ResourceType.ApiPolicy + ? { + content: '', + format: 'xml', + } + : undefined + ); + + await runPublish(client, store, { + service: testContext, + sourceDir: '/source', + commitId: 'base', + filter: { apis: ['orders'], backends: [] }, + dryRun: false, + deleteUnmatched: false, + logLevel: LogLevel.INFO, + }); + + expect(client.putResource).toHaveBeenCalledWith( + testContext, + revision, + expect.anything() + ); + expect(client.putResource).not.toHaveBeenCalledWith( + testContext, + backend, + expect.anything() + ); + }); + it('should not scan unrelated resources that share the selected parent name', async () => { const resources = [ { type: ResourceType.Api, nameParts: ['orders'] }, @@ -415,6 +539,45 @@ describe('publish-service', () => { expect(client.putResource.mock.calls[0]?.[1].nameParts).toEqual(['keep']); }); + it('should publish an incremental GatewayApi link using the same target context as dry-run', async () => { + const gatewayAssociation: ResourceDescriptor = { + type: ResourceType.GatewayApi, + nameParts: ['edge'], + }; + vi.mocked(computeGitDiff).mockResolvedValue({ + changedDescriptors: [gatewayAssociation], + deletedDescriptors: [], + }); + const client = createMockClient(); + const store = createMockStore([gatewayAssociation]); + store.readAssociation.mockResolvedValue([{ name: 'orders' }]); + + const result = await runPublish(client, store, { + service: testContext, + sourceDir: '/source', + filter: { + gateways: ['edge'], + apis: ['orders'], + }, + includeTransitive: false, + commitId: 'abc123', + dryRun: false, + deleteUnmatched: false, + logLevel: LogLevel.INFO, + }); + + expect(result.totalPuts).toBe(1); + expect(client.putResource).toHaveBeenCalledWith( + testContext, + { + type: ResourceType.GatewayApi, + nameParts: ['edge', 'orders'], + workspace: undefined, + }, + {} + ); + }); + it('should resolve policy dependencies from unchanged child artifacts in incremental mode', async () => { const api = { type: ResourceType.Api, nameParts: ['orders'] }; const apiPolicy = { type: ResourceType.ApiPolicy, nameParts: ['orders'] }; @@ -448,6 +611,103 @@ describe('publish-service', () => { )).toBe(true); }); + it('should not resolve dependencies from API operations excluded by a sub-filter', async () => { + const resources: ResourceDescriptor[] = [ + { type: ResourceType.Api, nameParts: ['orders'] }, + { type: ResourceType.ApiOperationPolicy, nameParts: ['orders', 'get-orders'] }, + { type: ResourceType.ApiOperationPolicy, nameParts: ['orders', 'delete-orders'] }, + { type: ResourceType.Backend, nameParts: ['included-backend'] }, + { type: ResourceType.Backend, nameParts: ['excluded-backend'] }, + ]; + const client = createMockClient(); + const store = createMockStore(resources); + store.readContent.mockImplementation(async (_sourceDir: string, descriptor: ResourceDescriptor) => { + if (descriptor.type !== ResourceType.ApiOperationPolicy) { + return undefined; + } + const backend = descriptor.nameParts[1] === 'get-orders' + ? 'included-backend' + : 'excluded-backend'; + return { content: `` }; + }); + + await runPublish(client, store, { + service: testContext, + sourceDir: '/source', + filter: { + apis: ['orders'], + apiSubFilters: { + orders: { operations: ['get-orders'] }, + }, + backends: [], + }, + dryRun: false, + deleteUnmatched: false, + logLevel: LogLevel.INFO, + }); + + const backendNames = client.putResource.mock.calls + .filter((call) => call[1].type === ResourceType.Backend) + .map((call) => call[1].nameParts[0]); + expect(backendNames).toContain('included-backend'); + expect(backendNames).not.toContain('excluded-backend'); + }); + + it('should not resolve dependencies from workspace API operations excluded by a sub-filter', async () => { + const resources: ResourceDescriptor[] = [ + { type: ResourceType.Api, nameParts: ['orders'], workspace: 'team-a' }, + { + type: ResourceType.ApiOperationPolicy, + nameParts: ['orders', 'get-orders'], + workspace: 'team-a', + }, + { + type: ResourceType.ApiOperationPolicy, + nameParts: ['orders', 'delete-orders'], + workspace: 'team-a', + }, + { type: ResourceType.Backend, nameParts: ['included-backend'], workspace: 'team-a' }, + { type: ResourceType.Backend, nameParts: ['excluded-backend'], workspace: 'team-a' }, + ]; + const client = createMockClient(); + const store = createMockStore(resources); + store.readContent.mockImplementation(async (_sourceDir: string, descriptor: ResourceDescriptor) => { + if (descriptor.type !== ResourceType.ApiOperationPolicy) { + return undefined; + } + const backend = descriptor.nameParts[1] === 'get-orders' + ? 'included-backend' + : 'excluded-backend'; + return { content: `` }; + }); + + await runPublish(client, store, { + service: testContext, + sourceDir: '/source', + filter: { + workspaces: ['team-a'], + workspaceSubFilters: { + 'team-a': { + apis: ['orders'], + apiSubFilters: { + orders: { operations: ['get-orders'] }, + }, + backends: [], + }, + }, + }, + dryRun: false, + deleteUnmatched: false, + logLevel: LogLevel.INFO, + }); + + const backendNames = client.putResource.mock.calls + .filter((call) => call[1].type === ResourceType.Backend) + .map((call) => call[1].nameParts[0]); + expect(backendNames).toContain('included-backend'); + expect(backendNames).not.toContain('excluded-backend'); + }); + it('should report filtered targets in dry-run mode', async () => { const resources = [ { type: ResourceType.NamedValue, nameParts: ['keep'] }, @@ -682,6 +942,36 @@ describe('publish-service', () => { expect(client.putResource).not.toHaveBeenCalled(); }); + it('should return a partial failure when a dry-run existence check fails', async () => { + vi.mocked(generateDryRunReport).mockResolvedValueOnce({ + actions: [{ + operation: 'SKIP', + type: ResourceType.Tag, + name: 'tag1', + descriptor: { type: ResourceType.Tag, nameParts: ['tag1'] }, + reason: 'existence check failed: network error', + error: 'existence check failed: network error', + }], + summary: { creates: 0, patches: 0, deletes: 0, skips: 1 }, + }); + const client = createMockClient(); + const store = createMockStore([ + { type: ResourceType.Tag, nameParts: ['tag1'] }, + ]); + + const result = await runPublish(client, store, { + service: testContext, + sourceDir: '/source', + dryRun: true, + deleteUnmatched: false, + logLevel: LogLevel.INFO, + }); + + expect(result.exitCode).toBe(1); + expect(result.totalErrors).toBe(1); + expect(result.totalSkipped).toBe(1); + }); + it('should abort the entire publish before any PUT when a redaction marker is found', async () => { const resources = [ { type: ResourceType.NamedValue, nameParts: ['nv-secret'] }, @@ -770,7 +1060,7 @@ describe('publish-service', () => { expect(computeGitDiff).toHaveBeenCalledWith('/source', 'abc123'); }); - it('should delete descriptors removed in the commit when commitId is set', async () => { + it('should delete descriptors removed in the commit when explicitly enabled', async () => { const client = createMockClient(); const store = createMockStore([]); @@ -787,8 +1077,11 @@ describe('publish-service', () => { service: testContext, sourceDir: '/source', dryRun: false, - deleteUnmatched: false, + deleteUnmatched: true, commitId: 'abc123', + overrides: { + environment: { namePrefix: 'dev-' }, + }, logLevel: LogLevel.INFO, }; @@ -798,13 +1091,96 @@ describe('publish-service', () => { testContext, expect.objectContaining({ type: ResourceType.Tag, - nameParts: ['old-tag'], + nameParts: ['dev-old-tag'], }) ); expect(result.totalDeletes).toBe(1); expect(computeDeleteActions).not.toHaveBeenCalled(); }); + it('should not delete commit-scoped descriptors without explicit opt-in', async () => { + const client = createMockClient(); + const store = createMockStore([]); + vi.mocked(computeGitDiff).mockResolvedValue({ + changedDescriptors: [], + deletedDescriptors: [ + { type: ResourceType.Tag, nameParts: ['old-tag'] }, + ], + }); + + const result = await runPublish(client, store, { + service: testContext, + sourceDir: '/source', + dryRun: false, + deleteUnmatched: false, + commitId: 'abc123', + logLevel: LogLevel.INFO, + }); + + expect(client.deleteResource).not.toHaveBeenCalled(); + expect(result.totalDeletes).toBe(0); + }); + + it('should resolve opaque workspace association links before incremental deletion', async () => { + const client = createMockClient(); + client.listResources = async function* () { + yield { + name: 'opaque-link', + properties: { + apiId: `${testContext.baseUrl}/workspaces/team/apis/orders`, + }, + }; + }; + const store = createMockStore([]); + vi.mocked(computeGitDiff).mockResolvedValue({ + changedDescriptors: [], + deletedDescriptors: [{ + type: ResourceType.ProductApi, + nameParts: ['store', 'orders'], + workspace: 'team', + }], + }); + + await runPublish(client, store, { + service: testContext, + sourceDir: '/source', + dryRun: false, + deleteUnmatched: true, + commitId: 'abc123', + logLevel: LogLevel.INFO, + }); + + expect(client.deleteResource).toHaveBeenCalledWith(testContext, { + type: ResourceType.ProductApi, + nameParts: ['store', 'opaque-link'], + workspace: 'team', + }); + }); + + it('should not run full unmatched deletion when an incremental commit has no deletions', async () => { + const client = createMockClient(); + client.listResources = async function* () { + yield await Promise.reject(new Error('Full unmatched discovery must not run')); + }; + const store = createMockStore([]); + vi.mocked(computeGitDiff).mockResolvedValue({ + changedDescriptors: [], + deletedDescriptors: [], + }); + + const result = await runPublish(client, store, { + service: testContext, + sourceDir: '/source', + dryRun: false, + deleteUnmatched: true, + commitId: 'abc123', + logLevel: LogLevel.INFO, + }); + + expect(client.deleteResource).not.toHaveBeenCalled(); + expect(result.totalDeletes).toBe(0); + }); + it('incremental mode: lists FULL artifact set for env-mapping validation and known-artifact sets', async () => { // Regression: prior to this fix, publish-service passed the changed // subset (from computeGitDiff) to both validateAndBuildEnvMapping and @@ -869,7 +1245,7 @@ describe('publish-service', () => { service: testContext, sourceDir: '/source', dryRun: true, - deleteUnmatched: false, + deleteUnmatched: true, commitId: 'abc123', logLevel: LogLevel.INFO, }; @@ -895,7 +1271,7 @@ describe('publish-service', () => { const store = createMockStore(resources); vi.mocked(computeDeleteActions).mockResolvedValue([ - { type: ResourceType.Backend, nameParts: ['old-backend'] }, + { type: ResourceType.Backend, nameParts: ['dev-old-backend'] }, ]); const config: PublishConfig = { @@ -903,12 +1279,19 @@ describe('publish-service', () => { sourceDir: '/source', dryRun: false, deleteUnmatched: true, + overrides: { + environment: { namePrefix: 'dev-' }, + }, logLevel: LogLevel.INFO, }; const result = await runPublish(client, store, config); expect(computeDeleteActions).toHaveBeenCalled(); + expect(client.deleteResource).toHaveBeenCalledWith(testContext, { + type: ResourceType.Backend, + nameParts: ['dev-old-backend'], + }); expect(result.totalDeletes).toBe(1); }); @@ -1202,8 +1585,7 @@ describe('publish-service', () => { const store = createMockStore(resources); // premium-pool is a pool backend; the other two are regular backends. // The pool service references (weight/priority) are included to reflect - // real APIM artifacts; their values are passed through opaquely by the - // resource publisher (FR-009) and are not inspected here. + // real APIM artifacts; their non-ID values remain opaque during publish. vi.mocked(store.readResource).mockImplementation( async (_sourceDir, descriptor) => { if ((descriptor.nameParts[descriptor.nameParts.length - 1] ?? '') === 'premium-pool') { @@ -1384,6 +1766,103 @@ describe('publish-service', () => { expect(productPutCalls).toHaveLength(0); }); + it('counts concrete product association results returned by the live publisher', async () => { + const product: ResourceDescriptor = { + type: ResourceType.Product, + nameParts: ['my-product'], + }; + vi.mocked(publishProduct).mockResolvedValueOnce({ + descriptor: product, + status: 'success', + action: 'put', + relatedResults: [ + { + descriptor: { + type: ResourceType.ProductApi, + nameParts: ['my-product', 'orders'], + }, + status: 'success', + action: 'put', + }, + { + descriptor: { + type: ResourceType.ProductGroup, + nameParts: ['my-product', 'developers'], + }, + status: 'success', + action: 'put', + }, + { + descriptor: { + type: ResourceType.ProductTag, + nameParts: ['my-product', 'production'], + }, + status: 'skipped', + action: 'noop', + }, + ], + }); + const client = createMockClient(); + const store = createMockStore([product]); + + const result = await runPublish(client, store, { + service: testContext, + sourceDir: '/source', + dryRun: false, + deleteUnmatched: false, + logLevel: LogLevel.INFO, + }); + + expect(result.totalPuts).toBe(3); + expect(result.totalSkipped).toBe(1); + expect(result.actions.map((action) => action.descriptor.type)).toEqual([ + ResourceType.Product, + ResourceType.ProductApi, + ResourceType.ProductGroup, + ResourceType.ProductTag, + ]); + }); + + it('counts concrete API PATCH results separately from PUTs', async () => { + const api: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: ['orders'], + }; + const operation: ResourceDescriptor = { + type: ResourceType.ApiOperation, + nameParts: ['orders', 'get-orders'], + }; + vi.mocked(publishApi).mockResolvedValueOnce({ + descriptor: api, + status: 'success', + action: 'put', + relatedResults: [ + { + descriptor: operation, + status: 'success', + action: 'patch', + }, + ], + }); + const client = createMockClient(); + const store = createMockStore([api]); + + const result = await runPublish(client, store, { + service: testContext, + sourceDir: '/source', + dryRun: false, + deleteUnmatched: false, + logLevel: LogLevel.INFO, + }); + + expect(result.totalPuts).toBe(1); + expect(result.totalPatches).toBe(1); + expect(result.actions).toMatchObject([ + { descriptor: api, action: 'put' }, + { descriptor: operation, action: 'patch' }, + ]); + }); + it('skips ProductApi children when parent Product is in the batch', async () => { const resources: ResourceDescriptor[] = [ { type: ResourceType.Product, nameParts: ['my-product'] }, diff --git a/tests/unit/services/resource-publisher.env-mapping.test.ts b/tests/unit/services/resource-publisher.env-mapping.test.ts index 2536734e..45eea59c 100644 --- a/tests/unit/services/resource-publisher.env-mapping.test.ts +++ b/tests/unit/services/resource-publisher.env-mapping.test.ts @@ -215,6 +215,72 @@ describe('resource-publisher env-mapping', () => { const [, , payload] = client.putResource.mock.calls[0] as [unknown, unknown, Record]; expect((payload.properties as Record).scope).toBe('/products/dev-starter'); }); + + it.each([ + ['apis', 'petstore-api', 'dev-petstore-api'], + ['products', 'starter', 'dev-starter'], + ])('affixes %s target names in full ARM workspace scopes', async (segment, name, deployedName) => { + const armPrefix = + '/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.ApiManagement/service/apim-1'; + store.readResource.mockResolvedValue({ + name: 'workspace-sub', + properties: { + scope: `${armPrefix}/workspaces/team/${segment}/${name}`, + displayName: 'Workspace Sub', + }, + }); + const descriptor: ResourceDescriptor = { + type: ResourceType.Subscription, + nameParts: ['workspace-sub'], + workspace: 'team', + }; + const config = makeConfig({ envMapping: DEV_MAPPING, knownArtifactSets: EMPTY_KNOWN }); + + await publishResource(client, store, testContext, descriptor, config); + + const [, , payload] = client.putResource.mock.calls[0] as [ + unknown, + unknown, + Record, + ]; + expect((payload.properties as Record).scope).toBe( + `/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.ApiManagement/service/apim-1/workspaces/dev-team/${segment}/${deployedName}` + ); + }); + + it('rebuilds a source-service workspace scope for the target service', async () => { + const sourcePrefix = + '/subscriptions/source-sub/resourceGroups/source-rg/providers/Microsoft.ApiManagement/service/source-apim'; + store.readResource.mockResolvedValue({ + name: 'workspace-sub', + properties: { + scope: `${sourcePrefix}/workspaces/team/apis/petstore-api`, + displayName: 'Workspace Sub', + }, + }); + const descriptor: ResourceDescriptor = { + type: ResourceType.Subscription, + nameParts: ['workspace-sub'], + workspace: 'team', + }; + + await publishResource( + client, + store, + testContext, + descriptor, + makeConfig({ envMapping: DEV_MAPPING, knownArtifactSets: EMPTY_KNOWN }) + ); + + const [, , payload] = client.putResource.mock.calls[0] as [ + unknown, + unknown, + Record, + ]; + expect((payload.properties as Record).scope).toBe( + '/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.ApiManagement/service/apim-1/workspaces/dev-team/apis/dev-petstore-api' + ); + }); }); describe('ApiRelease apiId', () => { diff --git a/tests/unit/services/resource-publisher.test.ts b/tests/unit/services/resource-publisher.test.ts index 5f67af11..6855527c 100644 --- a/tests/unit/services/resource-publisher.test.ts +++ b/tests/unit/services/resource-publisher.test.ts @@ -5,7 +5,10 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { publishResource } from '../../../src/services/resource-publisher.js'; +import { + publishResource, + resolveAssociationDeleteDescriptor, +} from '../../../src/services/resource-publisher.js'; import { ResourceType } from '../../../src/models/resource-types.js'; import { ApimServiceContext, ResourceDescriptor } from '../../../src/models/types.js'; import { PublishConfig } from '../../../src/models/config.js'; @@ -72,6 +75,128 @@ function generatedSubscriptionId(fill: string): string { } describe('resource-publisher', () => { + describe('resolveAssociationDeleteDescriptor', () => { + it('resolves an opaque workspace API link name from its target ARM ID', async () => { + const client = createMockClient(); + client.listResources = async function* () { + yield { + name: 'opaque-api-link', + properties: { + apiId: `${testContext.baseUrl}/workspaces/team/apis/orders`, + }, + }; + }; + + const result = await resolveAssociationDeleteDescriptor( + client, + testContext, + { + type: ResourceType.ProductApi, + nameParts: ['store', 'orders'], + workspace: 'team', + } + ); + + expect(result).toEqual({ + type: ResourceType.ProductApi, + nameParts: ['store', 'opaque-api-link'], + workspace: 'team', + }); + }); + + it('resolves an opaque workspace ProductTag link with inverted path segments', async () => { + const client = createMockClient(); + client.listResources = async function* () { + yield { + name: 'opaque-product-link', + properties: { + productId: `${testContext.baseUrl}/workspaces/team/products/store`, + }, + }; + }; + + const result = await resolveAssociationDeleteDescriptor( + client, + testContext, + { + type: ResourceType.ProductTag, + nameParts: ['store', 'production'], + workspace: 'team', + } + ); + + expect(result).toEqual({ + type: ResourceType.ProductTag, + nameParts: ['opaque-product-link', 'production'], + workspace: 'team', + }); + }); + + it('resolves an opaque workspace ApiTag link with inverted path segments', async () => { + const client = createMockClient(); + client.listResources = async function* () { + yield { + name: 'opaque-api-link', + properties: { + apiId: `${testContext.baseUrl}/workspaces/team/apis/orders`, + }, + }; + }; + + const result = await resolveAssociationDeleteDescriptor( + client, + testContext, + { + type: ResourceType.ApiTag, + nameParts: ['orders', 'production'], + workspace: 'team', + } + ); + + expect(result).toEqual({ + type: ResourceType.ApiTag, + nameParts: ['opaque-api-link', 'production'], + workspace: 'team', + }); + }); + + it('uses target scope to distinguish otherwise identical workspace links', async () => { + const client = createMockClient(); + client.listResources = async function* () { + yield { + name: 'workspace-link', + properties: { + apiId: `${testContext.baseUrl}/workspaces/team/apis/orders`, + }, + }; + yield { + name: 'service-link', + properties: { + apiId: `${testContext.baseUrl}/apis/orders`, + }, + }; + }; + + const result = await resolveAssociationDeleteDescriptor( + client, + testContext, + { + type: ResourceType.ProductApi, + nameParts: ['store', 'orders'], + workspace: 'team', + targetScope: 'service', + } + ); + + expect(result).toEqual({ + type: ResourceType.ProductApi, + nameParts: ['store', 'service-link'], + workspace: 'team', + targetScope: 'service', + }); + }); + }); + describe('publishResource', () => { beforeEach(() => { mockCheckKeyVaultSecretAccess.mockClear(); @@ -133,7 +258,7 @@ describe('resource-publisher', () => { const result = await publishResource(client, store, testContext, descriptor, testConfig); expect(result.status).toBe('failed'); - expect(result.action).toBe('noop'); + expect(result.action).toBe('put'); expect(result.error).toBeDefined(); expect(result.error?.message).toBe('Network error'); }); @@ -207,6 +332,69 @@ describe('resource-publisher', () => { expect(((putJson.properties as Record)).nestedObject).toHaveProperty('prop1', 'val1'); }); + it('should map backend pool member IDs to the target service and environment', async () => { + const client = createMockClient(); + const store = createMockStore(); + store.readResource.mockResolvedValue({ + name: 'pool', + properties: { + type: 'Pool', + pool: { + services: [ + { + id: '/subscriptions/source/resourceGroups/source/providers/Microsoft.ApiManagement/service/source/backends/member', + weight: 1, + }, + { + id: '/subscriptions/source/resourceGroups/source/providers/Microsoft.ApiManagement/service/source/workspaces/team/backends/nested-member', + priority: 2, + }, + ], + }, + }, + }); + const config: PublishConfig = { + ...testConfig, + envMapping: { + prefix: 'dev-', + suffix: '', + appliesTo: new Set([ + ResourceType.Backend, + ResourceType.Workspace, + ]), + }, + }; + + await publishResource( + client, + store, + testContext, + { type: ResourceType.Backend, nameParts: ['pool'] }, + config + ); + + expect(client.putResource).toHaveBeenCalledWith( + testContext, + { type: ResourceType.Backend, nameParts: ['dev-pool'], workspace: undefined }, + expect.objectContaining({ + properties: expect.objectContaining({ + pool: expect.objectContaining({ + services: [ + { + id: `${testContext.baseUrl.replace(/^https?:\/\/[^/]+/, '')}/backends/dev-member`, + weight: 1, + }, + { + id: `${testContext.baseUrl.replace(/^https?:\/\/[^/]+/, '')}/workspaces/dev-team/backends/dev-nested-member`, + priority: 2, + }, + ], + }), + }), + }) + ); + }); + it('should publish policy content for policy resources without calling readResource', async () => { const client = createMockClient(); const store = createMockStore(); @@ -324,6 +512,48 @@ describe('resource-publisher', () => { ); }); + it('should preserve target scope for same-named workspace associations', async () => { + const client = createMockClient(); + const store = createMockStore(); + store.readAssociation.mockResolvedValue([ + { name: 'orders', scope: 'service' }, + { name: 'orders', scope: 'workspace' }, + ]); + const descriptor: ResourceDescriptor = { + type: ResourceType.ProductApi, + nameParts: ['store'], + workspace: 'team', + }; + + const result = await publishResource( + client, + store, + testContext, + descriptor, + testConfig + ); + + expect(result.relatedResults).toMatchObject([ + { + descriptor: { + type: ResourceType.ProductApi, + nameParts: ['store', 'orders'], + workspace: 'team', + targetScope: 'service', + }, + }, + { + descriptor: { + type: ResourceType.ProductApi, + nameParts: ['store', 'orders'], + workspace: 'team', + targetScope: 'workspace', + }, + }, + ]); + expect(client.putResource).toHaveBeenCalledTimes(2); + }); + it('should handle association resources (GatewayApi)', async () => { const client = createMockClient(); const store = createMockStore(); @@ -822,6 +1052,104 @@ describe('resource-publisher', () => { expect(props.scope).toBe('/apis/my-api'); }); + it.each([ + ['/apis/orders', ResourceType.Api, 'orders', 'apis'], + ['/products/store', ResourceType.Product, 'store', 'products'], + ] as const)( + 'should publish workspace Subscription with eligible relative target %s', + async (scope, targetType, targetName, filterField) => { + const client = createMockClient(); + const store = createMockStore(); + const subscriptionJson = { + name: 'workspace-sub', + properties: { scope, state: 'active' }, + }; + store.readResource.mockImplementation( + async (_dir: string, descriptor: ResourceDescriptor) => { + if (descriptor.type === ResourceType.Subscription) return subscriptionJson; + if ( + descriptor.type === targetType && + descriptor.workspace === 'team' && + descriptor.nameParts[0] === targetName + ) { + return { properties: {} }; + } + return undefined; + } + ); + const descriptor: ResourceDescriptor = { + type: ResourceType.Subscription, + nameParts: ['workspace-sub'], + workspace: 'team', + }; + const workspaceFilter = { + subscriptions: ['workspace-sub'], + [filterField]: [targetName], + }; + const config: PublishConfig = { + ...testConfig, + filter: { + apis: [], + products: [], + workspaces: ['team'], + workspaceSubFilters: { team: workspaceFilter }, + }, + }; + + const result = await publishResource(client, store, testContext, descriptor, config); + + expect(result.status).toBe('success'); + expect(client.putResource).toHaveBeenCalledOnce(); + const payload = client.putResource.mock.calls[0]?.[2] as Record; + const properties = payload.properties as Record; + expect(properties.scope).toBe( + `/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.ApiManagement/service/apim-1/workspaces/team${scope}` + ); + } + ); + + it('should evaluate a full ARM service target against the service filter from a workspace subscription', async () => { + const client = createMockClient(); + const store = createMockStore(); + const armScopePrefix = + '/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.ApiManagement/service/apim-1'; + store.readResource.mockImplementation( + async (_dir: string, descriptor: ResourceDescriptor) => { + if (descriptor.type === ResourceType.Subscription) { + return { + name: 'workspace-sub', + properties: { scope: `${armScopePrefix}/apis/shared` }, + }; + } + return descriptor.type === ResourceType.Api && + descriptor.workspace === undefined && + descriptor.nameParts[0] === 'shared' + ? { properties: {} } + : undefined; + } + ); + const descriptor: ResourceDescriptor = { + type: ResourceType.Subscription, + nameParts: ['workspace-sub'], + workspace: 'team', + }; + const config: PublishConfig = { + ...testConfig, + filter: { + apis: ['shared'], + workspaces: ['team'], + workspaceSubFilters: { + team: { subscriptions: ['workspace-sub'], apis: [] }, + }, + }, + }; + + const result = await publishResource(client, store, testContext, descriptor, config); + + expect(result.status).toBe('success'); + expect(client.putResource).toHaveBeenCalledOnce(); + }); + it('should skip subscription with root scope (master subscription)', async () => { // The master subscription has scope as the service root, which results in // "/" after ARM path stripping. This is invalid and the subscription should be skipped. diff --git a/tests/unit/services/transitive-resolver.test.ts b/tests/unit/services/transitive-resolver.test.ts index f111fa33..8d05947e 100644 --- a/tests/unit/services/transitive-resolver.test.ts +++ b/tests/unit/services/transitive-resolver.test.ts @@ -308,6 +308,35 @@ describe('transitive-resolver', () => { workspace: undefined, }); }); + + it.each([ + ['/apis/orders', ResourceType.Api, 'orders'], + ['/products/store', ResourceType.Product, 'store'], + ])( + 'should inherit workspace scope for relative target %s', + (scope, type, name) => { + expect(findSubscriptionTargets({ + properties: { scope }, + }, 'team-a')).toContainEqual({ + type, + nameParts: [name], + workspace: 'team-a', + }); + } + ); + + it('should use the workspace encoded in a full ARM target', () => { + expect(findSubscriptionTargets({ + properties: { + scope: + '/subscriptions/s/resourceGroups/r/providers/Microsoft.ApiManagement/service/a/workspaces/Team%20A/apis/Orders%20API', + }, + }, 'fallback')).toContainEqual({ + type: ResourceType.Api, + nameParts: ['Orders API'], + workspace: 'Team A', + }); + }); }); }); });