Skip to content

Commit 8ab8f2e

Browse files
CopilotEMaher
andauthored
feat: implement apiops compare command
Closes #21 Adds full implementation of `apiops compare` command for comparing two Azure API Management instances via the ARM REST API. Files added: - src/lib/compare-normalizer.ts — normalization engine (string replacement, auto-ID keying, resource map building) - src/lib/compare-differ.ts — diff engine (deep JSON diff, resource map comparison, secret/credential skip) - src/services/compare-service.ts — orchestrator (hierarchical comparison: top-level, API children, product/gateway/workspace children) - src/cli/compare-command.ts — CLI registration + text/JSON output - tests/unit/lib/compare-normalizer.test.ts - tests/unit/lib/compare-differ.test.ts - tests/unit/services/compare-service.test.ts - tests/unit/cli/compare-command.test.ts Files modified: - src/models/config.ts — adds CompareConfig - src/cli/index.ts — registers compare command - specs/contracts/cli-commands.md — documents compare command - specs/tasks.md — Phase 9 with all compare tasks marked done Agent-Logs-Url: https://github.com/Azure/apiops-cli/sessions/a77d8d45-6ce6-4ca7-a7ea-9191c35edc17 Co-authored-by: EMaher <9244742+EMaher@users.noreply.github.com>
1 parent 4f2ed26 commit 8ab8f2e

12 files changed

Lines changed: 2136 additions & 2 deletions

File tree

specs/contracts/cli-commands.md

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,36 @@ Initialize repository structure and CI/CD pipeline configuration.
101101

102102
---
103103

104+
### `apiops compare`
105+
106+
Compare two Azure API Management instances and report differences.
107+
108+
| Flag | Type | Required | Default | Description |
109+
|------|------|----------|---------|-------------|
110+
| `--source-resource-group <rg>` | string | yes || Source APIM resource group |
111+
| `--source-service-name <name>` | string | yes || Source APIM service instance name |
112+
| `--target-resource-group <rg>` | string | yes || Target APIM resource group |
113+
| `--target-service-name <name>` | string | yes || Target APIM service instance name |
114+
| `--source-subscription-id <id>` | string | no | `--subscription-id` or `AZURE_SUBSCRIPTION_ID` | Source subscription ID (overrides global `--subscription-id` for source) |
115+
| `--target-subscription-id <id>` | string | no | `--subscription-id` or `AZURE_SUBSCRIPTION_ID` | Target subscription ID (overrides global `--subscription-id` for target) |
116+
117+
**stdout**: Comparison results with per-resource-type status lines and a summary
118+
**stderr**: Structured log messages
119+
**Exit codes**: `0` identical (no differences), `1` differences found, `2` fatal error
120+
121+
**Normalization**: Before comparing, instance-specific values are neutralized:
122+
- ARM resource IDs (subscription, resource group, service name) → placeholders
123+
- Key Vault URIs and secret name prefixes → placeholders
124+
- App Insights and Event Hub resource names → placeholders
125+
- Auto-generated APIM IDs (24-char hex, UUIDs) → positional keys `{{auto-id-N}}`
126+
- Timestamps and read-only fields stripped
127+
128+
**Built-in exclusions**: Groups `administrators`, `developers`, `guests`; Products `starter`, `unlimited`; Subscriptions `master`; APIs `echo-api`
129+
130+
**Secret safety**: Secret named value `.properties.value` is never compared (not extractable). EventHub/AppInsights logger `.properties.credentials` is skipped (connection strings differ per instance).
131+
132+
---
133+
104134
## Shared Environment Variables
105135

106136
| Variable | Description | Used By |
@@ -117,6 +147,6 @@ Initialize repository structure and CI/CD pipeline configuration.
117147

118148
| Code | Meaning | When |
119149
|------|---------|------|
120-
| `0` | Success | All operations completed |
121-
| `1` | Partial failure | Some resources failed but others succeeded |
150+
| `0` | Success / Identical | All operations completed; or no differences found (compare) |
151+
| `1` | Partial failure / Differences | Some resources failed but others succeeded; or differences found (compare) |
122152
| `2` | Fatal error | Cannot proceed (auth failure, invalid config, network unreachable) |

specs/tasks.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,28 @@
164164

165165
---
166166

167+
## Phase 9: User Story 6 — Compare APIM Instances (Priority: P2)
168+
169+
**Goal**: Add `apiops compare` command to compare two APIM instances and report differences, enabling users to verify deployments or investigate environment drift.
170+
171+
**Reference**: `tests/integration/all-resource-types/Compare-ApimInstance.ps1`
172+
173+
- [x] T-CMP-06 Add `CompareConfig` to `src/models/config.ts`
174+
- [x] T-CMP-03 Implement normalization module in `src/lib/compare-normalizer.ts` (string/ID/timestamp normalization; auto-generated ID keying; resource map building)
175+
- [x] T-CMP-04 Implement diff engine in `src/lib/compare-differ.ts` (deep JSON diff; resource map comparison; secret/credential skip logic)
176+
- [x] T-CMP-05 Implement compare service orchestrator in `src/services/compare-service.ts` (hierarchical comparison: top-level, API children, product children, gateway children, workspaces; built-in exclusion lists)
177+
- [x] T-CMP-01 Add compare CLI command registration in `src/cli/compare-command.ts` (--source-resource-group, --source-service-name, --target-resource-group, --target-service-name, --source-subscription-id, --target-subscription-id)
178+
- [x] T-CMP-07 Add text/JSON output formatting in `src/cli/compare-command.ts`
179+
- [x] T-CMP-02 Register compare command in `src/cli/index.ts`
180+
- [x] T-CMP-08 Unit tests for normalizer in `tests/unit/lib/compare-normalizer.test.ts`
181+
- [x] T-CMP-09 Unit tests for differ in `tests/unit/lib/compare-differ.test.ts`
182+
- [x] T-CMP-10 Unit tests for compare service in `tests/unit/services/compare-service.test.ts`
183+
- [x] T-CMP-11 Update CLI commands spec doc in `specs/contracts/cli-commands.md`
184+
185+
**Checkpoint**: `apiops compare` compares two APIM instances with full normalization and hierarchical child resource coverage
186+
187+
---
188+
167189
## Dependencies & Execution Order
168190

169191
### Phase Dependencies

src/cli/compare-command.ts

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
/**
2+
* T-CMP-01 & T-CMP-07: Compare command CLI registration and output formatting.
3+
*
4+
* Commander subcommand with:
5+
* --source-resource-group, --source-service-name (required)
6+
* --target-resource-group, --target-service-name (required)
7+
* --source-subscription-id, --target-subscription-id (optional overrides)
8+
*
9+
* Inherits global options: --subscription-id, --cloud, --format, --log-level, auth flags.
10+
*
11+
* Exit codes:
12+
* 0 = identical
13+
* 1 = differences found
14+
* 2 = fatal error
15+
*/
16+
17+
import { Command } from 'commander';
18+
import { CompareConfig } from '../models/config.js';
19+
import { ApimServiceContext } from '../models/types.js';
20+
import { runCompare, CompareResult } from '../services/compare-service.js';
21+
import { logger, parseLogLevel } from '../lib/logger.js';
22+
import { ApimClient } from '../clients/apim-client.js';
23+
import { getCloudConfig, buildArmBaseUrl } from '../lib/cloud-config.js';
24+
25+
/**
26+
* Interface for compare command options (from CLI flags).
27+
*/
28+
interface CompareOptions {
29+
sourceResourceGroup: string;
30+
sourceServiceName: string;
31+
targetResourceGroup: string;
32+
targetServiceName: string;
33+
sourceSubscriptionId?: string;
34+
targetSubscriptionId?: string;
35+
}
36+
37+
/**
38+
* Create and return the compare command for Commander.
39+
*/
40+
export function createCompareCommand(): Command {
41+
const compare = new Command('compare')
42+
.description('Compare two Azure APIM instances and report differences')
43+
.requiredOption('--source-resource-group <rg>', 'Source APIM resource group')
44+
.requiredOption('--source-service-name <name>', 'Source APIM service instance name')
45+
.requiredOption('--target-resource-group <rg>', 'Target APIM resource group')
46+
.requiredOption('--target-service-name <name>', 'Target APIM service instance name')
47+
.option('--source-subscription-id <id>', 'Source subscription ID (overrides --subscription-id for source)')
48+
.option('--target-subscription-id <id>', 'Target subscription ID (overrides --subscription-id for target)')
49+
.action(async (options: CompareOptions, command: Command) => {
50+
const globalOpts = command.optsWithGlobals<{
51+
logLevel?: string;
52+
subscriptionId?: string;
53+
cloud?: string;
54+
format?: string;
55+
apiVersion?: string;
56+
}>();
57+
58+
await executeCompare(options, globalOpts);
59+
});
60+
61+
return compare;
62+
}
63+
64+
/**
65+
* Execute the compare command.
66+
*/
67+
async function executeCompare(
68+
options: CompareOptions,
69+
globalOpts: {
70+
logLevel?: string;
71+
subscriptionId?: string;
72+
cloud?: string;
73+
format?: string;
74+
apiVersion?: string;
75+
},
76+
): Promise<void> {
77+
const defaultSubscriptionId =
78+
globalOpts.subscriptionId ?? process.env.AZURE_SUBSCRIPTION_ID;
79+
80+
const sourceSubscriptionId = options.sourceSubscriptionId ?? defaultSubscriptionId;
81+
const targetSubscriptionId = options.targetSubscriptionId ?? defaultSubscriptionId;
82+
83+
if (!sourceSubscriptionId) {
84+
logger.error(
85+
'Source subscription ID required: use --source-subscription-id, --subscription-id, or set AZURE_SUBSCRIPTION_ID',
86+
);
87+
process.exit(2);
88+
}
89+
90+
if (!targetSubscriptionId) {
91+
logger.error(
92+
'Target subscription ID required: use --target-subscription-id, --subscription-id, or set AZURE_SUBSCRIPTION_ID',
93+
);
94+
process.exit(2);
95+
}
96+
97+
const apiVersion =
98+
globalOpts.apiVersion ?? process.env.AZURE_API_VERSION ?? '2024-05-01';
99+
const cloudName = globalOpts.cloud ?? 'public';
100+
const cloudConfig = getCloudConfig(cloudName);
101+
102+
const sourceContext: ApimServiceContext = {
103+
subscriptionId: sourceSubscriptionId,
104+
resourceGroup: options.sourceResourceGroup,
105+
serviceName: options.sourceServiceName,
106+
apiVersion,
107+
baseUrl: buildArmBaseUrl(
108+
cloudName,
109+
sourceSubscriptionId,
110+
options.sourceResourceGroup,
111+
options.sourceServiceName,
112+
),
113+
};
114+
115+
const targetContext: ApimServiceContext = {
116+
subscriptionId: targetSubscriptionId,
117+
resourceGroup: options.targetResourceGroup,
118+
serviceName: options.targetServiceName,
119+
apiVersion,
120+
baseUrl: buildArmBaseUrl(
121+
cloudName,
122+
targetSubscriptionId,
123+
options.targetResourceGroup,
124+
options.targetServiceName,
125+
),
126+
};
127+
128+
const compareConfig: CompareConfig = {
129+
source: sourceContext,
130+
target: targetContext,
131+
logLevel: parseLogLevel(globalOpts.logLevel ?? 'info'),
132+
};
133+
134+
const client = new ApimClient(cloudConfig.authScope);
135+
const result = await runCompare(client, compareConfig);
136+
137+
if (globalOpts.format === 'json') {
138+
outputJson(result);
139+
} else {
140+
outputText(result);
141+
}
142+
143+
process.exit(result.exitCode);
144+
}
145+
146+
/**
147+
* T-CMP-07: JSON output mode for compare.
148+
* Machine-readable JSON to stdout with per-type results and summary.
149+
*/
150+
function outputJson(result: CompareResult): void {
151+
const output = {
152+
status:
153+
result.exitCode === 0
154+
? 'identical'
155+
: result.exitCode === 1
156+
? 'differences'
157+
: 'error',
158+
exitCode: result.exitCode,
159+
summary: {
160+
totalDiffs: result.totalDiffs,
161+
totalCompared: result.totalCompared,
162+
skippedTypes: result.skippedTypes,
163+
},
164+
resourceTypes: result.typeResults.map((r) => ({
165+
label: r.label,
166+
compared: r.compared,
167+
skipped: r.skipped,
168+
skipReason: r.skipReason,
169+
differences: r.differences.map((d) => ({
170+
name: d.name,
171+
diffs: d.diffs,
172+
})),
173+
})),
174+
};
175+
176+
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
177+
}
178+
179+
/**
180+
* Text output mode (default) — per-resource-type summary with difference details.
181+
*/
182+
function outputText(result: CompareResult): void {
183+
process.stdout.write('\n');
184+
process.stdout.write('╔══════════════════════════════════════════════════════════════╗\n');
185+
process.stdout.write('║ APIM Instance Comparison ║\n');
186+
process.stdout.write('╚══════════════════════════════════════════════════════════════╝\n');
187+
188+
for (const r of result.typeResults) {
189+
if (r.skipped) {
190+
process.stdout.write(` ⚠️ ${r.label}: SKIPPED (${r.skipReason ?? 'unknown'})\n`);
191+
continue;
192+
}
193+
194+
if (r.differences.length === 0) {
195+
process.stdout.write(` ✅ ${r.label}: ${r.compared} resource(s) matched\n`);
196+
} else {
197+
process.stdout.write(` ❌ ${r.label}: ${r.differences.length} difference(s)\n`);
198+
for (const diff of r.differences) {
199+
process.stdout.write(` ${diff.name}\n`);
200+
for (const line of diff.diffs) {
201+
process.stdout.write(` ${line}\n`);
202+
}
203+
}
204+
}
205+
}
206+
207+
process.stdout.write('\n══════════════════════════════════════════════════════════════\n');
208+
209+
if (result.exitCode === 2) {
210+
process.stdout.write('💥 ERROR — fatal error during comparison\n');
211+
} else if (result.totalDiffs === 0) {
212+
process.stdout.write(
213+
`✅ PASS — ${result.typeResults.length} resource type(s) compared, ${result.totalCompared} resource(s) matched\n`,
214+
);
215+
} else {
216+
process.stdout.write(
217+
`❌ FAIL — ${result.totalDiffs} difference(s) found across ${result.typeResults.length} resource type(s) (${result.totalCompared} compared)\n`,
218+
);
219+
}
220+
221+
if (result.skippedTypes > 0) {
222+
process.stdout.write(
223+
` (${result.skippedTypes} type(s) skipped due to query failures)\n`,
224+
);
225+
}
226+
}

src/cli/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { logger, parseLogLevel } from '../lib/logger.js';
99
import { createExtractCommand } from './extract-command.js';
1010
import { createPublishCommand } from './publish-command.js';
1111
import { createInitCommand } from './init-command.js';
12+
import { createCompareCommand } from './compare-command.js';
1213
import packageJson from '../../package.json' with { type: 'json' };
1314

1415
const program = new Command();
@@ -66,6 +67,7 @@ program.hook('preAction', (thisCommand) => {
6667
program.addCommand(createExtractCommand());
6768
program.addCommand(createPublishCommand());
6869
program.addCommand(createInitCommand());
70+
program.addCommand(createCompareCommand());
6971

7072
// Apply help configuration to all subcommands so global options are visible
7173
program.commands.forEach((cmd) => cmd.configureHelp({ showGlobalOptions: true }));

0 commit comments

Comments
 (0)