swallowkit 1.1.2 → 2.0.0-alpha.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +61 -7
- package/README.md +61 -7
- package/dist/cli/commands/add-auth.d.ts.map +1 -1
- package/dist/cli/commands/add-auth.js +103 -27
- package/dist/cli/commands/add-auth.js.map +1 -1
- package/dist/cli/commands/configure-mcp.d.ts +12 -0
- package/dist/cli/commands/configure-mcp.d.ts.map +1 -0
- package/dist/cli/commands/configure-mcp.js +42 -0
- package/dist/cli/commands/configure-mcp.js.map +1 -0
- package/dist/cli/commands/dev.d.ts +5 -2
- package/dist/cli/commands/dev.d.ts.map +1 -1
- package/dist/cli/commands/dev.js +143 -67
- package/dist/cli/commands/dev.js.map +1 -1
- package/dist/cli/commands/init.d.ts +14 -7
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +149 -170
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/migrations.d.ts.map +1 -1
- package/dist/cli/commands/migrations.js +25 -0
- package/dist/cli/commands/migrations.js.map +1 -1
- package/dist/cli/commands/provision.d.ts +5 -0
- package/dist/cli/commands/provision.d.ts.map +1 -1
- package/dist/cli/commands/provision.js +278 -55
- package/dist/cli/commands/provision.js.map +1 -1
- package/dist/cli/commands/scaffold.d.ts +5 -1
- package/dist/cli/commands/scaffold.d.ts.map +1 -1
- package/dist/cli/commands/scaffold.js +123 -18
- package/dist/cli/commands/scaffold.js.map +1 -1
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +49 -8
- package/dist/cli/index.js.map +1 -1
- package/dist/core/config.d.ts +2 -1
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +19 -0
- package/dist/core/config.js.map +1 -1
- package/dist/core/operations/create-model.d.ts +2 -0
- package/dist/core/operations/create-model.d.ts.map +1 -1
- package/dist/core/operations/create-model.js +60 -8
- package/dist/core/operations/create-model.js.map +1 -1
- package/dist/core/operations/migration-generator.d.ts.map +1 -1
- package/dist/core/operations/migration-generator.js +12 -1
- package/dist/core/operations/migration-generator.js.map +1 -1
- package/dist/core/operations/model-migration.d.ts +22 -0
- package/dist/core/operations/model-migration.d.ts.map +1 -0
- package/dist/core/operations/model-migration.js +36 -0
- package/dist/core/operations/model-migration.js.map +1 -0
- package/dist/core/operations/provision-deployment.d.ts +52 -0
- package/dist/core/operations/provision-deployment.d.ts.map +1 -0
- package/dist/core/operations/provision-deployment.js +164 -0
- package/dist/core/operations/provision-deployment.js.map +1 -0
- package/dist/core/operations/provision-operations.d.ts +62 -6
- package/dist/core/operations/provision-operations.d.ts.map +1 -1
- package/dist/core/operations/provision-operations.js +653 -115
- package/dist/core/operations/provision-operations.js.map +1 -1
- package/dist/core/operations/scaffold-apply.d.ts +1 -0
- package/dist/core/operations/scaffold-apply.d.ts.map +1 -1
- package/dist/core/operations/scaffold-apply.js +1 -0
- package/dist/core/operations/scaffold-apply.js.map +1 -1
- package/dist/core/operations/scaffold-machine.d.ts +1 -0
- package/dist/core/operations/scaffold-machine.d.ts.map +1 -1
- package/dist/core/operations/scaffold-machine.js.map +1 -1
- package/dist/core/operations/scaffold-plan.d.ts +2 -0
- package/dist/core/operations/scaffold-plan.d.ts.map +1 -1
- package/dist/core/operations/scaffold-plan.js +3 -1
- package/dist/core/operations/scaffold-plan.js.map +1 -1
- package/dist/core/project/agent-instructions.d.ts +2 -1
- package/dist/core/project/agent-instructions.d.ts.map +1 -1
- package/dist/core/project/agent-instructions.js +15 -0
- package/dist/core/project/agent-instructions.js.map +1 -1
- package/dist/core/project/boundaries.d.ts +5 -2
- package/dist/core/project/boundaries.d.ts.map +1 -1
- package/dist/core/project/boundaries.js +51 -20
- package/dist/core/project/boundaries.js.map +1 -1
- package/dist/core/project/capabilities.d.ts +17 -0
- package/dist/core/project/capabilities.d.ts.map +1 -1
- package/dist/core/project/capabilities.js +45 -6
- package/dist/core/project/capabilities.js.map +1 -1
- package/dist/core/project/home-page.d.ts +4 -0
- package/dist/core/project/home-page.d.ts.map +1 -0
- package/dist/core/project/home-page.js +113 -0
- package/dist/core/project/home-page.js.map +1 -0
- package/dist/core/project/manifest.d.ts +3 -1
- package/dist/core/project/manifest.d.ts.map +1 -1
- package/dist/core/project/manifest.js +47 -25
- package/dist/core/project/manifest.js.map +1 -1
- package/dist/core/project/mcp-config.d.ts +26 -0
- package/dist/core/project/mcp-config.d.ts.map +1 -0
- package/dist/core/project/mcp-config.js +117 -0
- package/dist/core/project/mcp-config.js.map +1 -0
- package/dist/core/project/react-project.d.ts +26 -0
- package/dist/core/project/react-project.d.ts.map +1 -0
- package/dist/core/project/react-project.js +443 -0
- package/dist/core/project/react-project.js.map +1 -0
- package/dist/core/project/state.d.ts +7 -0
- package/dist/core/project/state.d.ts.map +1 -1
- package/dist/core/project/state.js +86 -0
- package/dist/core/project/state.js.map +1 -1
- package/dist/core/project/validation.d.ts.map +1 -1
- package/dist/core/project/validation.js +32 -10
- package/dist/core/project/validation.js.map +1 -1
- package/dist/core/project/workflows.d.ts.map +1 -1
- package/dist/core/project/workflows.js +53 -7
- package/dist/core/project/workflows.js.map +1 -1
- package/dist/core/scaffold/auth-generator.d.ts.map +1 -1
- package/dist/core/scaffold/auth-generator.js +26 -27
- package/dist/core/scaffold/auth-generator.js.map +1 -1
- package/dist/core/scaffold/model-parser.d.ts +23 -0
- package/dist/core/scaffold/model-parser.d.ts.map +1 -1
- package/dist/core/scaffold/model-parser.js +407 -137
- package/dist/core/scaffold/model-parser.js.map +1 -1
- package/dist/core/scaffold/native-schema-generator.d.ts +13 -0
- package/dist/core/scaffold/native-schema-generator.d.ts.map +1 -1
- package/dist/core/scaffold/native-schema-generator.js +294 -91
- package/dist/core/scaffold/native-schema-generator.js.map +1 -1
- package/dist/core/scaffold/openapi-generator.d.ts +5 -0
- package/dist/core/scaffold/openapi-generator.d.ts.map +1 -1
- package/dist/core/scaffold/openapi-generator.js +53 -3
- package/dist/core/scaffold/openapi-generator.js.map +1 -1
- package/dist/core/scaffold/react-bff-generator.d.ts +10 -0
- package/dist/core/scaffold/react-bff-generator.d.ts.map +1 -0
- package/dist/core/scaffold/react-bff-generator.js +252 -0
- package/dist/core/scaffold/react-bff-generator.js.map +1 -0
- package/dist/core/scaffold/react-ui-generator.d.ts +6 -0
- package/dist/core/scaffold/react-ui-generator.d.ts.map +1 -0
- package/dist/core/scaffold/react-ui-generator.js +41 -0
- package/dist/core/scaffold/react-ui-generator.js.map +1 -0
- package/dist/core/scaffold/ui-generator.d.ts +0 -3
- package/dist/core/scaffold/ui-generator.d.ts.map +1 -1
- package/dist/core/scaffold/ui-generator.js +68 -7
- package/dist/core/scaffold/ui-generator.js.map +1 -1
- package/dist/core/verify/index.d.ts +4 -3
- package/dist/core/verify/index.d.ts.map +1 -1
- package/dist/core/verify/index.js +122 -5
- package/dist/core/verify/index.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/machine/contracts.d.ts +1 -0
- package/dist/machine/contracts.d.ts.map +1 -1
- package/dist/machine/errors.d.ts +3 -2
- package/dist/machine/errors.d.ts.map +1 -1
- package/dist/machine/errors.js +2 -1
- package/dist/machine/errors.js.map +1 -1
- package/dist/machine/index.d.ts.map +1 -1
- package/dist/machine/index.js +61 -30
- package/dist/machine/index.js.map +1 -1
- package/dist/mcp/index.d.ts +6 -4
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +210 -25
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp/version-selection.d.ts +55 -0
- package/dist/mcp/version-selection.d.ts.map +1 -0
- package/dist/mcp/version-selection.js +152 -0
- package/dist/mcp/version-selection.js.map +1 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/azure-cli.d.ts +56 -0
- package/dist/utils/azure-cli.d.ts.map +1 -0
- package/dist/utils/azure-cli.js +326 -0
- package/dist/utils/azure-cli.js.map +1 -0
- package/dist/utils/azure-diagnostics.d.ts +16 -0
- package/dist/utils/azure-diagnostics.d.ts.map +1 -0
- package/dist/utils/azure-diagnostics.js +68 -0
- package/dist/utils/azure-diagnostics.js.map +1 -0
- package/dist/utils/package-manager.d.ts +2 -2
- package/dist/utils/package-manager.d.ts.map +1 -1
- package/dist/utils/package-manager.js +6 -3
- package/dist/utils/package-manager.js.map +1 -1
- package/dist/utils/python-uv.d.ts +5 -7
- package/dist/utils/python-uv.d.ts.map +1 -1
- package/dist/utils/python-uv.js +50 -24
- package/dist/utils/python-uv.js.map +1 -1
- package/dist/utils/react-dev.d.ts +4 -0
- package/dist/utils/react-dev.d.ts.map +1 -0
- package/dist/utils/react-dev.js +35 -0
- package/dist/utils/react-dev.js.map +1 -0
- package/dist/utils/semver.d.ts +14 -0
- package/dist/utils/semver.d.ts.map +1 -0
- package/dist/utils/semver.js +61 -0
- package/dist/utils/semver.js.map +1 -0
- package/package.json +7 -4
- package/src/__tests__/__snapshots__/boundaries.test.ts.snap +154 -0
- package/src/__tests__/agent-instructions.test.ts +6 -1
- package/src/__tests__/agent-loop-phase34.test.ts +143 -8
- package/src/__tests__/auth.test.ts +9 -0
- package/src/__tests__/azure-cli.test.ts +202 -0
- package/src/__tests__/boundaries.test.ts +42 -0
- package/src/__tests__/csharp-schema.test.ts +261 -0
- package/src/__tests__/dev.test.ts +13 -6
- package/src/__tests__/fixtures/az wrapper.cmd +2 -0
- package/src/__tests__/fixtures/azure-cli.cjs +153 -0
- package/src/__tests__/fixtures/provision-runner.cjs +28 -0
- package/src/__tests__/generated-project-matrix.test.ts +789 -0
- package/src/__tests__/home-page.test.ts +50 -0
- package/src/__tests__/init.test.ts +145 -5
- package/src/__tests__/mcp-version.test.ts +262 -0
- package/src/__tests__/mcp.test.ts +120 -3
- package/src/__tests__/multi-auth.test.ts +115 -4
- package/src/__tests__/package-manager.test.ts +17 -2
- package/src/__tests__/plan-apply.test.ts +179 -0
- package/src/__tests__/provision-runtime.test.ts +722 -0
- package/src/__tests__/python-uv.test.ts +50 -14
- package/src/__tests__/python-venv.test.ts +48 -0
- package/src/__tests__/react-dev.test.ts +72 -0
- package/src/__tests__/react.test.ts +838 -0
- package/src/__tests__/release-metadata.test.ts +121 -0
- package/src/__tests__/schema-analysis.test.ts +329 -0
- package/src/cli/commands/add-auth.ts +103 -29
- package/src/cli/commands/configure-mcp.ts +54 -0
- package/src/cli/commands/dev.ts +145 -73
- package/src/cli/commands/init.ts +147 -172
- package/src/cli/commands/migrations.ts +22 -0
- package/src/cli/commands/provision.ts +294 -56
- package/src/cli/commands/scaffold.ts +145 -22
- package/src/cli/index.ts +46 -10
- package/src/core/config.ts +21 -1
- package/src/core/operations/create-model.ts +51 -13
- package/src/core/operations/migration-generator.ts +14 -1
- package/src/core/operations/model-migration.ts +52 -0
- package/src/core/operations/provision-deployment.ts +183 -0
- package/src/core/operations/provision-operations.ts +662 -154
- package/src/core/operations/scaffold-apply.ts +2 -0
- package/src/core/operations/scaffold-machine.ts +1 -0
- package/src/core/operations/scaffold-plan.ts +5 -1
- package/src/core/project/agent-instructions.ts +17 -1
- package/src/core/project/agent-templates/AGENTS.md +113 -15
- package/src/core/project/agent-templates/CLAUDE.md +14 -1
- package/src/core/project/agent-templates/bff-routes.instructions.md +5 -3
- package/src/core/project/agent-templates/copilot-instructions.md +15 -1
- package/src/core/project/agent-templates/shared-models.instructions.md +11 -3
- package/src/core/project/boundaries.ts +62 -20
- package/src/core/project/capabilities.ts +63 -6
- package/src/core/project/home-page.ts +114 -0
- package/src/core/project/manifest.ts +52 -27
- package/src/core/project/mcp-config.ts +84 -0
- package/src/core/project/react-project.ts +529 -0
- package/src/core/project/state.ts +70 -0
- package/src/core/project/validation.ts +32 -10
- package/src/core/project/workflows.ts +53 -7
- package/src/core/scaffold/auth-generator.ts +26 -27
- package/src/core/scaffold/model-parser.ts +413 -129
- package/src/core/scaffold/native-schema-generator.ts +319 -103
- package/src/core/scaffold/openapi-generator.ts +63 -5
- package/src/core/scaffold/react-bff-generator.ts +292 -0
- package/src/core/scaffold/react-ui-generator.ts +51 -0
- package/src/core/scaffold/ui-generator.ts +70 -8
- package/src/core/verify/index.ts +118 -6
- package/src/index.ts +3 -1
- package/src/machine/contracts.ts +1 -0
- package/src/machine/errors.ts +4 -2
- package/src/machine/index.ts +67 -36
- package/src/mcp/index.ts +221 -31
- package/src/mcp/version-selection.ts +163 -0
- package/src/types/index.ts +4 -0
- package/src/utils/azure-cli.ts +315 -0
- package/src/utils/azure-diagnostics.ts +75 -0
- package/src/utils/package-manager.ts +7 -4
- package/src/utils/python-uv.ts +53 -33
- package/src/utils/react-dev.ts +31 -0
- package/src/utils/semver.ts +59 -0
|
@@ -1,21 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Plan / Apply Provision — Azure プロビジョニングの事前計画と承認付き実行。
|
|
3
3
|
*
|
|
4
|
-
* - plan provision:
|
|
5
|
-
* ネットワークアクセスや az login を必要とする操作は行わない。
|
|
6
|
-
* --what-if を明示した場合のみ az deployment group what-if を実行する。
|
|
4
|
+
* - plan provision: Bicep と Azure の現在の状態を確認する。リソースは変更しない。
|
|
7
5
|
* - apply provision: 常に承認(--approve)必須。承認なしでは requires-human で停止する。
|
|
6
|
+
* - 対話的な `swallowkit provision` は同じ計画をメモリ上で作成・表示し、確認後に
|
|
7
|
+
* executeProvisionPlan で同じ安全確認を経て実行する(計画ファイルは保存しない)。
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import * as crypto from "crypto";
|
|
11
11
|
import * as fs from "fs";
|
|
12
|
+
import { AzCliAvailability, AzCliResult, checkAzCliAvailability, runAzCli, runAzCliAsync } from "../../utils/azure-cli";
|
|
13
|
+
import { AzureCliDiagnostic, azureCliDiagnostic, redactAzureText } from "../../utils/azure-diagnostics";
|
|
12
14
|
import * as path from "path";
|
|
13
|
-
import { spawnSync } from "child_process";
|
|
14
15
|
import { MachineCommandError } from "../../machine/errors";
|
|
16
|
+
import type { MachineNextAction, MachineOperationStatus } from "../../machine/contracts";
|
|
15
17
|
import { getSwallowKitVersion } from "../../version";
|
|
16
18
|
import { inspectInfra, InfraInspection } from "../project/infra";
|
|
17
|
-
import { deletePlanState, loadPlanState, savePlanState } from "../project/state";
|
|
19
|
+
import { deletePlanState, listProvisionRuns, loadPlanState, loadProvisionRun, lockProvisionTarget, savePlanState, saveProvisionRun } from "../project/state";
|
|
18
20
|
import { hashFileIfExists } from "./file-session";
|
|
21
|
+
import {
|
|
22
|
+
DEFAULT_PROVISION_WAIT_SECONDS, DEPLOYMENT_QUERY, DeploymentObservation, DeploymentReference,
|
|
23
|
+
MAX_PROVISION_WAIT_SECONDS, queryAzureDeployment, queryDeploymentOperationErrors, UNBOUNDED_WAIT_TRANSIENT_RETRIES, waitForAzureDeployment,
|
|
24
|
+
} from "./provision-deployment";
|
|
19
25
|
import {
|
|
20
26
|
MIGRATION_MANIFEST_PATH,
|
|
21
27
|
MigrationManifest,
|
|
@@ -30,7 +36,6 @@ import {
|
|
|
30
36
|
getMigrationTagKey,
|
|
31
37
|
loadMigrationManifest,
|
|
32
38
|
replaceMigrationManifest,
|
|
33
|
-
resolveMigrations,
|
|
34
39
|
selectPendingMigrations,
|
|
35
40
|
} from "../project/migrations";
|
|
36
41
|
|
|
@@ -48,9 +53,11 @@ export interface PlanProvisionOptions {
|
|
|
48
53
|
adoptExisting?: boolean;
|
|
49
54
|
baseline?: number;
|
|
50
55
|
reconcile?: boolean;
|
|
56
|
+
/** Attach an existing, matching baseline deployment without submitting Bicep again. */
|
|
57
|
+
resumeDeployment?: string;
|
|
51
58
|
}
|
|
52
59
|
|
|
53
|
-
export type ProvisionMode = "offline" | "bootstrap" | "adoption-required" | "adopt" | "migrate" | "noop" | "reconcile";
|
|
60
|
+
export type ProvisionMode = "offline" | "bootstrap" | "adoption-required" | "adopt" | "migrate" | "noop" | "reconcile" | "recover";
|
|
54
61
|
|
|
55
62
|
interface ProvisionCommand {
|
|
56
63
|
file: "az";
|
|
@@ -84,6 +91,7 @@ export interface ProvisionPlanData {
|
|
|
84
91
|
requiresApproval: true;
|
|
85
92
|
fingerprints: Record<string, string | null>;
|
|
86
93
|
baselineSnapshotChecksum?: string;
|
|
94
|
+
recoveryDeployment?: DeploymentReference;
|
|
87
95
|
}
|
|
88
96
|
|
|
89
97
|
function validateProvisionInputs(options: PlanProvisionOptions): void {
|
|
@@ -111,34 +119,37 @@ function validateProvisionInputs(options: PlanProvisionOptions): void {
|
|
|
111
119
|
"failed"
|
|
112
120
|
);
|
|
113
121
|
}
|
|
122
|
+
if (options.resumeDeployment && (!/^[A-Za-z0-9._()-]{1,64}$/.test(options.resumeDeployment) || options.reconcile || options.adoptExisting)) {
|
|
123
|
+
throw new MachineCommandError("invalid-arguments", "--resume-deployment requires a valid deployment name and cannot be combined with adoption or reconcile.", undefined, "blocked");
|
|
124
|
+
}
|
|
114
125
|
}
|
|
115
126
|
|
|
116
|
-
function
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
} catch {
|
|
121
|
-
return false;
|
|
122
|
-
}
|
|
127
|
+
function azCliUnavailableWarning(availability: Exclude<AzCliAvailability, { available: true }>): string {
|
|
128
|
+
return availability.reason === "not-found"
|
|
129
|
+
? "az-cli-not-found: Azure CLI was not found on PATH. Install it (https://aka.ms/azure-cli) or add it to PATH, then try again."
|
|
130
|
+
: `az-cli-not-runnable: Azure CLI was found but could not be started. ${availability.detail}`;
|
|
123
131
|
}
|
|
124
132
|
|
|
125
133
|
function displayCommand(command: ProvisionCommand): string {
|
|
126
134
|
return [command.file, ...command.args.map((arg) => /\s/.test(arg) ? JSON.stringify(arg) : arg)].join(" ");
|
|
127
135
|
}
|
|
128
136
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
stdout: string;
|
|
132
|
-
stderr: string;
|
|
137
|
+
function runAz(args: string[], timeout = 60_000): AzCliResult {
|
|
138
|
+
return runAzCli(args, { timeout });
|
|
133
139
|
}
|
|
134
140
|
|
|
135
|
-
function
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
141
|
+
function azQueryError(code: string, message: string, result: AzCliResult, secretValues: readonly string[] = []): MachineCommandError {
|
|
142
|
+
const diagnostic = azureCliDiagnostic(result, "azure-state", secretValues);
|
|
143
|
+
const output = (diagnostic.stderr || diagnostic.stdout).trim().slice(0, 2_000);
|
|
144
|
+
if (/az login|not logged in|AADSTS|expired/i.test(output)) {
|
|
145
|
+
return new MachineCommandError(
|
|
146
|
+
"az-not-logged-in",
|
|
147
|
+
`Azure CLI is not signed in (or the session expired). Run "az login", then try again.\n${output.slice(0, 1000)}`,
|
|
148
|
+
{ diagnostic },
|
|
149
|
+
"requires-human"
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return new MachineCommandError(code, `${message}: ${output || result.errorCode || result.failureKind || `exit ${result.status}`}`, { diagnostic }, "blocked");
|
|
142
153
|
}
|
|
143
154
|
|
|
144
155
|
function subscriptionArgs(subscription?: string): string[] {
|
|
@@ -147,10 +158,10 @@ function subscriptionArgs(subscription?: string): string[] {
|
|
|
147
158
|
|
|
148
159
|
function buildBootstrapCommands(options: PlanProvisionOptions, mainBicepPath: string, parametersPath: string): ProvisionCommand[] {
|
|
149
160
|
const commands: ProvisionCommand[] = [];
|
|
150
|
-
commands.push({ file: "az", args: ["group", "create", "--name", options.resourceGroup, "--location", options.location, ...subscriptionArgs(options.subscription)], kind: "resource-group" });
|
|
161
|
+
commands.push({ file: "az", args: ["group", "create", "--name", options.resourceGroup, "--location", options.location, ...subscriptionArgs(options.subscription), "--query", "id", "-o", "json"], kind: "resource-group" });
|
|
151
162
|
commands.push({
|
|
152
163
|
file: "az",
|
|
153
|
-
args: ["deployment", "group", "create", "--name", "swallowkit-baseline", "--resource-group", options.resourceGroup, "--template-file", mainBicepPath, "--parameters", parametersPath, "--parameters", `location=${options.location}`, `swaLocation=${options.swaLocation}`, ...subscriptionArgs(options.subscription)],
|
|
164
|
+
args: ["deployment", "group", "create", "--name", "swallowkit-baseline", "--resource-group", options.resourceGroup, "--template-file", mainBicepPath, "--parameters", parametersPath, "--parameters", `location=${options.location}`, `swaLocation=${options.swaLocation}`, ...subscriptionArgs(options.subscription), "--no-wait", "--query", DEPLOYMENT_QUERY, "-o", "json"],
|
|
154
165
|
kind: "deployment",
|
|
155
166
|
});
|
|
156
167
|
return commands;
|
|
@@ -175,24 +186,30 @@ interface RemoteGroupState {
|
|
|
175
186
|
|
|
176
187
|
function inspectRemoteGroup(options: PlanProvisionOptions): RemoteGroupState {
|
|
177
188
|
const existsResult = runAz(["group", "exists", "--name", options.resourceGroup, ...subscriptionArgs(options.subscription)], 60_000);
|
|
178
|
-
if (existsResult.status !== 0) {
|
|
179
|
-
throw
|
|
189
|
+
if (existsResult.status !== 0 || existsResult.failureKind || !/^(true|false)$/.test(existsResult.stdout.trim())) {
|
|
190
|
+
throw azQueryError("azure-state-query-failed", "Could not query resource group", existsResult);
|
|
180
191
|
}
|
|
181
192
|
if ((existsResult.stdout || "").trim() !== "true") return { exists: false, tags: {} };
|
|
182
193
|
const showResult = runAz(["group", "show", "--name", options.resourceGroup, ...subscriptionArgs(options.subscription), "--query", "{id:id,tags:tags}", "-o", "json"], 60_000);
|
|
183
|
-
if (showResult.status !== 0) {
|
|
184
|
-
throw
|
|
194
|
+
if (showResult.status !== 0 || showResult.failureKind || showResult.stdoutTruncated) {
|
|
195
|
+
throw azQueryError("azure-state-query-failed", "Could not read resource group tags", showResult);
|
|
196
|
+
}
|
|
197
|
+
let parsed: { id?: string; tags?: Record<string, string> };
|
|
198
|
+
try { parsed = JSON.parse(showResult.stdout); } catch {
|
|
199
|
+
throw azQueryError("azure-state-query-failed", "Invalid resource group response", showResult);
|
|
200
|
+
}
|
|
201
|
+
if (!parsed || typeof parsed.id !== "string") {
|
|
202
|
+
throw azQueryError("azure-state-query-failed", "Invalid resource group identity", showResult);
|
|
185
203
|
}
|
|
186
|
-
const parsed = JSON.parse(showResult.stdout || "{}");
|
|
187
204
|
return { exists: true, id: parsed.id, tags: parsed.tags ?? {} };
|
|
188
205
|
}
|
|
189
206
|
|
|
190
207
|
function tagCommand(resourceId: string, key: string, state: MigrationState, expectedState: MigrationState | null): ProvisionCommand {
|
|
191
|
-
return { file: "az", args: ["tag", "update", "--resource-id", resourceId, "--operation", "Merge", "--tags", `${key}=${encodeMigrationState(state)}
|
|
208
|
+
return { file: "az", args: ["tag", "update", "--resource-id", resourceId, "--operation", "Merge", "--tags", `${key}=${encodeMigrationState(state)}`, "-o", "none"], kind: "tag", expectedState };
|
|
192
209
|
}
|
|
193
210
|
|
|
194
211
|
function bootstrapTagCommand(options: PlanProvisionOptions, key: string, state: MigrationState): ProvisionCommand {
|
|
195
|
-
return { file: "az", args: ["group", "update", "--name", options.resourceGroup, "--set", `tags.${key}=${encodeMigrationState(state)}`, ...subscriptionArgs(options.subscription)], kind: "tag", expectedState: null };
|
|
212
|
+
return { file: "az", args: ["group", "update", "--name", options.resourceGroup, "--set", `tags.${key}=${encodeMigrationState(state)}`, ...subscriptionArgs(options.subscription), "-o", "none"], kind: "tag", expectedState: null };
|
|
196
213
|
}
|
|
197
214
|
|
|
198
215
|
function runBaseWhatIf(options: PlanProvisionOptions, mainBicepPath: string, parametersPath: string): string {
|
|
@@ -208,7 +225,7 @@ function runBaseWhatIf(options: PlanProvisionOptions, mainBicepPath: string, par
|
|
|
208
225
|
"-o", "json",
|
|
209
226
|
]);
|
|
210
227
|
if (result.status !== 0) {
|
|
211
|
-
throw
|
|
228
|
+
throw azQueryError("what-if-failed", "az deployment group what-if failed", result);
|
|
212
229
|
}
|
|
213
230
|
return result.stdout.trim();
|
|
214
231
|
}
|
|
@@ -231,7 +248,98 @@ function assertAdoptionWhatIfIsSafe(whatIfResult: string): void {
|
|
|
231
248
|
}
|
|
232
249
|
}
|
|
233
250
|
|
|
234
|
-
|
|
251
|
+
function resolveSubscription(options: PlanProvisionOptions): string {
|
|
252
|
+
if (options.subscription) return options.subscription;
|
|
253
|
+
const result = runAz(["account", "show", "--query", "id", "-o", "tsv"]);
|
|
254
|
+
const subscription = result.stdout.trim();
|
|
255
|
+
if (result.status !== 0 || !SUBSCRIPTION_PATTERN.test(subscription)) {
|
|
256
|
+
throw azQueryError("azure-subscription-query-failed", "Could not identify the Azure subscription for this provision run", result);
|
|
257
|
+
}
|
|
258
|
+
return subscription;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function canonicalTemplate(value: unknown): string {
|
|
262
|
+
const normalize = (item: unknown): unknown => {
|
|
263
|
+
if (Array.isArray(item)) return item.map(normalize);
|
|
264
|
+
if (item && typeof item === "object") {
|
|
265
|
+
return Object.fromEntries(Object.entries(item).filter(([key]) => key !== "_generator")
|
|
266
|
+
.sort(([left], [right]) => left.localeCompare(right)).map(([key, entry]) => [key, normalize(entry)]));
|
|
267
|
+
}
|
|
268
|
+
return item;
|
|
269
|
+
};
|
|
270
|
+
return JSON.stringify(normalize(value));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function verifyExistingBaseline(
|
|
274
|
+
options: PlanProvisionOptions,
|
|
275
|
+
mainBicepPath: string,
|
|
276
|
+
parametersPath: string
|
|
277
|
+
): Promise<DeploymentReference> {
|
|
278
|
+
const projectRoot = path.dirname(path.dirname(mainBicepPath));
|
|
279
|
+
const secretValues = provisionSecretValues({
|
|
280
|
+
fingerprints: Object.fromEntries(collectLegacyBaselineFiles(projectRoot).map(file => [file.path, file.checksum])),
|
|
281
|
+
}, projectRoot);
|
|
282
|
+
const reference: DeploymentReference = {
|
|
283
|
+
subscription: options.subscription!, resourceGroup: options.resourceGroup, name: options.resumeDeployment!,
|
|
284
|
+
};
|
|
285
|
+
const observation = await queryAzureDeployment(reference, { secretValues });
|
|
286
|
+
if (observation.state === "unknown") {
|
|
287
|
+
throw new MachineCommandError("recovery-deployment-unknown", "The existing deployment could not be identified. No Bicep will be submitted.", { deployment: observation }, "blocked");
|
|
288
|
+
}
|
|
289
|
+
const compiled = await runAzCliAsync(["bicep", "build", "--file", mainBicepPath, "--stdout"], { timeout: 60_000, captureBytes: 16_777_216 });
|
|
290
|
+
const exported = await runAzCliAsync([
|
|
291
|
+
"deployment", "group", "export", "--name", reference.name,
|
|
292
|
+
"--resource-group", reference.resourceGroup, "--subscription", reference.subscription,
|
|
293
|
+
], { timeout: 60_000, captureBytes: 16_777_216 });
|
|
294
|
+
const parameters = await runAzCliAsync([
|
|
295
|
+
"deployment", "group", "show", "--name", reference.name, "--resource-group", reference.resourceGroup,
|
|
296
|
+
"--subscription", reference.subscription, "--query", "properties.parameters", "-o", "json",
|
|
297
|
+
], { timeout: 30_000 });
|
|
298
|
+
if ([compiled, exported, parameters].some((result) => result.status !== 0 || result.failureKind || result.stdoutTruncated)) {
|
|
299
|
+
throw new MachineCommandError("recovery-evidence-unavailable", "Recovery requires the existing deployment template and parameters to match the local baseline. Evidence could not be read; nothing will be redeployed.", {
|
|
300
|
+
diagnostics: [compiled, exported, parameters].map((result, index) => ({
|
|
301
|
+
...azureCliDiagnostic(result, ["local-bicep-build", "recovery-template", "recovery-parameters"][index], secretValues),
|
|
302
|
+
stdout: "[template/parameter payload omitted]", stderr: "[template/parameter payload omitted]", outputTruncated: true,
|
|
303
|
+
})),
|
|
304
|
+
}, "blocked");
|
|
305
|
+
}
|
|
306
|
+
let template: Record<string, unknown>;
|
|
307
|
+
let remoteParameters: Record<string, { type?: string; value?: unknown }>;
|
|
308
|
+
let localParameters: Record<string, { value?: unknown }>;
|
|
309
|
+
try {
|
|
310
|
+
template = JSON.parse(compiled.stdout) as Record<string, unknown>;
|
|
311
|
+
if (canonicalTemplate(template) !== canonicalTemplate(JSON.parse(exported.stdout))) {
|
|
312
|
+
throw new MachineCommandError("recovery-template-mismatch", "The deployed ARM template differs from the local Bicep. Restore the matching reviewed files before recovery; do not redeploy to repair migration bookkeeping.", undefined, "blocked");
|
|
313
|
+
}
|
|
314
|
+
remoteParameters = JSON.parse(parameters.stdout) as Record<string, { type?: string; value?: unknown }>;
|
|
315
|
+
localParameters = JSON.parse(fs.readFileSync(parametersPath, "utf-8")).parameters as Record<string, { value?: unknown }>;
|
|
316
|
+
if (!remoteParameters || !localParameters || typeof remoteParameters !== "object" || typeof localParameters !== "object") throw new Error("Invalid parameters");
|
|
317
|
+
} catch (error) {
|
|
318
|
+
if (error instanceof MachineCommandError) throw error;
|
|
319
|
+
throw new MachineCommandError("recovery-invalid-evidence", "Recovery template/parameter evidence is not valid JSON.", undefined, "blocked");
|
|
320
|
+
}
|
|
321
|
+
const declarations = template.parameters as Record<string, { type?: string }> | undefined;
|
|
322
|
+
if (Object.values(declarations ?? {}).some((parameter) => /^secure/i.test(parameter.type ?? ""))) {
|
|
323
|
+
throw new MachineCommandError("recovery-secure-parameters", "This untracked deployment has secure parameters that cannot be verified. Recovery requires the original execution checkpoint; no secret values will be requested or printed.", undefined, "blocked");
|
|
324
|
+
}
|
|
325
|
+
const expected = { ...localParameters, location: { value: options.location }, swaLocation: { value: options.swaLocation } };
|
|
326
|
+
for (const [name, parameter] of Object.entries(expected)) {
|
|
327
|
+
if (!parameter || !Object.prototype.hasOwnProperty.call(parameter, "value")
|
|
328
|
+
|| canonicalTemplate(parameter.value) !== canonicalTemplate(remoteParameters[name]?.value)) {
|
|
329
|
+
throw new MachineCommandError("recovery-parameters-mismatch", "The deployed parameters differ from the local reviewed parameters/regions. No tag or ledger will be changed.", undefined, "blocked");
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return observation.reference;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Builds the provision plan. Machine/MCP callers persist it for a later `apply provision --plan`;
|
|
337
|
+
* the interactive `swallowkit provision` command keeps it in memory and executes it after confirmation.
|
|
338
|
+
*/
|
|
339
|
+
export async function planProvisionOperation(
|
|
340
|
+
options: PlanProvisionOptions,
|
|
341
|
+
planOptions: { persist?: boolean } = {}
|
|
342
|
+
): Promise<ProvisionPlanData> {
|
|
235
343
|
const projectRoot = process.cwd();
|
|
236
344
|
validateProvisionInputs(options);
|
|
237
345
|
|
|
@@ -250,9 +358,10 @@ export async function planProvisionOperation(options: PlanProvisionOptions): Pro
|
|
|
250
358
|
warnings.push("infra/main.parameters.json not found; deployment will fail until it is created.");
|
|
251
359
|
}
|
|
252
360
|
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
361
|
+
const azCli = checkAzCliAvailability();
|
|
362
|
+
const azCliAvailable = azCli.available;
|
|
363
|
+
if (!azCli.available) {
|
|
364
|
+
warnings.push(azCliUnavailableWarning(azCli));
|
|
256
365
|
}
|
|
257
366
|
|
|
258
367
|
const mainBicepPath = path.join(projectRoot, "infra", "main.bicep");
|
|
@@ -265,6 +374,7 @@ export async function planProvisionOperation(options: PlanProvisionOptions): Pro
|
|
|
265
374
|
let whatIfResult: string | undefined;
|
|
266
375
|
let manifestAfterApply: MigrationManifest | undefined;
|
|
267
376
|
let baselineDrift: string[] = [];
|
|
377
|
+
let recoveryDeployment: DeploymentReference | undefined;
|
|
268
378
|
|
|
269
379
|
const manifestPath = path.join(projectRoot, MIGRATION_MANIFEST_PATH);
|
|
270
380
|
const manifest = fs.existsSync(manifestPath) ? loadMigrationManifest(projectRoot) : null;
|
|
@@ -281,6 +391,18 @@ export async function planProvisionOperation(options: PlanProvisionOptions): Pro
|
|
|
281
391
|
}
|
|
282
392
|
|
|
283
393
|
if (azCliAvailable) {
|
|
394
|
+
options = { ...options, subscription: resolveSubscription(options) };
|
|
395
|
+
const unfinished = readRuns(projectRoot).find((run) =>
|
|
396
|
+
run.outcome !== "succeeded" && run.outcome !== "failed" && run.plan.resourceGroup === options.resourceGroup
|
|
397
|
+
&& run.plan.subscription === options.subscription);
|
|
398
|
+
if (unfinished) {
|
|
399
|
+
throw new MachineCommandError(
|
|
400
|
+
"provision-run-pending",
|
|
401
|
+
`Provision run ${unfinished.runId} is unfinished. Inspect or resume that run instead of submitting Bicep again.`,
|
|
402
|
+
{ runId: unfinished.runId, outcome: unfinished.outcome },
|
|
403
|
+
"blocked", provisionNextActions(unfinished.runId)
|
|
404
|
+
);
|
|
405
|
+
}
|
|
284
406
|
const remoteGroup = inspectRemoteGroup(options);
|
|
285
407
|
if (!manifest) {
|
|
286
408
|
if (remoteGroup.exists) {
|
|
@@ -295,7 +417,32 @@ export async function planProvisionOperation(options: PlanProvisionOptions): Pro
|
|
|
295
417
|
}
|
|
296
418
|
|
|
297
419
|
migrationTagKey = getMigrationTagKey(manifest.projectId);
|
|
298
|
-
if (
|
|
420
|
+
if (options.resumeDeployment) {
|
|
421
|
+
if (!remoteGroup.exists || manifest.baseline.legacy) {
|
|
422
|
+
throw new MachineCommandError("recovery-baseline-required", "--resume-deployment requires an existing resource group and the original non-legacy baseline manifest.", undefined, "blocked");
|
|
423
|
+
}
|
|
424
|
+
const tagValue = remoteGroup.tags[migrationTagKey];
|
|
425
|
+
remoteState = tagValue ? decodeMigrationState(tagValue) : null;
|
|
426
|
+
if (remoteState && remoteState.version !== 0) {
|
|
427
|
+
throw new MachineCommandError("recovery-baseline-only", "Untracked-deployment recovery supports baseline 0 only. Use the original checkpoint to resume migrations.", undefined, "blocked");
|
|
428
|
+
}
|
|
429
|
+
recoveryDeployment = await verifyExistingBaseline(options, mainBicepPath, parametersPath);
|
|
430
|
+
const baselineFiles = collectLegacyBaselineFiles(projectRoot);
|
|
431
|
+
manifestAfterApply = {
|
|
432
|
+
...manifest,
|
|
433
|
+
baseline: { ...manifest.baseline, files: baselineFiles, checksum: computeBaselineChecksum(baselineFiles) },
|
|
434
|
+
};
|
|
435
|
+
if (remoteState && remoteState.checksum !== manifestAfterApply.baseline.checksum) {
|
|
436
|
+
throw new MachineCommandError("recovery-checksum-mismatch", "The existing baseline tag does not match this project. Nothing will be overwritten.", undefined, "blocked");
|
|
437
|
+
}
|
|
438
|
+
mode = "recover";
|
|
439
|
+
warnings.push("Recovery only observes the matching existing deployment, then records migration state after Azure Succeeded. It never submits Bicep.");
|
|
440
|
+
commandSpecs = [{
|
|
441
|
+
file: "az",
|
|
442
|
+
args: ["deployment", "group", "show", "--name", recoveryDeployment.name, "--resource-group", options.resourceGroup, ...subscriptionArgs(options.subscription), "--query", DEPLOYMENT_QUERY, "-o", "json"],
|
|
443
|
+
kind: "deployment",
|
|
444
|
+
}, tagCommand(remoteGroup.id!, migrationTagKey, { schemaVersion: 1, version: 0, checksum: manifestAfterApply.baseline.checksum }, remoteState)];
|
|
445
|
+
} else if (!remoteGroup.exists) {
|
|
299
446
|
mode = "bootstrap";
|
|
300
447
|
const baselineFiles = collectLegacyBaselineFiles(projectRoot);
|
|
301
448
|
manifestAfterApply = {
|
|
@@ -321,7 +468,7 @@ export async function planProvisionOperation(options: PlanProvisionOptions): Pro
|
|
|
321
468
|
commandSpecs = [];
|
|
322
469
|
if (options.reconcile && !manifest.baseline.legacy) {
|
|
323
470
|
whatIfResult = runBaseWhatIf(options, mainBicepPath, parametersPath);
|
|
324
|
-
warnings.push("
|
|
471
|
+
warnings.push("Explicit reconcile redeploys the untagged baseline. For an interrupted wait/tag/ledger update use --resume-deployment instead.");
|
|
325
472
|
mode = "reconcile";
|
|
326
473
|
const baselineFiles = collectLegacyBaselineFiles(projectRoot);
|
|
327
474
|
manifestAfterApply = {
|
|
@@ -379,12 +526,13 @@ export async function planProvisionOperation(options: PlanProvisionOptions): Pro
|
|
|
379
526
|
file: "az",
|
|
380
527
|
args: [
|
|
381
528
|
"deployment", "group", "create",
|
|
382
|
-
"--name",
|
|
529
|
+
"--name", `swallowkit-migration-${migration.version}-${migration.checksum.slice(0, 12)}`,
|
|
383
530
|
"--resource-group", options.resourceGroup,
|
|
384
531
|
"--template-file", path.join(projectRoot, migration.template),
|
|
385
532
|
...(migration.parameters ? ["--parameters", path.join(projectRoot, migration.parameters)] : []),
|
|
386
533
|
"--parameters", `projectName=${projectName}`, `location=${options.location}`,
|
|
387
534
|
...subscriptionArgs(options.subscription),
|
|
535
|
+
"--no-wait", "--query", DEPLOYMENT_QUERY, "-o", "json",
|
|
388
536
|
],
|
|
389
537
|
kind: "migration",
|
|
390
538
|
expectedState,
|
|
@@ -395,8 +543,14 @@ export async function planProvisionOperation(options: PlanProvisionOptions): Pro
|
|
|
395
543
|
}
|
|
396
544
|
}
|
|
397
545
|
}
|
|
398
|
-
} else if (options.whatIf || options.adoptExisting || options.reconcile) {
|
|
399
|
-
|
|
546
|
+
} else if (options.whatIf || options.adoptExisting || options.reconcile || options.resumeDeployment) {
|
|
547
|
+
const detail = azCli.available ? "" : ` ${azCliUnavailableWarning(azCli)}`;
|
|
548
|
+
throw new MachineCommandError(
|
|
549
|
+
!azCli.available && azCli.reason === "not-runnable" ? "az-cli-not-runnable" : "az-cli-not-found",
|
|
550
|
+
`Azure CLI is required for --what-if, --adopt-existing, --reconcile, and --resume-deployment.${detail}`,
|
|
551
|
+
undefined,
|
|
552
|
+
"blocked"
|
|
553
|
+
);
|
|
400
554
|
}
|
|
401
555
|
|
|
402
556
|
const fingerprints: Record<string, string | null> = {
|
|
@@ -428,6 +582,8 @@ export async function planProvisionOperation(options: PlanProvisionOptions): Pro
|
|
|
428
582
|
mode,
|
|
429
583
|
remoteState,
|
|
430
584
|
pendingMigrations: pendingMigrations.map((migration) => ({ version: migration.version, checksum: migration.checksum })),
|
|
585
|
+
recoveryDeployment,
|
|
586
|
+
nonce: crypto.randomUUID(),
|
|
431
587
|
baselineSnapshotChecksum,
|
|
432
588
|
fingerprints,
|
|
433
589
|
})
|
|
@@ -458,73 +614,233 @@ export async function planProvisionOperation(options: PlanProvisionOptions): Pro
|
|
|
458
614
|
requiresApproval: true,
|
|
459
615
|
fingerprints,
|
|
460
616
|
...(baselineSnapshotChecksum ? { baselineSnapshotChecksum } : {}),
|
|
617
|
+
...(recoveryDeployment ? { recoveryDeployment } : {}),
|
|
461
618
|
};
|
|
462
619
|
|
|
463
|
-
savePlanState(plan, projectRoot);
|
|
620
|
+
if (planOptions.persist !== false) savePlanState(plan, projectRoot);
|
|
464
621
|
return plan;
|
|
465
622
|
}
|
|
466
623
|
|
|
467
624
|
export interface ApplyProvisionOptions {
|
|
468
625
|
planId: string;
|
|
469
626
|
approve?: boolean;
|
|
627
|
+
waitSeconds?: number;
|
|
628
|
+
/** Human CLI: keep polling until Azure reaches a terminal state instead of using the bounded wait budget. */
|
|
629
|
+
waitUntilComplete?: boolean;
|
|
470
630
|
}
|
|
471
631
|
|
|
472
632
|
export interface ApplyProvisionResult {
|
|
633
|
+
status: MachineOperationStatus;
|
|
634
|
+
outcome: "succeeded" | "in-progress" | "unknown" | "partial" | "failed";
|
|
635
|
+
runId: string;
|
|
473
636
|
planId: string;
|
|
474
637
|
resourceGroup: string;
|
|
475
638
|
location: string;
|
|
476
639
|
swaLocation: string;
|
|
477
640
|
executedCommands: string[];
|
|
478
|
-
deploymentOutputs?: Record<string, unknown>;
|
|
479
641
|
warnings: string[];
|
|
642
|
+
diagnostics: AzureCliDiagnostic[];
|
|
643
|
+
deployment?: DeploymentObservation;
|
|
644
|
+
nextActions: MachineNextAction[];
|
|
645
|
+
interrupted?: NodeJS.Signals;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
export interface ProvisionRun {
|
|
649
|
+
schemaVersion: 1;
|
|
650
|
+
runId: string;
|
|
651
|
+
projectRoot: string;
|
|
652
|
+
plan: ProvisionPlanData;
|
|
653
|
+
cursor: number;
|
|
654
|
+
attemptedIndex?: number;
|
|
655
|
+
deployment?: DeploymentReference;
|
|
656
|
+
observation?: DeploymentObservation;
|
|
657
|
+
outcome: ApplyProvisionResult["outcome"];
|
|
658
|
+
executedCommands: string[];
|
|
659
|
+
warnings: string[];
|
|
660
|
+
diagnostics: AzureCliDiagnostic[];
|
|
661
|
+
updatedAt: string;
|
|
480
662
|
}
|
|
481
663
|
|
|
482
664
|
function statesEqual(left: MigrationState | null | undefined, right: MigrationState | null | undefined): boolean {
|
|
483
665
|
return left?.version === right?.version && left?.checksum === right?.checksum;
|
|
484
666
|
}
|
|
485
667
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
return
|
|
668
|
+
const MACHINE_RETRY_HINT = "Re-run plan provision.";
|
|
669
|
+
export const INTERACTIVE_PROVISION_RETRY_HINT = 'Run "swallowkit provision" again to review the current state.';
|
|
670
|
+
|
|
671
|
+
export function provisionNextActions(runId: string): MachineNextAction[] {
|
|
672
|
+
return [
|
|
673
|
+
{ command: `swallowkit machine inspect provision --plan ${runId}`, description: "Read the original Azure deployment state without creating, cancelling, deleting, tagging, or updating the ledger." },
|
|
674
|
+
{ command: `swallowkit machine apply provision --plan ${runId} --approve`, description: "After explicit human approval, resume this checkpoint: observe the original deployment and finish tags/ledger; never resubmit an attempted deployment." },
|
|
675
|
+
];
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function resultForRun(run: ProvisionRun, outcome = run.outcome, interrupted?: NodeJS.Signals): ApplyProvisionResult {
|
|
679
|
+
run.warnings = run.warnings.slice(-20);
|
|
680
|
+
run.diagnostics = run.diagnostics.slice(-20);
|
|
681
|
+
return {
|
|
682
|
+
status: outcome === "succeeded" ? "complete" : outcome === "in-progress" ? "in-progress" : outcome === "failed" ? "failed" : "blocked",
|
|
683
|
+
outcome, runId: run.runId, planId: run.plan.planId,
|
|
684
|
+
resourceGroup: run.plan.resourceGroup, location: run.plan.location, swaLocation: run.plan.swaLocation,
|
|
685
|
+
executedCommands: run.executedCommands, warnings: run.warnings, diagnostics: run.diagnostics,
|
|
686
|
+
...(run.observation ? { deployment: run.observation } : {}),
|
|
687
|
+
nextActions: outcome === "succeeded"
|
|
688
|
+
? [{ command: "swallowkit machine inspect infra", description: "Inspect local infrastructure assets after deployment and migration bookkeeping completed." }]
|
|
689
|
+
: provisionNextActions(run.runId),
|
|
690
|
+
...(interrupted ? { interrupted } : {}),
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function readRun(runId: string, projectRoot: string): ProvisionRun | null {
|
|
695
|
+
let run: ProvisionRun | null;
|
|
696
|
+
try { run = loadProvisionRun<ProvisionRun>(runId, projectRoot); } catch {
|
|
697
|
+
throw new MachineCommandError("invalid-provision-checkpoint", "The execution checkpoint cannot be read. Do not re-submit Bicep; inspect the original deployment first.", { runId }, "blocked");
|
|
698
|
+
}
|
|
699
|
+
if (run && (run.schemaVersion !== 1 || run.runId !== runId || run.plan?.planId !== runId
|
|
700
|
+
|| run.projectRoot !== path.resolve(projectRoot) || !Number.isInteger(run.cursor)
|
|
701
|
+
|| !Array.isArray(run.plan.commandSpecs) || !run.plan.fingerprints
|
|
702
|
+
|| !Array.isArray(run.warnings) || !Array.isArray(run.diagnostics) || !Array.isArray(run.executedCommands)
|
|
703
|
+
|| !["succeeded", "in-progress", "unknown", "partial", "failed"].includes(run.outcome)
|
|
704
|
+
|| run.cursor < 0 || run.cursor > run.plan.commandSpecs.length)) {
|
|
705
|
+
throw new MachineCommandError("invalid-provision-checkpoint", "The execution checkpoint is invalid or belongs to another checkout. Nothing will be re-submitted.", { runId }, "blocked");
|
|
706
|
+
}
|
|
707
|
+
return run;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function readRuns(projectRoot: string): ProvisionRun[] {
|
|
711
|
+
try {
|
|
712
|
+
return listProvisionRuns<ProvisionRun>(projectRoot).map(record => {
|
|
713
|
+
if (!record || typeof record.runId !== "string") throw new Error("Invalid checkpoint identity.");
|
|
714
|
+
const run = readRun(record.runId, projectRoot);
|
|
715
|
+
if (!run) throw new Error("Missing execution checkpoint.");
|
|
716
|
+
return run;
|
|
717
|
+
});
|
|
718
|
+
} catch (error) {
|
|
719
|
+
if (error instanceof MachineCommandError) throw error;
|
|
720
|
+
throw new MachineCommandError("invalid-provision-checkpoint", "An execution checkpoint is corrupt. Preserve it and inspect the original deployment; nothing will be re-submitted.", undefined, "blocked");
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function validateWaitSeconds(value = DEFAULT_PROVISION_WAIT_SECONDS): number {
|
|
725
|
+
if (!Number.isInteger(value) || value < 0 || value > MAX_PROVISION_WAIT_SECONDS) {
|
|
726
|
+
throw new MachineCommandError("invalid-arguments", `--wait-seconds must be an integer from 0 to ${MAX_PROVISION_WAIT_SECONDS}.`, undefined, "blocked");
|
|
727
|
+
}
|
|
728
|
+
return value;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function assertLocalFiles(plan: ProvisionPlanData, projectRoot: string, retryHint: string, allowRecordedManifest = false): void {
|
|
732
|
+
const changedFiles = Object.entries(plan.fingerprints).filter(([relativePath, expectedHash]) => {
|
|
733
|
+
const file = path.join(projectRoot, relativePath);
|
|
734
|
+
if (allowRecordedManifest && relativePath === MIGRATION_MANIFEST_PATH && plan.manifestAfterApply && fs.existsSync(file)
|
|
735
|
+
&& fs.readFileSync(file, "utf-8") === `${JSON.stringify(plan.manifestAfterApply, null, 2)}\n`) return false;
|
|
736
|
+
return hashFileIfExists(file) !== expectedHash;
|
|
737
|
+
}).map(([file]) => file);
|
|
738
|
+
if (changedFiles.length || (plan.baselineSnapshotChecksum
|
|
739
|
+
&& computeBaselineChecksum(collectLegacyBaselineFiles(projectRoot)) !== plan.baselineSnapshotChecksum)) {
|
|
740
|
+
throw new MachineCommandError("stale-plan", `${changedFiles.join(", ") || "Baseline infrastructure files"} changed after the preview was computed. ${retryHint}`, { planId: plan.planId, changedFiles }, "blocked");
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
async function readMigrationState(plan: ProvisionPlanData, signal?: AbortSignal, secretValues: readonly string[] = []): Promise<{ group: RemoteGroupState; state: MigrationState | null }> {
|
|
745
|
+
const exists = await runAzCliAsync(["group", "exists", "--name", plan.resourceGroup, ...subscriptionArgs(plan.subscription)], { timeout: 30_000, signal });
|
|
746
|
+
if (exists.status !== 0 || exists.failureKind || !/^(true|false)$/.test(exists.stdout.trim())) {
|
|
747
|
+
throw azQueryError("azure-state-query-failed", "Could not query resource group", exists, secretValues);
|
|
748
|
+
}
|
|
749
|
+
if (exists.stdout.trim() === "false") return { group: { exists: false, tags: {} }, state: null };
|
|
750
|
+
const show = await runAzCliAsync(["group", "show", "--name", plan.resourceGroup, ...subscriptionArgs(plan.subscription), "--query", "{id:id,tags:tags}", "-o", "json"], { timeout: 30_000, signal });
|
|
751
|
+
if (show.status !== 0 || show.failureKind || show.stdoutTruncated) throw azQueryError("azure-state-query-failed", "Could not read resource group tags", show, secretValues);
|
|
752
|
+
let parsed: { id?: unknown; tags?: Record<string, string> };
|
|
753
|
+
try { parsed = JSON.parse(show.stdout); } catch { throw azQueryError("azure-state-query-failed", "Invalid resource group response", show, secretValues); }
|
|
754
|
+
const expectedId = `/subscriptions/${plan.subscription}/resourceGroups/${plan.resourceGroup}`;
|
|
755
|
+
if (typeof parsed.id !== "string" || parsed.id.toLowerCase() !== expectedId.toLowerCase()) {
|
|
756
|
+
throw new MachineCommandError("azure-resource-group-mismatch", "Azure returned a different resource group/subscription. Nothing will be updated.", undefined, "blocked");
|
|
757
|
+
}
|
|
758
|
+
const group = { exists: true, id: parsed.id, tags: parsed.tags ?? {} };
|
|
759
|
+
const tag = plan.migrationTagKey ? group.tags[plan.migrationTagKey] : undefined;
|
|
760
|
+
return { group, state: tag ? decodeMigrationState(tag) : null };
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function desiredTagState(command: ProvisionCommand, key?: string): MigrationState {
|
|
764
|
+
const value = command.args.find((arg) => arg.startsWith(`${key}=`) || arg.startsWith(`tags.${key}=`));
|
|
765
|
+
if (!value) throw new MachineCommandError("incompatible-plan", "The reviewed migration tag is missing.", undefined, "blocked");
|
|
766
|
+
return decodeMigrationState(value.slice(value.indexOf("=") + 1));
|
|
491
767
|
}
|
|
492
768
|
|
|
493
|
-
function
|
|
494
|
-
|
|
495
|
-
const
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
769
|
+
function provisionSecretValues(plan: Pick<ProvisionPlanData, "fingerprints">, projectRoot: string): string[] {
|
|
770
|
+
const files = Object.keys(plan.fingerprints);
|
|
771
|
+
const secureBicep = files.filter(file => file.endsWith(".bicep")).some(file => /@secure\s*\(/.test(fs.readFileSync(path.join(projectRoot, file), "utf-8")));
|
|
772
|
+
const values: string[] = [];
|
|
773
|
+
const collect = (value: unknown): void => {
|
|
774
|
+
if (typeof value === "string" && value) values.push(value);
|
|
775
|
+
else if (Array.isArray(value)) value.forEach(collect);
|
|
776
|
+
else if (value && typeof value === "object") Object.values(value).forEach(collect);
|
|
777
|
+
};
|
|
778
|
+
for (const file of files.filter(file => file.endsWith(".json") && file !== MIGRATION_MANIFEST_PATH)) {
|
|
779
|
+
let parsed: { parameters?: Record<string, { value?: unknown }> };
|
|
780
|
+
try { parsed = JSON.parse(fs.readFileSync(path.join(projectRoot, file), "utf-8")); } catch {
|
|
781
|
+
throw new MachineCommandError("invalid-infra-parameters", "Infrastructure JSON is invalid. Correct it before provision; unstructured Azure output cannot safely redact unknown parameter secrets.", undefined, "blocked");
|
|
782
|
+
}
|
|
783
|
+
for (const [name, parameter] of Object.entries(parsed.parameters ?? {})) {
|
|
784
|
+
if (secureBicep || /secret|password|token|key|connection.?string/i.test(name)) collect(parameter?.value);
|
|
499
785
|
}
|
|
500
|
-
return;
|
|
501
786
|
}
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
787
|
+
return [...new Set(values)].sort((left, right) => right.length - left.length);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function rememberDiagnostic(run: ProvisionRun, result: AzCliResult, phase: string, secretValues: readonly string[]): void {
|
|
791
|
+
run.diagnostics.push(azureCliDiagnostic(result, phase, secretValues));
|
|
792
|
+
run.diagnostics = run.diagnostics.slice(-20);
|
|
793
|
+
if (result.stderr.trim()) {
|
|
794
|
+
run.warnings.push(result.stderrTruncated ? "Azure CLI stderr exceeded the capture limit; output omitted." : redactAzureText(result.stderr, secretValues).slice(0, 4_000));
|
|
795
|
+
run.warnings = run.warnings.slice(-20);
|
|
509
796
|
}
|
|
510
797
|
}
|
|
511
798
|
|
|
512
|
-
function
|
|
513
|
-
const
|
|
514
|
-
if (!
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
799
|
+
export async function inspectProvisionOperation(options: { planId: string; waitSeconds?: number }): Promise<ApplyProvisionResult> {
|
|
800
|
+
const run = readRun(options.planId, process.cwd());
|
|
801
|
+
if (!run) throw new MachineCommandError("provision-run-not-found", "No execution checkpoint exists for this ID. For an older interrupted bootstrap use plan provision --resume-deployment <name> with the original resource group/subscription/regions.", { runId: options.planId }, "blocked");
|
|
802
|
+
const waitSeconds = validateWaitSeconds(options.waitSeconds ?? 0);
|
|
803
|
+
if (run.deployment) {
|
|
804
|
+
let secretValues: string[] = [];
|
|
805
|
+
let omitOutput = false;
|
|
806
|
+
try {
|
|
807
|
+
assertLocalFiles(run.plan, process.cwd(), "Reviewed files no longer match.", true);
|
|
808
|
+
secretValues = provisionSecretValues(run.plan, process.cwd());
|
|
809
|
+
} catch {
|
|
810
|
+
omitOutput = true;
|
|
811
|
+
run.warnings.push("Reviewed files are unavailable or changed; deployment state is still read, but diagnostic output/causes are omitted to avoid exposing unknown secrets.");
|
|
812
|
+
}
|
|
813
|
+
run.observation = await waitForAzureDeployment(run.deployment, { deadline: Date.now() + waitSeconds * 1_000, secretValues });
|
|
814
|
+
if (omitOutput) {
|
|
815
|
+
run.observation.errors = [];
|
|
816
|
+
run.observation.diagnostic = { ...run.observation.diagnostic, stdout: "[output omitted: reviewed parameters unavailable]", stderr: "", outputTruncated: true };
|
|
817
|
+
}
|
|
818
|
+
run.diagnostics = [...run.diagnostics, run.observation.diagnostic].slice(-20);
|
|
819
|
+
let outcome: ApplyProvisionResult["outcome"] = run.observation.state === "Failed" || run.observation.state === "Canceled" ? "failed"
|
|
820
|
+
: run.observation.state === "Succeeded" ? run.outcome === "succeeded" ? "succeeded" : "partial"
|
|
821
|
+
: run.observation.state === "unknown" ? "unknown" : "in-progress";
|
|
822
|
+
if (outcome === "succeeded") {
|
|
823
|
+
try {
|
|
824
|
+
assertLocalFiles(run.plan, process.cwd(), "The original reviewed files no longer match.", true);
|
|
825
|
+
const tag = [...run.plan.commandSpecs].reverse().find(command => command.kind === "tag");
|
|
826
|
+
if (tag && !statesEqual((await readMigrationState(run.plan, undefined, secretValues)).state, desiredTagState(tag, run.plan.migrationTagKey))) {
|
|
827
|
+
outcome = "partial";
|
|
828
|
+
run.warnings.push("Azure Succeeded, but the completed run's migration tag no longer matches. No bookkeeping or deployment write was performed.");
|
|
829
|
+
}
|
|
830
|
+
} catch {
|
|
831
|
+
outcome = "partial";
|
|
832
|
+
run.warnings.push("Azure Succeeded, but current local files or migration bookkeeping could not be verified. No writes were performed.");
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
return resultForRun(run, outcome);
|
|
521
836
|
}
|
|
837
|
+
return resultForRun(run);
|
|
522
838
|
}
|
|
523
839
|
|
|
524
840
|
export async function applyProvisionOperation(options: ApplyProvisionOptions): Promise<ApplyProvisionResult> {
|
|
525
841
|
const projectRoot = process.cwd();
|
|
526
|
-
|
|
527
|
-
const plan = loadPlanState<ProvisionPlanData>(options.planId, projectRoot);
|
|
842
|
+
const run = /^[a-f0-9]{12}$/.test(options.planId) ? readRun(options.planId, projectRoot) : null;
|
|
843
|
+
const plan = run?.plan ?? loadPlanState<ProvisionPlanData>(options.planId, projectRoot);
|
|
528
844
|
if (!plan) {
|
|
529
845
|
throw new MachineCommandError(
|
|
530
846
|
"plan-not-found",
|
|
@@ -534,111 +850,303 @@ export async function applyProvisionOperation(options: ApplyProvisionOptions): P
|
|
|
534
850
|
);
|
|
535
851
|
}
|
|
536
852
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
throw new MachineCommandError(
|
|
545
|
-
"stale-plan",
|
|
546
|
-
`Plan "${options.planId}" is stale: ${changedFiles.join(", ")} changed after the plan was created. Re-run "plan provision".`,
|
|
547
|
-
{ planId: options.planId, changedFiles },
|
|
548
|
-
"blocked"
|
|
549
|
-
);
|
|
550
|
-
}
|
|
551
|
-
if (plan.baselineSnapshotChecksum) {
|
|
552
|
-
const currentBaselineChecksum = computeBaselineChecksum(collectLegacyBaselineFiles(projectRoot));
|
|
553
|
-
if (currentBaselineChecksum !== plan.baselineSnapshotChecksum) {
|
|
554
|
-
throw new MachineCommandError(
|
|
555
|
-
"stale-plan",
|
|
556
|
-
`Plan "${options.planId}" is stale: baseline infrastructure inventory changed after the plan was created.`,
|
|
557
|
-
{ planId: options.planId },
|
|
558
|
-
"blocked"
|
|
559
|
-
);
|
|
853
|
+
const result = await executeProvisionPlan(plan, {
|
|
854
|
+
approve: options.approve, waitSeconds: options.waitSeconds, waitUntilComplete: options.waitUntilComplete,
|
|
855
|
+
retryHint: MACHINE_RETRY_HINT, projectRoot,
|
|
856
|
+
});
|
|
857
|
+
if (result.status === "complete") {
|
|
858
|
+
try { deletePlanState(plan.planId, projectRoot); } catch {
|
|
859
|
+
result.warnings.push("Azure and migration bookkeeping completed, but the old preview could not be removed. The completed execution checkpoint prevents replay.");
|
|
560
860
|
}
|
|
561
861
|
}
|
|
862
|
+
return result;
|
|
863
|
+
}
|
|
562
864
|
|
|
865
|
+
/**
|
|
866
|
+
* Runs an approved provision plan with every safety check: local files and Azure state must still
|
|
867
|
+
* match the reviewed preview, and each migration/tag step re-checks Azure state before it runs.
|
|
868
|
+
*/
|
|
869
|
+
export async function executeProvisionPlan(
|
|
870
|
+
plan: ProvisionPlanData,
|
|
871
|
+
options: {
|
|
872
|
+
approve?: boolean; retryHint?: string; projectRoot?: string; waitSeconds?: number;
|
|
873
|
+
/** Human CLI only: no overall deadline; each az call stays bounded and the checkpoint stays resumable. */
|
|
874
|
+
waitUntilComplete?: boolean;
|
|
875
|
+
signal?: AbortSignal; pollIntervalMs?: number;
|
|
876
|
+
} = {}
|
|
877
|
+
): Promise<ApplyProvisionResult> {
|
|
878
|
+
const projectRoot = options.projectRoot ?? process.cwd();
|
|
879
|
+
const retryHint = options.retryHint ?? MACHINE_RETRY_HINT;
|
|
880
|
+
const waitUntilComplete = options.waitUntilComplete === true && options.waitSeconds === undefined;
|
|
881
|
+
const waitSeconds = waitUntilComplete ? 0 : validateWaitSeconds(options.waitSeconds);
|
|
563
882
|
if (!options.approve) {
|
|
564
883
|
throw new MachineCommandError(
|
|
565
884
|
"approval-required",
|
|
566
885
|
"Provisioning creates billable Azure resources and always requires human approval. Review plan.commands and re-run with --approve.",
|
|
567
886
|
{ planId: plan.planId, commands: plan.commands },
|
|
568
|
-
"requires-human"
|
|
887
|
+
"requires-human",
|
|
888
|
+
[{ command: `swallowkit machine apply provision --plan ${plan.planId} --approve`, description: "Review the commands and obtain explicit human consent before applying or resuming." }]
|
|
569
889
|
);
|
|
570
890
|
}
|
|
571
891
|
if (!plan.mode || !plan.commandSpecs) {
|
|
572
|
-
throw new MachineCommandError("incompatible-plan",
|
|
892
|
+
throw new MachineCommandError("incompatible-plan", `This provision plan was created by an older SwallowKit version. ${retryHint}`, undefined, "blocked");
|
|
573
893
|
}
|
|
574
894
|
if (plan.mode === "offline") {
|
|
575
|
-
throw new MachineCommandError(
|
|
895
|
+
throw new MachineCommandError(
|
|
896
|
+
"online-plan-required",
|
|
897
|
+
`Azure state was not read because Azure CLI was unavailable, so nothing can be applied. Make az available and signed in (az login). ${retryHint}`,
|
|
898
|
+
{ planId: plan.planId, warnings: plan.warnings },
|
|
899
|
+
"blocked"
|
|
900
|
+
);
|
|
576
901
|
}
|
|
577
902
|
|
|
578
903
|
if (plan.mode === "adoption-required") {
|
|
579
904
|
throw new MachineCommandError(
|
|
580
905
|
"adoption-required",
|
|
581
|
-
'This
|
|
906
|
+
'This resource group has no migration tag. Recover a matching existing baseline with --resume-deployment <name>; adoption applies only to a legacy manifest. Do not reconcile just to repair bookkeeping.',
|
|
582
907
|
{ planId: plan.planId },
|
|
583
908
|
"requires-human"
|
|
584
909
|
);
|
|
585
910
|
}
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
911
|
+
let run = readRun(plan.planId, projectRoot);
|
|
912
|
+
const resuming = run !== null;
|
|
913
|
+
if (run) plan = run.plan;
|
|
914
|
+
try {
|
|
915
|
+
assertLocalFiles(plan, projectRoot, run ? "Restore the original reviewed files before resuming this checkpoint; do not resubmit Bicep." : retryHint, run?.cursor === plan.commandSpecs.length);
|
|
916
|
+
} catch (error) {
|
|
917
|
+
if (run && error instanceof MachineCommandError) {
|
|
918
|
+
throw new MachineCommandError(error.code, error.message, { ...resultForRun(run), cause: error.details }, error.status, provisionNextActions(run.runId));
|
|
919
|
+
}
|
|
920
|
+
throw error;
|
|
921
|
+
}
|
|
922
|
+
const secretValues = provisionSecretValues(plan, projectRoot);
|
|
923
|
+
if (!plan.subscription || plan.commandSpecs.some((command) => (command.kind === "deployment" || command.kind === "migration")
|
|
924
|
+
&& !plan.recoveryDeployment && !command.args.includes("--no-wait"))) {
|
|
925
|
+
throw new MachineCommandError("incompatible-plan", `Create a fresh preview with bounded deployment tracking. For an older interrupted deployment use --resume-deployment instead of resubmitting Bicep. ${retryHint}`, undefined, "blocked");
|
|
926
|
+
}
|
|
927
|
+
const azCli = checkAzCliAvailability();
|
|
928
|
+
if (!azCli.available) {
|
|
929
|
+
throw new MachineCommandError(azCli.reason === "not-found" ? "az-cli-not-found" : "az-cli-not-runnable", azCliUnavailableWarning(azCli), undefined, "blocked");
|
|
594
930
|
}
|
|
595
931
|
|
|
596
|
-
const
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
932
|
+
const targetKey = crypto.createHash("sha256").update(`${plan.subscription}/${plan.resourceGroup.toLowerCase()}`).digest("hex");
|
|
933
|
+
let unlock: () => void;
|
|
934
|
+
try { unlock = lockProvisionTarget(targetKey, projectRoot); } catch (error) {
|
|
935
|
+
throw new MachineCommandError("provision-target-locked", error instanceof Error ? error.message : "Provision target is locked.", { runId: plan.planId }, "blocked", provisionNextActions(plan.planId));
|
|
936
|
+
}
|
|
937
|
+
const controller = new AbortController();
|
|
938
|
+
let interrupted: NodeJS.Signals | undefined;
|
|
939
|
+
const interrupt = (signal: NodeJS.Signals) => {
|
|
940
|
+
interrupted = signal;
|
|
941
|
+
controller.abort();
|
|
942
|
+
};
|
|
943
|
+
const sigint = () => interrupt("SIGINT");
|
|
944
|
+
const sigterm = () => interrupt("SIGTERM");
|
|
945
|
+
const externalAbort = () => controller.abort();
|
|
946
|
+
process.on("SIGINT", sigint);
|
|
947
|
+
process.on("SIGTERM", sigterm);
|
|
948
|
+
options.signal?.addEventListener("abort", externalAbort, { once: true });
|
|
949
|
+
if (options.signal?.aborted) controller.abort();
|
|
950
|
+
const signal = controller.signal;
|
|
951
|
+
const deadline = waitUntilComplete ? Number.POSITIVE_INFINITY : Date.now() + waitSeconds * 1_000;
|
|
952
|
+
const persist = () => {
|
|
953
|
+
if (!run) return;
|
|
954
|
+
run.warnings = run.warnings.slice(-20);
|
|
955
|
+
run.diagnostics = run.diagnostics.slice(-20);
|
|
956
|
+
run.updatedAt = new Date().toISOString();
|
|
957
|
+
saveProvisionRun(run, projectRoot);
|
|
958
|
+
};
|
|
959
|
+
const pause = (outcome: ApplyProvisionResult["outcome"], warning: string) => {
|
|
960
|
+
run!.outcome = outcome;
|
|
961
|
+
run!.warnings.push(warning);
|
|
962
|
+
persist();
|
|
963
|
+
return resultForRun(run!, outcome, interrupted);
|
|
964
|
+
};
|
|
965
|
+
const observe = async () => {
|
|
966
|
+
const observation = await waitForAzureDeployment(run!.deployment!, {
|
|
967
|
+
deadline: signal.aborted ? Date.now() : deadline,
|
|
968
|
+
// A final short read after Ctrl+C must not inherit the aborted wait signal.
|
|
969
|
+
...(signal.aborted ? {} : { signal }),
|
|
970
|
+
...(signal.aborted ? { queryTimeoutMs: 5_000 } : {}),
|
|
971
|
+
pollIntervalMs: options.pollIntervalMs,
|
|
972
|
+
...(waitUntilComplete ? { transientRetries: UNBOUNDED_WAIT_TRANSIENT_RETRIES } : {}),
|
|
973
|
+
secretValues,
|
|
974
|
+
onObservation: (value) => {
|
|
975
|
+
run!.observation = value;
|
|
976
|
+
if (value.state !== "unknown") run!.deployment = value.reference;
|
|
977
|
+
persist();
|
|
978
|
+
},
|
|
979
|
+
});
|
|
980
|
+
run!.observation = observation;
|
|
981
|
+
run!.diagnostics.push(observation.diagnostic);
|
|
982
|
+
run!.diagnostics = run!.diagnostics.slice(-20);
|
|
983
|
+
return observation;
|
|
984
|
+
};
|
|
985
|
+
try {
|
|
986
|
+
if (!run) {
|
|
987
|
+
const unfinished = readRuns(projectRoot).find((other) =>
|
|
988
|
+
other.outcome !== "succeeded" && other.outcome !== "failed"
|
|
989
|
+
&& other.plan.resourceGroup === plan.resourceGroup && other.plan.subscription === plan.subscription);
|
|
990
|
+
if (unfinished) throw new MachineCommandError("provision-run-pending", "Resume the existing execution checkpoint instead of creating another deployment.", { runId: unfinished.runId }, "blocked", provisionNextActions(unfinished.runId));
|
|
991
|
+
const current = await readMigrationState(plan, signal, secretValues);
|
|
992
|
+
if (plan.mode === "bootstrap" ? current.group.exists : !current.group.exists || !statesEqual(current.state, plan.remoteState)) {
|
|
993
|
+
throw new MachineCommandError("stale-azure-state", plan.mode === "bootstrap"
|
|
994
|
+
? `The resource group was created after the preview was computed. ${retryHint}`
|
|
995
|
+
: `Azure migration state changed after the preview was computed. ${retryHint}`, { expected: plan.remoteState, actual: current.state }, "blocked");
|
|
996
|
+
}
|
|
997
|
+
const checkpointPlan = { ...plan };
|
|
998
|
+
delete checkpointPlan.whatIfResult;
|
|
999
|
+
run = {
|
|
1000
|
+
schemaVersion: 1, runId: plan.planId, projectRoot: path.resolve(projectRoot), plan: checkpointPlan,
|
|
1001
|
+
cursor: 0, outcome: "in-progress", executedCommands: [], warnings: [...plan.warnings], diagnostics: [],
|
|
1002
|
+
updatedAt: new Date().toISOString(),
|
|
1003
|
+
};
|
|
1004
|
+
persist();
|
|
605
1005
|
}
|
|
606
|
-
|
|
607
|
-
|
|
1006
|
+
for (; run.cursor < plan.commandSpecs.length;) {
|
|
1007
|
+
if (signal.aborted) return pause(run.observation?.state === "Succeeded" ? "partial" : "unknown", "Local provisioning was interrupted. Azure may continue; use status/resume. No Azure cancel/delete was requested and the ledger is not marked successful.");
|
|
1008
|
+
assertLocalFiles(plan, projectRoot, "Restore the original reviewed files before resuming; do not resubmit Bicep.");
|
|
1009
|
+
const command = plan.commandSpecs[run.cursor];
|
|
1010
|
+
if (command.kind === "deployment" || command.kind === "migration") {
|
|
1011
|
+
if (command.kind === "migration" && run.attemptedIndex !== run.cursor && run.observation?.state === "Succeeded" && Date.now() >= deadline) {
|
|
1012
|
+
return pause("partial", "The bounded wait budget ended before the next reviewed migration. Resume this checkpoint after approval; completed deployments will not be re-submitted.");
|
|
1013
|
+
}
|
|
1014
|
+
if (run.attemptedIndex !== run.cursor) {
|
|
1015
|
+
let observationOnly = Boolean(plan.recoveryDeployment);
|
|
1016
|
+
if (plan.recoveryDeployment) {
|
|
1017
|
+
run.deployment = plan.recoveryDeployment;
|
|
1018
|
+
} else {
|
|
1019
|
+
const current = await readMigrationState(plan, signal, secretValues);
|
|
1020
|
+
if (!current.group.exists || !statesEqual(current.state, command.expectedState ?? plan.remoteState)) {
|
|
1021
|
+
throw new MachineCommandError("stale-azure-state", `Azure migration state changed before deployment. ${retryHint}`, { expected: command.expectedState ?? plan.remoteState, actual: current.state }, "blocked");
|
|
1022
|
+
}
|
|
1023
|
+
const name = command.args[command.args.indexOf("--name") + 1];
|
|
1024
|
+
const reference: DeploymentReference = { name, resourceGroup: plan.resourceGroup, subscription: plan.subscription! };
|
|
1025
|
+
const previous = await queryAzureDeployment(reference, { signal, secretValues });
|
|
1026
|
+
if (previous.state === "unknown" && previous.reason !== "not-found") {
|
|
1027
|
+
run.deployment = reference;
|
|
1028
|
+
run.observation = previous;
|
|
1029
|
+
return pause("unknown", "The deployment name could not be checked before submission. No create request was sent; inspect the original deployment before proceeding.");
|
|
1030
|
+
}
|
|
1031
|
+
if (previous.state !== "unknown" && (plan.mode !== "reconcile" || previous.state === "Running" || previous.state === "Accepted")) {
|
|
1032
|
+
run.deployment = previous.reference;
|
|
1033
|
+
run.observation = previous;
|
|
1034
|
+
if (!resuming || command.kind !== "deployment") {
|
|
1035
|
+
return pause("unknown", "A deployment already exists under the reviewed name. No create request was sent. Resume only after matching its template/parameters; do not overwrite or re-submit it.");
|
|
1036
|
+
}
|
|
1037
|
+
run.deployment = await verifyExistingBaseline({ ...plan, resumeDeployment: name },
|
|
1038
|
+
path.join(projectRoot, "infra", "main.bicep"), path.join(projectRoot, "infra", "main.parameters.json"));
|
|
1039
|
+
observationOnly = true;
|
|
1040
|
+
} else {
|
|
1041
|
+
run.deployment = {
|
|
1042
|
+
...reference,
|
|
1043
|
+
...(previous.state !== "unknown" ? { previousCorrelationId: previous.reference.correlationId } : {}),
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
// Persist intent before spawn: even a hard process termination must never replay this create.
|
|
1048
|
+
run.attemptedIndex = run.cursor;
|
|
1049
|
+
persist();
|
|
1050
|
+
if (!observationOnly) {
|
|
1051
|
+
const result = await runAzCliAsync(command.args, { timeout: 60_000, signal });
|
|
1052
|
+
run.executedCommands.push(displayCommand(command));
|
|
1053
|
+
rememberDiagnostic(run, result, command.kind === "migration" ? "migration-submit" : "baseline-submit", secretValues);
|
|
1054
|
+
persist();
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
const observation = await observe();
|
|
1058
|
+
if (signal.aborted) return pause(observation.state === "Succeeded" ? "partial" : observation.state === "Running" || observation.state === "Accepted" ? "in-progress" : "unknown", "Local provisioning was interrupted. Azure may continue. The original deployment is tracked; no cancellation or successful ledger update was performed.");
|
|
1059
|
+
if (observation.state === "Failed" || observation.state === "Canceled") {
|
|
1060
|
+
const operations = await queryDeploymentOperationErrors(observation.reference, secretValues, signal);
|
|
1061
|
+
observation.errors.push(...operations.errors);
|
|
1062
|
+
run.diagnostics.push(operations.diagnostic);
|
|
1063
|
+
run.outcome = "failed";
|
|
1064
|
+
persist();
|
|
1065
|
+
throw new MachineCommandError("azure-deployment-failed",
|
|
1066
|
+
`Azure deployment ${observation.reference.name} is ${observation.state}.${observation.errors.length ? `\n${observation.errors.map((error) => `${error.code}: ${error.message}`).join("\n")}` : " Azure did not return a structured cause; inspect deployment operations."}`,
|
|
1067
|
+
resultForRun(run), "failed", provisionNextActions(run.runId));
|
|
1068
|
+
}
|
|
1069
|
+
if (observation.state !== "Succeeded") {
|
|
1070
|
+
return pause(observation.state === "unknown" ? "unknown" : "in-progress",
|
|
1071
|
+
observation.state === "unknown" ? `Azure deployment result is unknown (${observation.reason}); a local az error is not proof that Azure failed. No create will be automatically retried.`
|
|
1072
|
+
: `Azure deployment is ${observation.state}. The bounded local wait ended; status/resume tracks the same deployment without submitting Bicep again.`);
|
|
1073
|
+
}
|
|
1074
|
+
} else if (command.kind === "tag") {
|
|
1075
|
+
if (run.deployment) {
|
|
1076
|
+
const observation = await queryAzureDeployment(run.deployment, { signal, secretValues });
|
|
1077
|
+
run.observation = observation;
|
|
1078
|
+
if (observation.state !== "Succeeded") return pause("partial", "Azure Succeeded was previously observed, but its identity/terminal state could not be confirmed before migration tagging. No tag or ledger update was performed.");
|
|
1079
|
+
}
|
|
1080
|
+
const current = await readMigrationState(plan, signal, secretValues);
|
|
1081
|
+
const desired = desiredTagState(command, plan.migrationTagKey);
|
|
1082
|
+
if (!current.group.exists) throw new MachineCommandError("stale-azure-state", "The resource group disappeared before tagging.", undefined, "blocked");
|
|
1083
|
+
if (!statesEqual(current.state, desired)) {
|
|
1084
|
+
if (!statesEqual(current.state, command.expectedState)) {
|
|
1085
|
+
throw new MachineCommandError("stale-azure-state", `Azure migration state changed before its tag could be updated. ${retryHint}`, { expected: command.expectedState, actual: current.state }, "blocked");
|
|
1086
|
+
}
|
|
1087
|
+
run.attemptedIndex = run.cursor;
|
|
1088
|
+
persist();
|
|
1089
|
+
const result = await runAzCliAsync(command.args, { timeout: 60_000, signal });
|
|
1090
|
+
run.executedCommands.push(displayCommand(command));
|
|
1091
|
+
rememberDiagnostic(run, result, "migration-tag", secretValues);
|
|
1092
|
+
if (result.status !== 0 || result.failureKind || signal.aborted) {
|
|
1093
|
+
return pause("partial", "Azure deployment succeeded, but migration tagging did not complete locally. Resume this checkpoint to verify/finish tagging; do not redeploy the baseline.");
|
|
1094
|
+
}
|
|
1095
|
+
const after = await readMigrationState(plan, signal, secretValues);
|
|
1096
|
+
if (!statesEqual(after.state, desired)) return pause("partial", "The tag update returned successfully but its version/checksum could not be confirmed. Resume bookkeeping without redeploying Bicep.");
|
|
1097
|
+
}
|
|
1098
|
+
} else {
|
|
1099
|
+
const current = await readMigrationState(plan, signal, secretValues);
|
|
1100
|
+
if (run.attemptedIndex === run.cursor) {
|
|
1101
|
+
if (!current.group.exists) return pause("unknown", "Resource group creation was attempted, but its result is unknown. Inspect the target before resuming; nothing will be re-created automatically.");
|
|
1102
|
+
} else {
|
|
1103
|
+
if (current.group.exists) throw new MachineCommandError("stale-azure-state", `The resource group was created after the preview was computed. ${retryHint}`, undefined, "blocked");
|
|
1104
|
+
run.attemptedIndex = run.cursor;
|
|
1105
|
+
persist();
|
|
1106
|
+
const result = await runAzCliAsync(command.args, { timeout: 60_000, signal });
|
|
1107
|
+
run.executedCommands.push(displayCommand(command));
|
|
1108
|
+
rememberDiagnostic(run, result, "resource-group-create", secretValues);
|
|
1109
|
+
if (result.status !== 0 || result.failureKind || signal.aborted) return pause("unknown", "Local resource-group creation did not complete. Azure may have accepted it; inspect/resume this checkpoint, not a new bootstrap.");
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
run.cursor++;
|
|
1113
|
+
delete run.attemptedIndex;
|
|
1114
|
+
persist();
|
|
608
1115
|
}
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
if (
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
`Command failed: ${commandText}\n${(result.stderr || result.stdout).trim().slice(0, 4000)}`,
|
|
616
|
-
{ executedCommands },
|
|
617
|
-
"failed"
|
|
618
|
-
);
|
|
1116
|
+
if (signal.aborted) return pause("partial", "Local provisioning was interrupted before ledger completion. Resume bookkeeping; no deployment will be submitted again.");
|
|
1117
|
+
assertLocalFiles(plan, projectRoot, "Restore the reviewed files before completing the local ledger.", true);
|
|
1118
|
+
const finalTag = [...plan.commandSpecs].reverse().find((command) => command.kind === "tag");
|
|
1119
|
+
if (finalTag) {
|
|
1120
|
+
const current = await readMigrationState(plan, signal, secretValues);
|
|
1121
|
+
if (!statesEqual(current.state, desiredTagState(finalTag, plan.migrationTagKey))) return pause("partial", "The final remote migration version/checksum changed before local ledger completion. No ledger success was recorded.");
|
|
619
1122
|
}
|
|
620
|
-
if (
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
1123
|
+
if (run.deployment) {
|
|
1124
|
+
run.observation = await queryAzureDeployment(run.deployment, { signal, secretValues });
|
|
1125
|
+
if (run.observation.state !== "Succeeded") return pause("partial", "The original deployment identity/terminal state could not be confirmed before local ledger completion. No successful ledger update was performed.");
|
|
1126
|
+
}
|
|
1127
|
+
if (plan.manifestAfterApply && run.outcome !== "succeeded") replaceMigrationManifest(plan.manifestAfterApply, projectRoot);
|
|
1128
|
+
run.outcome = "succeeded";
|
|
1129
|
+
persist();
|
|
1130
|
+
return resultForRun(run);
|
|
1131
|
+
} catch (error) {
|
|
1132
|
+
if (!run) throw error;
|
|
1133
|
+
if (error instanceof MachineCommandError && error.code === "azure-deployment-failed") throw error;
|
|
1134
|
+
run.outcome = run.observation?.state === "Succeeded" ? "partial" : "unknown";
|
|
1135
|
+
run.warnings.push(`Provisioning stopped: ${redactAzureText(error instanceof Error ? error.message : "Unknown local error", secretValues)}. Azure deployment and local bookkeeping are separate; inspect/resume this checkpoint.`);
|
|
1136
|
+
try { persist(); } catch {
|
|
1137
|
+
run.warnings.push("The checkpoint could not be updated. Preserve the existing checkpoint; inspect the original Azure deployment before any further write.");
|
|
1138
|
+
}
|
|
1139
|
+
if (error instanceof MachineCommandError) {
|
|
1140
|
+
throw new MachineCommandError(error.code, error.message, { ...resultForRun(run), cause: error.details }, error.status ?? "blocked", provisionNextActions(run.runId));
|
|
1141
|
+
}
|
|
1142
|
+
return resultForRun(run, run.outcome, interrupted);
|
|
1143
|
+
} finally {
|
|
1144
|
+
process.removeListener("SIGINT", sigint);
|
|
1145
|
+
process.removeListener("SIGTERM", sigterm);
|
|
1146
|
+
options.signal?.removeEventListener("abort", externalAbort);
|
|
1147
|
+
try { unlock(); } catch {
|
|
1148
|
+
run?.warnings.push("The local execution lock could not be removed. Inspect its owner before retrying; Azure deployment/ledger results are unchanged.");
|
|
1149
|
+
console.warn("Provision lock cleanup failed; inspect the checkpoint before another apply.");
|
|
627
1150
|
}
|
|
628
1151
|
}
|
|
629
|
-
|
|
630
|
-
if (plan.manifestAfterApply) {
|
|
631
|
-
replaceMigrationManifest(plan.manifestAfterApply, projectRoot);
|
|
632
|
-
}
|
|
633
|
-
deletePlanState(plan.planId, projectRoot);
|
|
634
|
-
|
|
635
|
-
return {
|
|
636
|
-
planId: plan.planId,
|
|
637
|
-
resourceGroup: plan.resourceGroup,
|
|
638
|
-
location: plan.location,
|
|
639
|
-
swaLocation: plan.swaLocation,
|
|
640
|
-
executedCommands,
|
|
641
|
-
...(deploymentOutputs ? { deploymentOutputs } : {}),
|
|
642
|
-
warnings,
|
|
643
|
-
};
|
|
644
1152
|
}
|