diff --git a/README.md b/README.md index b4a18321..b438e654 100644 --- a/README.md +++ b/README.md @@ -85,9 +85,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 absent from artifacts, or removed by an incremental commit (mutually exclusive with `--filter`) | ```bash apiops publish --help @@ -109,8 +111,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/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 d074d5fe..8d14dc32 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 | +| `--delete-unmatched` | boolean | `false` | No | Delete APIM resources absent from source, or removed by an incremental commit | -> **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 `--filter`; with `--commit-id`, it explicitly enables commit-scoped deletions. ### Global flags @@ -168,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 @@ -185,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/filtering-resources.md b/docs/guides/filtering-resources.md index 63f6b303..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 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 61bfc165..7caf3abc 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.' ); 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 && hasFilter; } /** @@ -182,6 +198,7 @@ function outputJson(result: PublishResult): void { exitCode: number; summary: { totalPuts: number; + totalPatches: number; totalDeletes: number; totalErrors: number; totalSkipped: number; @@ -198,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; }; @@ -215,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, @@ -235,6 +255,7 @@ function outputJson(result: PublishResult): void { operation: a.operation, type: a.type, name: a.name, + error: a.error, })), summary: result.dryRunReport.summary, }; @@ -257,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`); @@ -272,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 6b2774ba..51fc96d5 100644 --- a/src/lib/resource-path.ts +++ b/src/lib/resource-path.ts @@ -153,6 +153,24 @@ export function getApiRootName(apiName: string): string { return apiName.split(';rev=')[0] ?? apiName; } +/** + * Create a case-insensitive identity key for a resource descriptor. + */ +export function getResourceDescriptorKey(descriptor: ResourceDescriptor): string { + const scopeSuffix = descriptor.targetScope ? `:${descriptor.targetScope}` : ''; + return `${descriptor.type}:${descriptor.workspace ?? ''}:${descriptor.nameParts.join('/')}${scopeSuffix}`.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 @@ -410,6 +428,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/config.ts b/src/models/config.ts index e715e65a..6c5085fe 100644 --- a/src/models/config.ts +++ b/src/models/config.ts @@ -89,6 +89,8 @@ export interface KnownArtifactSets { export interface PublishConfig { service: ApimServiceContext; sourceDir: string; + filter?: FilterConfig; + includeTransitive?: boolean; overrides?: OverrideConfig; /** * Pre-built environment name mapping (prefix/suffix + appliesTo). 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 5743887f..d78917ab 100644 --- a/src/services/api-publisher.ts +++ b/src/services/api-publisher.ts @@ -14,23 +14,30 @@ import type { PublishConfig } from '../models/config.js'; import * as yaml from 'js-yaml'; import { ResourceType } from '../models/resource-types.js'; import { + applyApiPathPrefix, normalizeApiAuthenticationSettings, normalizeMcpToolOperationIds, + normalizeApiVersionSetId, prefersLegacyAuthOverride, 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 { mapDescriptor } from './env-mapper.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 */ -const API_CHILD_TYPES: ResourceType[] = [ +export const API_CHILD_TYPES: ResourceType[] = [ ResourceType.ApiPolicy, ResourceType.ApiTag, ResourceType.ApiDiagnostic, @@ -44,6 +51,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. @@ -54,19 +75,26 @@ export async function publishApi( store: IArtifactStore, context: ApimServiceContext, descriptor: ResourceDescriptor, - config: PublishConfig + config: PublishConfig, + allowedDescriptors?: ResourceDescriptor[] ): Promise { + let publicationPlan: ApiPublicationPlan; + let rootPutDescriptor: ResourceDescriptor; + let rootPublished = false; try { + publicationPlan = await planApiPublication( + store, + descriptor, + config, + allowedDescriptors + ); + // Deployed (env-mapped) base descriptor — derived once and used for every // direct APIM call so canonical and affixed names can never diverge. const deployedDescriptor = config.envMapping ? mapDescriptor(descriptor, config.envMapping) : descriptor; - - // Step 1: Publish root API (with spec import if available). - // On a fresh target the root is created at its source revision number so - // it cannot collide with ;rev=N revision artifacts. - const putDescriptor = await resolveRootApiPutDescriptor( + rootPutDescriptor = await resolveRootApiPutDescriptor( client, store, context, @@ -74,56 +102,276 @@ export async function publishApi( deployedDescriptor, config ); + } 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, { - putDescriptor, + includeSpecification: publicationPlan.importSpecification, + putDescriptor: rootPutDescriptor, }); 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, - deployedDescriptor, - rootResult.operationIdsWithNullDescription - ); + publicationPlan.operationDescriptionPuts, + config + )); } // Step 2: Find and publish revisions in numeric order - const publishedRevisionCount = await publishApiRevisions(client, store, context, descriptor, config); + const revisionResults = await publishApiRevisions( + client, + store, + context, + config, + 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 publishRootApi(client, store, context, descriptor, config, { - includeSpecification: false, - putDescriptor: deployedDescriptor, - }); + 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(client, store, context, descriptor, config, rootResult.specImported); + relatedResults.push(...await publishApiChildren( + client, + store, + context, + config, + 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)), }; } } +export async function planApiPublication( + store: IArtifactStore, + apiDescriptor: ResourceDescriptor, + config: PublishConfig, + allowedDescriptors?: ResourceDescriptor[] +): Promise { + const allDescriptors = await store.listResources(config.sourceDir); + const apiName = getNamePart(apiDescriptor.nameParts, 0); + const managedChildren = allDescriptors.filter( + (descriptor) => + SPEC_MANAGED_CHILD_TYPES.has(descriptor.type) && + getNamePart(descriptor.nameParts, 0).toLowerCase() === apiName.toLowerCase() && + descriptor.workspace === apiDescriptor.workspace + ); + const effectiveFilter = apiDescriptor.workspace + ? resolveWorkspaceFilter(apiDescriptor.workspace, config.filter) + : config.filter; + 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(); + let 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 rootRevision = (apiJson?.properties as Record | undefined)?.apiRevision; + if (typeof rootRevision === 'string' && rootRevision !== '') { + const rootRevisionNumber = Number(rootRevision); + revisions = revisions.filter( + (revision) => + extractRevisionNumber(getNamePart(revision.nameParts, 0)) !== rootRevisionNumber + ); + } + 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)) + )) + ); + + 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, + })); + } + } + } + + 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, + }; +} + /** * Maps spec file format to APIM ContentFormat for inline import. * @param specDialect - The spec dialect of a JSON spec. Swagger 2.0 ('swagger2') @@ -160,7 +408,6 @@ function getImportFormat(specFormat: string, _apiType?: string, specDialect?: Ap interface RootApiResult { status: 'success' | 'skipped'; specImported: boolean; - operationIdsWithNullDescription?: string[]; isCurrent?: boolean; } @@ -237,8 +484,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 — including env-mapped API names so the - // tool operationIds match the affixed API this PUT targets. + // APIs receive in resource-publisher. json = normalizeMcpToolOperationIds(json, context, config.envMapping); // Apply overrides @@ -253,7 +499,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') @@ -294,93 +539,77 @@ 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, options?.putDescriptor ?? descriptor, json); + try { + await client.putResource(context, options?.putDescriptor ?? deployedDescriptor, json); + } catch (error) { + throw new ApiPutAttemptError(error); + } return { descriptor, status: 'success', action: 'put', specImported, - operationIdsWithNullDescription, isCurrent, }; } -/** - * Find and publish API revisions in numeric order - */ -async function publishApiRevisions( +async function alignActiveRevisionWithSource( client: IApimClient, store: IArtifactStore, context: ApimServiceContext, - apiDescriptor: ResourceDescriptor, + descriptor: ResourceDescriptor, config: PublishConfig -): Promise { - // List all resources from store - const allDescriptors = await store.listResources(config.sourceDir); - - // 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=`) +): Promise { + logger.debug( + `Source marks "${getNamePart(descriptor.nameParts, 0)}" as current; re-applying root metadata to align active revision` ); - // 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; + return publishRootApi(client, store, context, descriptor, config, { + includeSpecification: false, }); +} - // The current revision is already published as the root API, so skip a - // revision artifact that carries the same number — otherwise we re-create / - // collide with it (e.g. the root PUT created at ;rev=N for a fresh - // multi-revision API, or a stale ;rev=N folder from an older extract). - const rootJson = await store.readResource(config.sourceDir, apiDescriptor); - const rootRevision = (rootJson?.properties as Record | undefined)?.apiRevision; - const rootRevisionNumber = - typeof rootRevision === 'string' && rootRevision !== '' ? Number(rootRevision) : undefined; - - // Publish each revision in order; a failed revision must fail the API — - // otherwise errors are silently swallowed and the exit code stays 0. - let publishedCount = 0; - for (const revDescriptor of sortedRevisions) { - if ( - rootRevisionNumber !== undefined && - extractRevisionNumber(getNamePart(revDescriptor.nameParts, 0)) === rootRevisionNumber - ) { - logger.debug( - `Skipping revision ${getNamePart(revDescriptor.nameParts, 0)} — already published as the root API` - ); - continue; - } +/** + * Find and publish API revisions in numeric order + */ +async function publishApiRevisions( + client: IApimClient, + store: IArtifactStore, + context: ApimServiceContext, + config: PublishConfig, + revisionDescriptors: ResourceDescriptor[] +): Promise { + // Publish each revision in order + const results: ResourcePublishResult[] = []; + for (const revDescriptor of revisionDescriptors) { const result = await publishResource(client, store, context, revDescriptor, config); if (result.status === 'failed') { throw new Error( - `Failed to publish revision ${getNamePart(revDescriptor.nameParts, 0)}: ` + - `${result.error?.message ?? 'unknown error'}` + `Failed to publish revision ${getNamePart(revDescriptor.nameParts, 0)}: ${result.error?.message ?? 'unknown error'}` ); } - if (result.status === 'success') publishedCount++; + results.push(result); } - // Return only revisions actually published so the caller does not run a - // spurious active-revision alignment PUT when nothing changed. - return publishedCount; + return results; } /** @@ -422,45 +651,15 @@ async function publishApiChildren( client: IApimClient, store: IArtifactStore, context: ApimServiceContext, - apiDescriptor: ResourceDescriptor, config: PublishConfig, - specImported: boolean = false -): Promise { - // List all resources from store - const allDescriptors = await store.listResources(config.sourceDir); - - // 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) && - !(specImported && SPEC_MANAGED_CHILD_TYPES.has(d.type)) - ); - - 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) === getNamePart(apiDescriptor.nameParts, 0) && - !isAutoGeneratedId(getNamePart(d.nameParts, 1)) - ); - - if (explicitSchemas.length > 0) { - logger.debug( - `Re-publishing ${explicitSchemas.length} explicit schema(s) after spec import for "${getNamePart(apiDescriptor.nameParts, 0)}"` - ); - childDescriptors = [...childDescriptors, ...explicitSchemas]; - } - } - + 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); @@ -472,21 +671,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); - } + results.push(...await reconcileOperationsAfterSpecImport( + client, + context, + plan.operationPatches, + config + )); + return results; } /** @@ -500,77 +713,87 @@ async function publishApiChildren( */ async function reconcileOperationsAfterSpecImport( client: IApimClient, - store: IArtifactStore, context: ApimServiceContext, - apiDescriptor: ResourceDescriptor, - config: PublishConfig, - allDescriptors: ResourceDescriptor[] -): Promise { - const operationDescriptors = allDescriptors.filter( - (d) => - d.type === ResourceType.ApiOperation && - getNamePart(d.nameParts, 0) === getNamePart(apiDescriptor.nameParts, 0) && - !isAutoGeneratedId(getNamePart(d.nameParts, 1)) - ); - - 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 }; - - // Artifact lookup above uses the canonical descriptor; the PATCH must target - // the deployed (env-mapped) name so reconciliation hits the API that the - // root create actually produced under environment mapping. - const patchDescriptor = config.envMapping - ? mapDescriptor(descriptor, config.envMapping) - : descriptor; - + operationPatches: ApiPatchPlan[], + config: PublishConfig +): Promise { + const tasks = operationPatches.map(({ descriptor, payload }) => async () => { try { - await client.patchResource(context, patchDescriptor, 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; } /** @@ -669,36 +892,89 @@ 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( diff --git a/src/services/dry-run-reporter.ts b/src/services/dry-run-reporter.ts index 9ccc66d7..c0812616 100644 --- a/src/services/dry-run-reporter.ts +++ b/src/services/dry-run-reporter.ts @@ -10,26 +10,41 @@ 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, filterRevisionDeletesHandledByBaseApi, } 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 }; } /** @@ -47,58 +62,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++; @@ -114,7 +164,20 @@ export async function generateDryRunReport( if (incrementalDeletedDescriptors.length > 0) { for (const descriptor of filterRevisionDeletesHandledByBaseApi(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 = { @@ -132,24 +195,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 = filterRevisionDeletesHandledByBaseApi( await computeDeleteActionsForDryRun( client, @@ -173,8 +240,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 }; } @@ -182,49 +249,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 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 expanded; + 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 4ce59e7c..f1459ce6 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,7 +172,11 @@ 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, + }; } /** @@ -185,6 +197,7 @@ function isManagedGatewayName(name: string, type: ResourceType): boolean { } export function toDeployedName(name: string, type: ResourceType, m: EnvMapping): string { + if (type === ResourceType.Group && isBuiltInGroup(name)) return name; if (isManagedGatewayName(name, type)) return name; if (!m.appliesTo.has(type)) return name; const { base, revSuffix } = splitRevisionSuffix(name, type); @@ -197,6 +210,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 (isManagedGatewayName(deployedName, type)) return deployedName; if (!m.appliesTo.has(type)) return deployedName; if (!isInEnvNamespace(deployedName, type, m)) return undefined; @@ -213,6 +227,7 @@ 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 (isManagedGatewayName(deployedName, type)) return true; if (!m.appliesTo.has(type)) return true; const { base } = splitRevisionSuffix(deployedName, type); @@ -220,15 +235,22 @@ export function isInEnvNamespace(deployedName: string, type: ResourceType, m: En return base.startsWith(m.prefix) && base.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) { @@ -237,11 +259,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/extract-service.ts b/src/services/extract-service.ts index ca5106d6..8c1d87b4 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) { @@ -501,14 +507,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 @@ -537,61 +535,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); } /** @@ -603,10 +567,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 0d7a5dc6..a73ca28b 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'; @@ -172,6 +172,40 @@ function getWorkspaceFilter( } : undefined; } +/** + * 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" @@ -239,6 +273,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/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 2720b0cb..8429561c 100644 --- a/src/services/product-publisher.ts +++ b/src/services/product-publisher.ts @@ -10,12 +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 } 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 { 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). @@ -26,57 +40,58 @@ export async function publishProduct( store: IArtifactStore, context: ApimServiceContext, descriptor: ResourceDescriptor, - config: PublishConfig + 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); - - // 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) { - 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}`); } @@ -85,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)), + }], }; } } @@ -157,120 +176,189 @@ 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 -): Promise { + allowedDescriptors?: ResourceDescriptor[] +): 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, - }; - - 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 -): 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, }; - + 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 ${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( + 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; } diff --git a/src/services/publish-service.ts b/src/services/publish-service.ts index d1052b92..517667a9 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,10 +17,28 @@ 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, isApiRevisionName, getApiRootName } from '../lib/resource-path.js'; +import { + getApiRootName, + getNamePart, + getResourceDescriptorKey, + isApiRevisionName, + isChildType, + isTopLevelSingleton, +} from '../lib/resource-path.js'; +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'; @@ -32,7 +50,9 @@ 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'; import { validateAndBuildEnvMapping } from './env-mapping-validator.js'; +import { mapDescriptor } from './env-mapper.js'; /** * The APIM Backend properties.type value that identifies a pool backend. @@ -43,13 +63,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; @@ -133,6 +154,7 @@ export async function runPublish( return { totalPuts: 0, + totalPatches: 0, totalDeletes: 0, totalErrors: actions.length, totalSkipped: 0, @@ -149,15 +171,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, }; @@ -174,13 +198,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, @@ -191,7 +216,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; @@ -200,6 +226,7 @@ export async function runPublish( return { totalPuts, + totalPatches, totalDeletes, totalErrors, totalSkipped, @@ -210,6 +237,7 @@ export async function runPublish( logger.error('Fatal error during publish:', error); return { totalPuts: 0, + totalPatches: 0, totalDeletes: 0, totalErrors: 1, totalSkipped: 0, @@ -226,24 +254,162 @@ 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, + config.filter + ); + } + + return { targetDescriptors, deletedDescriptors }; +} + +async function expandTransitivePublishTargets( + store: IArtifactStore, + sourceDir: string, + initialDescriptors: ResourceDescriptor[], + availableDescriptors: ResourceDescriptor[], + filter: FilterConfig +): 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; + } + 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(); + 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 || + !shouldReconcileResource(candidate, filter) + ) { + continue; + } + const candidateName = candidate.type === ResourceType.Api + ? extractRootApiName(candidate.nameParts[0] ?? '').toLowerCase() + : (candidate.nameParts[0] ?? '').toLowerCase(); + 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); + } + } + } + } + + 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 } + ); } /** @@ -284,7 +450,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)); } } @@ -300,7 +466,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 @@ -323,7 +489,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( @@ -331,7 +497,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` @@ -342,6 +508,7 @@ async function executePuts( context, config, poolBackends, + targetDescriptors, results ); } @@ -356,14 +523,14 @@ async function executePuts( apiDescriptors ); - await publishAndOutput(client, store, context, config, regularApis, results); + await publishAndOutput(client, store, context, config, regularApis, targetDescriptors, results); if (mcpApis.length > 0) { - logger.debug(`Publishing ${mcpApis.length} MCP API resource(s) after regular APIs`); - await publishAndOutput(client, store, context, config, mcpApis, results); + logger.debug(`Publishing ${mcpApis.length} MCP API resource(s) after regular tier 2 resources`); + await publishAndOutput(client, store, context, config, mcpApis, targetDescriptors, results); } - await publishAndOutput(client, store, context, config, nonApiDescriptors, results); + await publishAndOutput(client, store, context, config, nonApiDescriptors, targetDescriptors, results); } else { // For tiers 3/4, exclude child resources whose parent is being published // in tier 2 (publishApi/publishProduct handle their children internally). @@ -377,27 +544,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[] } { @@ -424,10 +631,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); @@ -538,7 +746,8 @@ async function publishTier( store: IArtifactStore, context: ApimServiceContext, config: PublishConfig, - descriptors: ResourceDescriptor[] + descriptors: ResourceDescriptor[], + allTargetDescriptors: ResourceDescriptor[] ): Promise { const tasks = descriptors.map((descriptor) => async () => { try { @@ -550,37 +759,50 @@ 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, 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 { @@ -589,12 +811,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'), - }; + }]; } }); } @@ -602,7 +824,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; @@ -610,11 +832,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; } @@ -628,10 +850,13 @@ 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(); +} /** * Execute DELETE operations in reverse dependency order (tier 4 → tier 1). */ @@ -655,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 + ); } /** @@ -664,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'); @@ -696,7 +929,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); @@ -715,11 +954,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 417b1225..1c4c17a1 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, MANAGED_GATEWAY_NAME } 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'; @@ -22,6 +30,8 @@ import { REDACTION_MARKER } from './secret-redactor.js'; import { isAssociationReferenceNotFoundError, isLinkAlreadyExistsError } from '../clients/apim-client.js'; import type { OverrideConfig, OverrideSection } 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'; import { mapDescriptor, toDeployedName } from './env-mapper.js'; import type { EnvMapping } from './env-mapper.js'; import { rewritePolicyRefs } from './policy-ref-rewriter.js'; @@ -32,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; } /** @@ -205,8 +229,10 @@ export async function publishResource( store: IArtifactStore, context: ApimServiceContext, descriptor: ResourceDescriptor, - config: PublishConfig + config: PublishConfig, + allowedDescriptors?: ResourceDescriptor[] ): Promise { + let attemptedPut = false; try { // The built-in "managed" gateway cannot be created/updated as a resource; // only its API assignments are manageable. Skip any managed Gateway resource @@ -224,10 +250,29 @@ export async function publishResource( context, descriptor, config, - associationType + associationType, + allowedDescriptors ); } + if (descriptor.type === ResourceType.ApiTag) { + const eligibility = await evaluateResourceEligibility( + store, + descriptor, + config + ); + if (!eligibility.eligible) { + logger.warn( + `Skipping ApiTag association "${descriptor.nameParts.join('/')}": ${eligibility.reason}` + ); + 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}`. @@ -360,21 +405,17 @@ 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)) { + const eligibility = await evaluateResourceEligibility( + store, + descriptor, + config, + json + ); + if (!eligibility.eligible) { + logger.warn( + `Skipping subscription "${subscriptionName}": ${eligibility.reason}` + ); return { descriptor, status: 'skipped', @@ -382,7 +423,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. @@ -395,6 +441,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. @@ -441,24 +500,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) { @@ -475,6 +517,7 @@ export async function publishResource( : descriptor; // PUT to APIM + attemptedPut = true; await client.putResource(context, deployedDescriptor, json); return { @@ -486,7 +529,7 @@ export async function publishResource( return { descriptor, status: 'failed', - action: 'noop', + action: attemptedPut ? 'put' : 'noop', error: error instanceof Error ? error : new Error(String(error)), }; } @@ -501,40 +544,47 @@ 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], - }; - // Apply env-mapping to affix both the parent and child name segments - const assocDescriptor = config.envMapping - ? mapDescriptor(rawAssocDescriptor, config.envMapping) - : rawAssocDescriptor; + const relatedResults: ResourcePublishResult[] = []; + for (const plan of plans) { + if (!plan.eligible) { + logger.warn( + `Skipping ${descriptor.type} association "${plan.descriptor.nameParts.join('/')}": ${plan.reason}` + ); + relatedResults.push({ + descriptor: plan.descriptor, + status: 'skipped', + action: 'noop', + }); + continue; + } 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)) { + relatedResults.push({ + descriptor: plan.descriptor, + status: 'success', + action: 'put', + }); continue; } // The referenced API/group is absent on the target (filtered out or @@ -542,19 +592,34 @@ async function publishAssociation( // aborting the whole association, so other present entries still link. if (isAssociationReferenceNotFoundError(error)) { logger.warn( - `Skipping ${associationType} association '${entry.name}' on ` + + `Skipping ${associationType} association '${getNamePart(plan.descriptor.nameParts, 1)}' on ` + `'${getNamePart(descriptor.nameParts, 0)}': referenced resource not found on target` ); + relatedResults.push({ + descriptor: plan.descriptor, + status: 'skipped', + action: 'noop', + }); continue; } - throw error; + relatedResults.push({ + descriptor: plan.descriptor, + status: 'failed', + action: 'put', + error: error instanceof Error ? error : new Error(String(error)), + }); + break; } } + const failedResult = relatedResults.find((result) => result.status === 'failed'); return { descriptor, - status: 'success', - action: 'put', + status: failedResult ? 'failed' : 'success', + action: 'noop', + error: failedResult?.error, + relatedResults, + suppressPrimaryResult: true, }; } catch (error) { return { @@ -566,6 +631,283 @@ async function publishAssociation( } } +export async function planAssociationPublications( + store: IArtifactStore, + context: ApimServiceContext, + descriptor: ResourceDescriptor, + 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 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 }; +} + +export async function evaluateAssociationEligibility( + store: IArtifactStore, + target: ResourceDescriptor, + config: PublishConfig, + allowedDescriptors?: ResourceDescriptor[] +): Promise { + if (!config.filter) { + return { eligible: true }; + } + + const effectiveFilter = target.workspace + ? resolveWorkspaceFilter(target.workspace, config.filter) + : config.filter; + if (effectiveFilter && !shouldIncludeResource(target, effectiveFilter)) { + 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 }; + } + + if (hasExplicitTypeFilter(target.type, effectiveFilter)) { + 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 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 }; +} + /** * Publish wiki resource (ApiWiki, ProductWiki) */ @@ -576,6 +918,7 @@ async function publishWiki( descriptor: ResourceDescriptor, config: PublishConfig ): Promise { + let attemptedPut = false; try { const wikiContent = await store.readContent( config.sourceDir, @@ -606,6 +949,7 @@ async function publishWiki( const deployedDescriptor = config.envMapping ? mapDescriptor(descriptor, config.envMapping) : descriptor; + attemptedPut = true; await client.putResource(context, deployedDescriptor, payload); return { @@ -617,7 +961,7 @@ async function publishWiki( return { descriptor, status: 'failed', - action: 'noop', + action: attemptedPut ? 'put' : 'noop', error: error instanceof Error ? error : new Error(String(error)), }; } @@ -635,6 +979,7 @@ async function publishPolicy( descriptor: ResourceDescriptor, config: PublishConfig ): Promise { + let attemptedPut = false; try { const policyContent = await store.readContent( config.sourceDir, @@ -693,6 +1038,7 @@ async function publishPolicy( const deployedDescriptor = config.envMapping ? mapDescriptor(descriptor, config.envMapping) : descriptor; + attemptedPut = true; await client.putResource(context, deployedDescriptor, mergedPayload); return { @@ -704,7 +1050,7 @@ async function publishPolicy( return { descriptor, status: 'failed', - action: 'noop', + action: attemptedPut ? 'put' : 'noop', error: error instanceof Error ? error : new Error(String(error)), }; } @@ -727,6 +1073,7 @@ async function publishPolicy( function normalizeSubscriptionScope( json: Record, context: ApimServiceContext, + workspace?: string, envMapping?: EnvMapping ): Record { const props = json.properties as Record | undefined; @@ -739,10 +1086,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); @@ -754,18 +1125,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. * @@ -819,6 +1239,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. * @@ -932,6 +1445,7 @@ async function publishWorkspaceApiTagLink( descriptor: ResourceDescriptor, config: PublishConfig ): Promise { + let attemptedPut = false; try { const canonicalApiName = getNamePart(descriptor.nameParts, 0); const deployedApiName = config.envMapping @@ -941,12 +1455,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. @@ -964,7 +1485,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-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..1251e823 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,185 @@ 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); + 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 { + 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 221ef1e8..a91d3c14 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`); @@ -112,7 +125,8 @@ export async function extractWorkspaces( const wsResult = await extractWorkspace( client, store, context, wsName, outputDir, - resolveWorkspaceFilter(wsName, filter) + resolveWorkspaceFilter(wsName, filter), + includeTransitive ); wsResult.errorCount += workspaceContainerError; results.push(wsResult); @@ -127,7 +141,8 @@ async function extractWorkspace( context: ApimServiceContext, workspaceName: string, outputDir: string, - filter?: FilterConfig + filter?: FilterConfig, + includeTransitive: boolean = false ): Promise { logger.info(`Extracting workspace "${workspaceName}"...`); @@ -145,6 +160,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 { @@ -154,6 +173,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) { @@ -172,6 +200,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; @@ -192,6 +228,13 @@ async function extractWorkspace( 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++; errorCount += productResult.errorCount; } catch (error) { @@ -237,6 +280,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 }; @@ -258,52 +319,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..7af27632 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; @@ -126,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', () => { @@ -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..f70b8bed 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, @@ -304,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', () => { @@ -650,6 +672,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 e0cb8db7..761a3296 100644 --- a/tests/unit/services/api-publisher.test.ts +++ b/tests/unit/services/api-publisher.test.ts @@ -11,7 +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 { EnvMapping } from '../../../src/services/env-mapper.js'; +import { DEFAULT_APPLIES_TO, EnvMapping } from '../../../src/services/env-mapper.js'; // Mock resource-publisher so we can verify call sequence const mockPublishResource = vi.fn(); @@ -95,6 +95,381 @@ 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 = { + 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 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([]); + 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.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, + 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([]); @@ -559,6 +934,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 }, @@ -573,10 +960,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; @@ -588,6 +975,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 () => { @@ -672,6 +1127,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('fails the API publish when a revision publish fails (no silent success)', async () => { const client = createMockClient(); const store = createMockStore([ @@ -1301,13 +1795,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 () => { @@ -1331,13 +1825,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({ @@ -1459,6 +1962,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 = [ @@ -1657,11 +2305,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 9ceaad9a..b8e00d84 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(); @@ -240,6 +264,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') @@ -280,7 +305,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], ])); @@ -303,6 +328,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], @@ -323,30 +410,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 df1cda88..39250f7b 100644 --- a/tests/unit/services/env-mapper.test.ts +++ b/tests/unit/services/env-mapper.test.ts @@ -431,25 +431,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/extract-service.test.ts b/tests/unit/services/extract-service.test.ts index 53c23e84..090b8209 100644 --- a/tests/unit/services/extract-service.test.ts +++ b/tests/unit/services/extract-service.test.ts @@ -744,6 +744,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/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 a54ad722..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,157 @@ 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(); + 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(); @@ -255,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 @@ -268,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 @@ -278,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 () => { @@ -368,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 2b2ad606..8860c60a 100644 --- a/tests/unit/services/publish-service.test.ts +++ b/tests/unit/services/publish-service.test.ts @@ -80,7 +80,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([]); @@ -145,6 +145,605 @@ 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 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'] }, + { 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 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'] }; + 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 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'] }, + { 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'] }, @@ -393,6 +992,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'] }, @@ -481,7 +1110,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([]); @@ -498,8 +1127,11 @@ describe('publish-service', () => { service: testContext, sourceDir: '/source', dryRun: false, - deleteUnmatched: false, + deleteUnmatched: true, commitId: 'abc123', + overrides: { + environment: { namePrefix: 'dev-' }, + }, logLevel: LogLevel.INFO, }; @@ -509,13 +1141,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 @@ -580,7 +1295,7 @@ describe('publish-service', () => { service: testContext, sourceDir: '/source', dryRun: true, - deleteUnmatched: false, + deleteUnmatched: true, commitId: 'abc123', logLevel: LogLevel.INFO, }; @@ -606,7 +1321,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 = { @@ -614,12 +1329,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); }); @@ -951,8 +1673,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') { @@ -1133,6 +1854,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 fe76173b..c64597b8 100644 --- a/tests/unit/services/resource-publisher.test.ts +++ b/tests/unit/services/resource-publisher.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { publishResource, + resolveAssociationDeleteDescriptor, normalizeApiAuthenticationSettings, prefersLegacyAuthOverride, } from '../../../src/services/resource-publisher.js'; @@ -78,6 +79,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(); @@ -157,7 +280,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'); }); @@ -231,6 +354,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(); @@ -348,6 +534,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(); @@ -374,6 +602,79 @@ 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 preserve the managed GatewayApi parent under environment mapping', async () => { const client = createMockClient(); const store = createMockStore(); @@ -399,6 +700,52 @@ describe('resource-publisher', () => { ); }); + 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('skips a GatewayApi link when the referenced API is not on the target and keeps going', async () => { const client = createMockClient(); const store = createMockStore(); @@ -420,7 +767,6 @@ describe('resource-publisher', () => { } return {}; }); - const descriptor: ResourceDescriptor = { type: ResourceType.GatewayApi, nameParts: ['my-gateway'], @@ -729,6 +1075,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(); @@ -755,6 +1166,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 34f16248..8d05947e 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,134 @@ 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, + }); + }); + + 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', + }); + }); + }); }); }); diff --git a/tests/unit/services/workspace-extractor.test.ts b/tests/unit/services/workspace-extractor.test.ts index c3ecc01a..e24f1b1f 100644 --- a/tests/unit/services/workspace-extractor.test.ts +++ b/tests/unit/services/workspace-extractor.test.ts @@ -388,6 +388,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; @@ -421,6 +470,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', () => {