Skip to content

Commit 525913a

Browse files
CopilotEMaher
andcommitted
fixes for roundtrip tests in Premium SKU
Agent-Logs-Url: https://github.com/Azure/apiops-cli/sessions/08964b6c-4c8a-4dda-bcb9-17322e0e16bb Co-authored-by: EMaher <9244742+EMaher@users.noreply.github.com>
1 parent 593c15a commit 525913a

33 files changed

Lines changed: 1656 additions & 1659 deletions

.github/workflows/integration-test.yml

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,12 @@ on:
4040
description: 'Azure region'
4141
required: false
4242
type: string
43-
default: 'eastus2'
43+
default: 'centralus'
44+
log_level:
45+
description: 'PowerShell log level (Info, Verbose, Debug)'
46+
required: false
47+
type: string
48+
default: Verbose
4449
skip_teardown:
4550
description: 'Skip teardown (for debugging)'
4651
required: false
@@ -58,7 +63,12 @@ on:
5863
description: 'Azure region'
5964
required: false
6065
type: string
61-
default: 'eastus2'
66+
default: 'centralus'
67+
log_level:
68+
description: 'PowerShell log level (Info, Verbose, Debug)'
69+
required: false
70+
type: string
71+
default: Verbose
6272
skip_teardown:
6373
description: 'Skip teardown (for debugging)'
6474
required: false
@@ -114,11 +124,18 @@ jobs:
114124
shell: pwsh
115125
run: |
116126
$skipTeardown = '${{ inputs.skip_teardown }}' -eq 'true'
127+
$logLevel = '${{ inputs.log_level }}'
128+
if ([string]::IsNullOrWhiteSpace($logLevel)) { $logLevel = 'Verbose' }
129+
if ($logLevel -notin @('Info', 'Verbose', 'Debug')) {
130+
throw "Invalid log_level '$logLevel'. Allowed values: Info, Verbose, Debug."
131+
}
132+
117133
$params = @{
118134
SourceResourceGroup = '${{ env.SOURCE_RG }}'
119135
TargetResourceGroup = '${{ env.TARGET_RG }}'
120136
SkuName = '${{ inputs.sku }}'
121137
Location = '${{ inputs.location }}'
138+
LogLevel = $logLevel
122139
PublisherEmail = '${{ secrets.APIM_PUBLISHER_EMAIL }}'
123140
ExtractOutputDir = './extracted-artifacts'
124141
HardDelete = $true

.gitignore

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,12 @@ Desktop.ini
3838
# Local testing output (use --output .local-extract for local runs)
3939
.local-extract*/
4040

41-
# Log files for integration tests
41+
# Files for integration tests
4242
tests/integration/all-resource-types/logs/**
43+
tests/integration/all-resource-types/extracted-artifacts*/**
44+
tests/integration/all-resource-types/target-apim.json
45+
tests/integration/all-resource-types/source-apim.json
46+
tests/integration/all-resource-types/source-apim-post-activation.bicep
4347

4448
# Environment variables
4549
.env

.squad/agents/apimexpert/history.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,5 +98,11 @@ the SDK surface, reference docs, or ad-hoc observation.
9898

9999
**Research output:** `.squad/decisions.md` entry (merged from inbox), full analysis in `specs/sku-upgrade.md`
100100

101-
### 2026-05-13: APIM v1 → v2 SKU Migration Research
101+
### 2026-05-19: `policyRestrictions.scope` grammar
102+
103+
`Microsoft.ApiManagement/service/policyRestrictions@2025-09-01-preview`. Schema says `"Path to the policy document."` but the API validates `scope` as a **relative ARM path to an existing API, operation, or product**.
104+
105+
- Accepted: `/apis/{apiId}`, `/apis/{apiId}/operations/{opId}`, `/products/{productId}`, `""`.
106+
- Classic Developer/Premium SKU only.
107+
- Docs: <https://learn.microsoft.com/rest/api/apimanagement/policy-restriction> · <https://learn.microsoft.com/azure/templates/microsoft.apimanagement/service/policyrestrictions>
102108

.squad/agents/securityexpert/history.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,9 @@
2222
Performed a thorough read-only sensitivity audit across all tracked files in preparation for open-source publication. Scanned for secrets/credentials, internal Microsoft URLs, PII, internal comments, internal tool configs, sensitive paths, draft docs, hardcoded Azure resource IDs, and internal dependency references. Findings delivered for compliance sign-off. No live credentials, certificates, or storage keys were found. All Azure GUIDs encountered were either zero-padded placeholders or public Azure built-in role definition IDs. Primary cleanup items: a developer machine path and alias references in `.squad/` history/decisions; one real-looking storage account name in a test fixture.
2323

2424
**Findings Summary:** 2 MEDIUM items, 3 LOW items. Orchestration log: `.squad/orchestration-log/2026-05-19T22-01-securityexpert.md`
25+
- When using PowerShell transcript/trace logging, always pass `-UseMinimalHeader` to `Start-Transcript` to prevent machine/host environment details from being written to logs.
26+
- `Start-Transcript -UseMinimalHeader` keeps machine/host details out of logs.
27+
- **ARM async-operation URLs** (`Azure-AsyncOperation` / `Location`) include `t/c/s/h` query params that act as short-lived bearer credentials. Regex-mask them. <https://learn.microsoft.com/azure/azure-resource-manager/management/async-operations>
28+
- **`x-ms-routing-request-id`** carries `REGION:UTC:GUID` — mask the whole value, not just the GUID. <https://learn.microsoft.com/azure/azure-resource-manager/management/request-limits-and-throttling>
29+
- **PowerShell `Start-Transcript` double-emits native stderr** when paired with `2>&1 | Write-Host`. Either regex-mask in `Protect-LogLine` (so both copies get masked) or redirect the child's stderr to a pipe via `System.Diagnostics.Process` so the transcript never sees the raw line. Both layers together = defense in depth.
30+
- **Do not mask all GUIDs.** Azure built-in role-definition IDs and ARM template hashes are public constants useful for debugging. Anchor secret regex to the path segment, header name, or query-parameter context that makes the value sensitive.

.squad/agents/testengineer/history.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,3 +159,16 @@
159159
- History updated with dual-mode package consumption patterns
160160

161161
<!-- Append new learnings here after each session -->
162+
163+
### 2026-05-19: MaskingHelpers — capture child stderr directly
164+
165+
Rewrote [tests/integration/all-resource-types/MaskingHelpers.psm1](tests/integration/all-resource-types/MaskingHelpers.psm1) around `System.Diagnostics.Process` with `RedirectStandardOutput/Error = $true` so the child's raw bytes bypass PowerShell's ErrorRecord promotion and never reach the parent transcript. Per-stream `Start-ThreadJob` readers drain into `ConcurrentQueue[string]`s; main runspace polls every 100 ms and emits through `Protect-LogLine`.
166+
167+
Breaking signature: helpers now take `-Arguments [string[]]` instead of `-Command [scriptblock]`. All four call sites updated.
168+
169+
Gotchas for future PowerShell work:
170+
171+
- `Register-ObjectEvent` Action handlers do not drain reliably while the main runspace is in `Start-Sleep`. Use `Start-ThreadJob` (PS 7+) to bypass the engine event pump.
172+
- `$x = if ($cond) { [List[T]]::new() }` assigns `$null` — PowerShell enumerates the empty list. Use `$x = $null; if ($cond) { $x = ... }`.
173+
- `ProcessStartInfo.StandardOutputEncoding/StandardErrorEncoding` default to OEM on Windows; force UTF-8 or `az --debug` output mangles.
174+

src/lib/dependency-graph.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,11 @@ import { DependencyEdge } from '../models/types.js';
2020
export const DEPENDENCY_EDGES: DependencyEdge[] = [
2121
// Tier 1 -> Tier 2 dependencies
2222
{ from: ResourceType.Diagnostic, to: ResourceType.Logger, required: false },
23+
{ from: ResourceType.Diagnostic, to: ResourceType.Workspace, required: false },
2324
{ from: ResourceType.ServicePolicy, to: ResourceType.NamedValue, required: false },
2425
{ from: ResourceType.ServicePolicy, to: ResourceType.PolicyFragment, required: false },
2526
{ from: ResourceType.Api, to: ResourceType.VersionSet, required: false },
27+
{ from: ResourceType.PolicyRestriction, to: ResourceType.Product, required: false },
2628

2729
// Tier 2 -> Tier 3 dependencies
2830
{ from: ResourceType.ProductPolicy, to: ResourceType.Product, required: true },
@@ -61,6 +63,7 @@ export const DEPENDENCY_EDGES: DependencyEdge[] = [
6163
];
6264

6365
export const TIER_1_RESOURCES: ResourceType[] = [
66+
ResourceType.Workspace,
6467
ResourceType.NamedValue,
6568
ResourceType.Tag,
6669
ResourceType.Gateway,
@@ -70,7 +73,6 @@ export const TIER_1_RESOURCES: ResourceType[] = [
7073
ResourceType.Group,
7174
ResourceType.PolicyFragment,
7275
ResourceType.GlobalSchema,
73-
ResourceType.PolicyRestriction,
7476
ResourceType.Documentation,
7577
];
7678

@@ -82,6 +84,7 @@ export const TIER_2_RESOURCES: ResourceType[] = [
8284
];
8385

8486
export const TIER_3_RESOURCES: ResourceType[] = [
87+
ResourceType.PolicyRestriction,
8588
ResourceType.ProductPolicy,
8689
ResourceType.ProductGroup,
8790
ResourceType.ProductTag,

src/lib/resource-path.ts

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,20 @@ import { ResourceDescriptor } from '../models/types.js';
1010
import { RESOURCE_TYPE_METADATA, ResourceType } from '../models/resource-types.js';
1111

1212
/**
13-
* Association resource types that represent parent-child relationships
14-
* (not independently publishable resources).
15-
* These are handled specially during publishing via association files
16-
* (apis.json, groups.json) and should not be discovered as individual resources.
13+
* Association resource types that are published by specialized parent publishers.
14+
*
15+
* Product associations are handled by product-publisher using the parent
16+
* product descriptor, so their association files should not be discovered as
17+
* independent resources.
18+
*
19+
* Gateway associations are intentionally excluded from this set because they
20+
* are published via the generic association path in resource-publisher and
21+
* must therefore be discovered from gateways/{gateway}/apis.json.
1722
*/
18-
const ASSOCIATION_TYPES = new Set<ResourceType>([
23+
const PARENT_PUBLISHED_ASSOCIATION_TYPES = new Set<ResourceType>([
1924
ResourceType.ProductApi,
2025
ResourceType.ProductGroup,
2126
ResourceType.ProductTag,
22-
ResourceType.GatewayApi,
2327
]);
2428

2529
const SUPPORTED_SPECIFICATION_EXTENSIONS = new Set([
@@ -369,6 +373,11 @@ export function parseArtifactPath(
369373
return undefined;
370374
}
371375

376+
const workspaceContainer = parseWorkspaceContainerDescriptor(fileName, workspace);
377+
if (workspaceContainer) {
378+
return workspaceContainer;
379+
}
380+
372381
// Try to match against each resource type's pattern
373382
for (const [typeKey, metadata] of Object.entries(RESOURCE_TYPE_METADATA)) {
374383
const type = typeKey as ResourceType;
@@ -379,8 +388,8 @@ export function parseArtifactPath(
379388

380389
// Skip association resource types — these are handled specially during publishing
381390
// via their parent's association files (apis.json, groups.json)
382-
if (ASSOCIATION_TYPES.has(type)) {
383-
return undefined;
391+
if (PARENT_PUBLISHED_ASSOCIATION_TYPES.has(type)) {
392+
continue;
384393
}
385394

386395
const nameParts = parseTemplatePath(
@@ -396,6 +405,24 @@ export function parseArtifactPath(
396405
return undefined;
397406
}
398407

408+
function parseWorkspaceContainerDescriptor(
409+
fileName: string,
410+
workspace?: string
411+
): ResourceDescriptor | undefined {
412+
// Workspace container descriptors are stored at:
413+
// workspaces/{workspace}/workspaceInformation.json
414+
// and are not workspace-scoped children. Return a top-level Workspace
415+
// descriptor so publish can create the container before workspace children.
416+
if (fileName === 'workspaceInformation.json' && workspace) {
417+
return {
418+
type: ResourceType.Workspace,
419+
nameParts: [workspace],
420+
};
421+
}
422+
423+
return undefined;
424+
}
425+
399426
/**
400427
* Parse a changed artifact file path into a ResourceDescriptor.
401428
*

src/models/resource-types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ export enum ResourceType {
4141
GraphQLResolverPolicy = 'GraphQLResolverPolicy',
4242
/** MCP (Model Context Protocol) server configuration per API. Singleton per API. */
4343
McpServer = 'McpServer',
44+
/** Premium/PremiumV2 workspace container. */
45+
Workspace = 'Workspace',
4446
}
4547

4648
/**
@@ -278,4 +280,10 @@ export const RESOURCE_TYPE_METADATA: Record<ResourceType, ResourceTypeMetadata>
278280
infoFile: 'mcpServerInformation.json',
279281
supportsGet: true,
280282
},
283+
[ResourceType.Workspace]: {
284+
armPathSuffix: 'workspaces/{0}',
285+
artifactDirectory: 'workspaces/{0}',
286+
infoFile: 'workspaceInformation.json',
287+
supportsGet: true,
288+
},
281289
};

src/services/api-publisher.ts

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,22 @@ export async function publishApi(
5757
}
5858

5959
// Step 2: Find and publish revisions in numeric order
60-
await publishApiRevisions(client, store, context, descriptor, config);
60+
const publishedRevisionCount = await publishApiRevisions(client, store, context, descriptor, config);
61+
62+
// Step 2b: Align root API only when source marks it as current.
63+
// Source of truth is properties.isCurrent in root apiInformation.json.
64+
if (publishedRevisionCount > 0 && rootResult.isCurrent === true) {
65+
const alignResult = await alignActiveRevisionWithSource(
66+
client,
67+
store,
68+
context,
69+
descriptor,
70+
config
71+
);
72+
if (alignResult.status !== 'success') {
73+
return alignResult;
74+
}
75+
}
6176

6277
// Step 3: Publish child resources in parallel
6378
// When a spec was imported, operations and schemas are auto-created by APIM
@@ -105,6 +120,11 @@ function getImportFormat(specFormat: string, _apiType?: string): string | undefi
105120
interface RootApiResult {
106121
status: 'success' | 'skipped';
107122
specImported: boolean;
123+
isCurrent?: boolean;
124+
}
125+
126+
interface PublishRootApiOptions {
127+
includeSpecification?: boolean;
108128
}
109129

110130
/**
@@ -118,7 +138,8 @@ async function publishRootApi(
118138
store: IArtifactStore,
119139
context: ApimServiceContext,
120140
descriptor: ResourceDescriptor,
121-
config: PublishConfig
141+
config: PublishConfig,
142+
options?: PublishRootApiOptions
122143
): Promise<RootApiResult & ResourcePublishResult> {
123144
let json = await store.readResource(config.sourceDir, descriptor);
124145
if (!json) {
@@ -132,10 +153,14 @@ async function publishRootApi(
132153

133154
// Apply overrides
134155
json = applyOverrides(descriptor, json, config.overrides);
156+
const isCurrent = getApiIsCurrent(json);
135157

136158
// Try to read the specification file for this API
137159
let specImported = false;
138-
const specResult = await store.readContent(config.sourceDir, descriptor, 'specification');
160+
const includeSpecification = options?.includeSpecification ?? true;
161+
const specResult = includeSpecification
162+
? await store.readContent(config.sourceDir, descriptor, 'specification')
163+
: undefined;
139164
if (specResult) {
140165
const properties = json.properties as Record<string, unknown> | undefined;
141166
const apiType = properties?.type as string | undefined;
@@ -180,9 +205,26 @@ async function publishRootApi(
180205
status: 'success',
181206
action: 'put',
182207
specImported,
208+
isCurrent,
183209
};
184210
}
185211

212+
async function alignActiveRevisionWithSource(
213+
client: IApimClient,
214+
store: IArtifactStore,
215+
context: ApimServiceContext,
216+
descriptor: ResourceDescriptor,
217+
config: PublishConfig
218+
): Promise<RootApiResult & ResourcePublishResult> {
219+
logger.debug(
220+
`Source marks "${getNamePart(descriptor.nameParts, 0)}" as current; re-applying root metadata to align active revision`
221+
);
222+
223+
return publishRootApi(client, store, context, descriptor, config, {
224+
includeSpecification: false,
225+
});
226+
}
227+
186228
/**
187229
* Find and publish API revisions in numeric order
188230
*/
@@ -192,7 +234,7 @@ async function publishApiRevisions(
192234
context: ApimServiceContext,
193235
apiDescriptor: ResourceDescriptor,
194236
config: PublishConfig
195-
): Promise<void> {
237+
): Promise<number> {
196238
// List all resources from store
197239
const allDescriptors = await store.listResources(config.sourceDir);
198240

@@ -214,6 +256,8 @@ async function publishApiRevisions(
214256
for (const revDescriptor of sortedRevisions) {
215257
await publishResource(client, store, context, revDescriptor, config);
216258
}
259+
260+
return sortedRevisions.length;
217261
}
218262

219263
/**
@@ -398,3 +442,9 @@ function extractRevisionNumber(apiName: string): number {
398442
const match = /;rev=(\d+)/.exec(apiName);
399443
return match ? parseInt(match[1], 10) : 0;
400444
}
445+
446+
function getApiIsCurrent(json: Record<string, unknown>): boolean | undefined {
447+
const properties = json.properties as Record<string, unknown> | undefined;
448+
const isCurrent = properties?.isCurrent;
449+
return typeof isCurrent === 'boolean' ? isCurrent : undefined;
450+
}

src/services/extract-service.ts

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -553,18 +553,14 @@ async function extractWorkspaceResources(
553553
filter: FilterConfig | undefined,
554554
result: ExtractionResult
555555
): Promise<void> {
556-
try {
557-
const wsResults = await extractWorkspaces(
558-
client, store, context, outputDir, filter
559-
);
556+
const wsResults = await extractWorkspaces(
557+
client, store, context, outputDir, filter
558+
);
560559

561-
result.workspaceResults = wsResults;
560+
result.workspaceResults = wsResults;
562561

563-
for (const ws of wsResults) {
564-
result.totalExtracted += ws.resourceCount;
565-
result.totalErrors += ws.errorCount;
566-
}
567-
} catch (error) {
568-
logger.warn(`Workspace extraction failed: ${(error as Error).message}`);
562+
for (const ws of wsResults) {
563+
result.totalExtracted += ws.resourceCount;
564+
result.totalErrors += ws.errorCount;
569565
}
570566
}

0 commit comments

Comments
 (0)