swallowkit 2.0.0-alpha.4 → 2.0.0-alpha.6
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/dist/cli/commands/add-auth.js +37 -6
- package/dist/cli/commands/add-auth.js.map +1 -1
- package/dist/cli/commands/init.d.ts +2 -0
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +3 -1
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/provision.d.ts +2 -1
- package/dist/cli/commands/provision.d.ts.map +1 -1
- package/dist/cli/commands/provision.js +66 -4
- package/dist/cli/commands/provision.js.map +1 -1
- package/dist/cli/commands/scaffold.d.ts.map +1 -1
- package/dist/cli/commands/scaffold.js +45 -1
- package/dist/cli/commands/scaffold.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +15 -2
- package/dist/cli/index.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/provision-deployment.d.ts +45 -0
- package/dist/core/operations/provision-deployment.d.ts.map +1 -0
- package/dist/core/operations/provision-deployment.js +151 -0
- package/dist/core/operations/provision-deployment.js.map +1 -0
- package/dist/core/operations/provision-operations.d.ts +39 -5
- package/dist/core/operations/provision-operations.d.ts.map +1 -1
- package/dist/core/operations/provision-operations.js +609 -103
- package/dist/core/operations/provision-operations.js.map +1 -1
- package/dist/core/project/boundaries.js +3 -3
- package/dist/core/project/boundaries.js.map +1 -1
- package/dist/core/project/capabilities.d.ts +9 -0
- package/dist/core/project/capabilities.d.ts.map +1 -1
- package/dist/core/project/capabilities.js +20 -6
- package/dist/core/project/capabilities.js.map +1 -1
- package/dist/core/project/react-project.js +1 -1
- 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/workflows.d.ts.map +1 -1
- package/dist/core/project/workflows.js +43 -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 +18 -19
- package/dist/core/scaffold/auth-generator.js.map +1 -1
- package/dist/core/scaffold/native-schema-generator.d.ts +2 -0
- package/dist/core/scaffold/native-schema-generator.d.ts.map +1 -1
- package/dist/core/scaffold/native-schema-generator.js +16 -1
- package/dist/core/scaffold/native-schema-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 +98 -4
- package/dist/core/verify/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 +20 -13
- package/dist/machine/index.js.map +1 -1
- package/dist/mcp/index.d.ts +3 -2
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +76 -15
- package/dist/mcp/index.js.map +1 -1
- package/dist/utils/azure-cli.d.ts +16 -4
- package/dist/utils/azure-cli.d.ts.map +1 -1
- package/dist/utils/azure-cli.js +166 -4
- package/dist/utils/azure-cli.js.map +1 -1
- 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/package.json +6 -3
- package/src/__tests__/__snapshots__/boundaries.test.ts.snap +6 -6
- package/src/__tests__/agent-instructions.test.ts +3 -0
- package/src/__tests__/agent-loop-phase34.test.ts +114 -8
- package/src/__tests__/azure-cli.test.ts +5 -2
- package/src/__tests__/fixtures/az wrapper.cmd +2 -0
- package/src/__tests__/fixtures/azure-cli.cjs +130 -0
- package/src/__tests__/fixtures/provision-runner.cjs +28 -0
- package/src/__tests__/generated-project-matrix.test.ts +789 -0
- package/src/__tests__/mcp.test.ts +71 -0
- package/src/__tests__/multi-auth.test.ts +115 -4
- package/src/__tests__/plan-apply.test.ts +111 -0
- package/src/__tests__/provision-runtime.test.ts +562 -0
- package/src/cli/commands/add-auth.ts +36 -6
- package/src/cli/commands/init.ts +4 -4
- package/src/cli/commands/provision.ts +69 -5
- package/src/cli/commands/scaffold.ts +65 -1
- package/src/cli/index.ts +12 -2
- package/src/core/operations/create-model.ts +51 -13
- package/src/core/operations/provision-deployment.ts +167 -0
- package/src/core/operations/provision-operations.ts +597 -145
- package/src/core/project/agent-templates/AGENTS.md +46 -3
- package/src/core/project/agent-templates/CLAUDE.md +9 -0
- package/src/core/project/agent-templates/copilot-instructions.md +10 -0
- package/src/core/project/agent-templates/shared-models.instructions.md +11 -3
- package/src/core/project/boundaries.ts +3 -3
- package/src/core/project/capabilities.ts +29 -6
- package/src/core/project/react-project.ts +1 -1
- package/src/core/project/state.ts +70 -0
- package/src/core/project/workflows.ts +43 -7
- package/src/core/scaffold/auth-generator.ts +18 -19
- package/src/core/scaffold/native-schema-generator.ts +16 -0
- package/src/core/verify/index.ts +95 -5
- package/src/machine/contracts.ts +1 -0
- package/src/machine/errors.ts +4 -2
- package/src/machine/index.ts +23 -16
- package/src/mcp/index.ts +68 -20
- package/src/utils/azure-cli.ts +167 -6
- package/src/utils/azure-diagnostics.ts +75 -0
|
@@ -1,9 +1,7 @@
|
|
|
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 で停止する。
|
|
8
6
|
* - 対話的な `swallowkit provision` は同じ計画をメモリ上で作成・表示し、確認後に
|
|
9
7
|
* executeProvisionPlan で同じ安全確認を経て実行する(計画ファイルは保存しない)。
|
|
@@ -11,13 +9,19 @@
|
|
|
11
9
|
|
|
12
10
|
import * as crypto from "crypto";
|
|
13
11
|
import * as fs from "fs";
|
|
14
|
-
import { AzCliAvailability, checkAzCliAvailability, runAzCli } from "../../utils/azure-cli";
|
|
12
|
+
import { AzCliAvailability, AzCliResult, checkAzCliAvailability, runAzCli, runAzCliAsync } from "../../utils/azure-cli";
|
|
13
|
+
import { AzureCliDiagnostic, azureCliDiagnostic, redactAzureText } from "../../utils/azure-diagnostics";
|
|
15
14
|
import * as path from "path";
|
|
16
15
|
import { MachineCommandError } from "../../machine/errors";
|
|
16
|
+
import type { MachineNextAction, MachineOperationStatus } from "../../machine/contracts";
|
|
17
17
|
import { getSwallowKitVersion } from "../../version";
|
|
18
18
|
import { inspectInfra, InfraInspection } from "../project/infra";
|
|
19
|
-
import { deletePlanState, loadPlanState, savePlanState } from "../project/state";
|
|
19
|
+
import { deletePlanState, listProvisionRuns, loadPlanState, loadProvisionRun, lockProvisionTarget, savePlanState, saveProvisionRun } from "../project/state";
|
|
20
20
|
import { hashFileIfExists } from "./file-session";
|
|
21
|
+
import {
|
|
22
|
+
DEFAULT_PROVISION_WAIT_SECONDS, DEPLOYMENT_QUERY, DeploymentObservation, DeploymentReference,
|
|
23
|
+
MAX_PROVISION_WAIT_SECONDS, queryAzureDeployment, queryDeploymentOperationErrors, waitForAzureDeployment,
|
|
24
|
+
} from "./provision-deployment";
|
|
21
25
|
import {
|
|
22
26
|
MIGRATION_MANIFEST_PATH,
|
|
23
27
|
MigrationManifest,
|
|
@@ -49,9 +53,11 @@ export interface PlanProvisionOptions {
|
|
|
49
53
|
adoptExisting?: boolean;
|
|
50
54
|
baseline?: number;
|
|
51
55
|
reconcile?: boolean;
|
|
56
|
+
/** Attach an existing, matching baseline deployment without submitting Bicep again. */
|
|
57
|
+
resumeDeployment?: string;
|
|
52
58
|
}
|
|
53
59
|
|
|
54
|
-
export type ProvisionMode = "offline" | "bootstrap" | "adoption-required" | "adopt" | "migrate" | "noop" | "reconcile";
|
|
60
|
+
export type ProvisionMode = "offline" | "bootstrap" | "adoption-required" | "adopt" | "migrate" | "noop" | "reconcile" | "recover";
|
|
55
61
|
|
|
56
62
|
interface ProvisionCommand {
|
|
57
63
|
file: "az";
|
|
@@ -85,6 +91,7 @@ export interface ProvisionPlanData {
|
|
|
85
91
|
requiresApproval: true;
|
|
86
92
|
fingerprints: Record<string, string | null>;
|
|
87
93
|
baselineSnapshotChecksum?: string;
|
|
94
|
+
recoveryDeployment?: DeploymentReference;
|
|
88
95
|
}
|
|
89
96
|
|
|
90
97
|
function validateProvisionInputs(options: PlanProvisionOptions): void {
|
|
@@ -112,6 +119,9 @@ function validateProvisionInputs(options: PlanProvisionOptions): void {
|
|
|
112
119
|
"failed"
|
|
113
120
|
);
|
|
114
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
|
+
}
|
|
115
125
|
}
|
|
116
126
|
|
|
117
127
|
function azCliUnavailableWarning(availability: Exclude<AzCliAvailability, { available: true }>): string {
|
|
@@ -124,32 +134,22 @@ function displayCommand(command: ProvisionCommand): string {
|
|
|
124
134
|
return [command.file, ...command.args.map((arg) => /\s/.test(arg) ? JSON.stringify(arg) : arg)].join(" ");
|
|
125
135
|
}
|
|
126
136
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
stdout: string;
|
|
130
|
-
stderr: string;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function runAz(args: string[], timeout = 300_000): AzResult {
|
|
134
|
-
const result = runAzCli(args, { timeout });
|
|
135
|
-
return {
|
|
136
|
-
status: result.status,
|
|
137
|
-
stdout: result.stdout,
|
|
138
|
-
stderr: result.stderr || (result.error ? result.error.message : ""),
|
|
139
|
-
};
|
|
137
|
+
function runAz(args: string[], timeout = 60_000): AzCliResult {
|
|
138
|
+
return runAzCli(args, { timeout });
|
|
140
139
|
}
|
|
141
140
|
|
|
142
|
-
function azQueryError(code: string, message: string, result:
|
|
143
|
-
const
|
|
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
144
|
if (/az login|not logged in|AADSTS|expired/i.test(output)) {
|
|
145
145
|
return new MachineCommandError(
|
|
146
146
|
"az-not-logged-in",
|
|
147
147
|
`Azure CLI is not signed in (or the session expired). Run "az login", then try again.\n${output.slice(0, 1000)}`,
|
|
148
|
-
|
|
148
|
+
{ diagnostic },
|
|
149
149
|
"requires-human"
|
|
150
150
|
);
|
|
151
151
|
}
|
|
152
|
-
return new MachineCommandError(code, `${message}: ${output}`,
|
|
152
|
+
return new MachineCommandError(code, `${message}: ${output || result.errorCode || result.failureKind || `exit ${result.status}`}`, { diagnostic }, "blocked");
|
|
153
153
|
}
|
|
154
154
|
|
|
155
155
|
function subscriptionArgs(subscription?: string): string[] {
|
|
@@ -158,10 +158,10 @@ function subscriptionArgs(subscription?: string): string[] {
|
|
|
158
158
|
|
|
159
159
|
function buildBootstrapCommands(options: PlanProvisionOptions, mainBicepPath: string, parametersPath: string): ProvisionCommand[] {
|
|
160
160
|
const commands: ProvisionCommand[] = [];
|
|
161
|
-
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" });
|
|
162
162
|
commands.push({
|
|
163
163
|
file: "az",
|
|
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)],
|
|
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"],
|
|
165
165
|
kind: "deployment",
|
|
166
166
|
});
|
|
167
167
|
return commands;
|
|
@@ -186,24 +186,30 @@ interface RemoteGroupState {
|
|
|
186
186
|
|
|
187
187
|
function inspectRemoteGroup(options: PlanProvisionOptions): RemoteGroupState {
|
|
188
188
|
const existsResult = runAz(["group", "exists", "--name", options.resourceGroup, ...subscriptionArgs(options.subscription)], 60_000);
|
|
189
|
-
if (existsResult.status !== 0) {
|
|
189
|
+
if (existsResult.status !== 0 || existsResult.failureKind || !/^(true|false)$/.test(existsResult.stdout.trim())) {
|
|
190
190
|
throw azQueryError("azure-state-query-failed", "Could not query resource group", existsResult);
|
|
191
191
|
}
|
|
192
192
|
if ((existsResult.stdout || "").trim() !== "true") return { exists: false, tags: {} };
|
|
193
193
|
const showResult = runAz(["group", "show", "--name", options.resourceGroup, ...subscriptionArgs(options.subscription), "--query", "{id:id,tags:tags}", "-o", "json"], 60_000);
|
|
194
|
-
if (showResult.status !== 0) {
|
|
194
|
+
if (showResult.status !== 0 || showResult.failureKind || showResult.stdoutTruncated) {
|
|
195
195
|
throw azQueryError("azure-state-query-failed", "Could not read resource group tags", showResult);
|
|
196
196
|
}
|
|
197
|
-
|
|
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);
|
|
203
|
+
}
|
|
198
204
|
return { exists: true, id: parsed.id, tags: parsed.tags ?? {} };
|
|
199
205
|
}
|
|
200
206
|
|
|
201
207
|
function tagCommand(resourceId: string, key: string, state: MigrationState, expectedState: MigrationState | null): ProvisionCommand {
|
|
202
|
-
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 };
|
|
203
209
|
}
|
|
204
210
|
|
|
205
211
|
function bootstrapTagCommand(options: PlanProvisionOptions, key: string, state: MigrationState): ProvisionCommand {
|
|
206
|
-
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 };
|
|
207
213
|
}
|
|
208
214
|
|
|
209
215
|
function runBaseWhatIf(options: PlanProvisionOptions, mainBicepPath: string, parametersPath: string): string {
|
|
@@ -219,7 +225,7 @@ function runBaseWhatIf(options: PlanProvisionOptions, mainBicepPath: string, par
|
|
|
219
225
|
"-o", "json",
|
|
220
226
|
]);
|
|
221
227
|
if (result.status !== 0) {
|
|
222
|
-
throw
|
|
228
|
+
throw azQueryError("what-if-failed", "az deployment group what-if failed", result);
|
|
223
229
|
}
|
|
224
230
|
return result.stdout.trim();
|
|
225
231
|
}
|
|
@@ -242,6 +248,90 @@ function assertAdoptionWhatIfIsSafe(whatIfResult: string): void {
|
|
|
242
248
|
}
|
|
243
249
|
}
|
|
244
250
|
|
|
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
|
+
|
|
245
335
|
/**
|
|
246
336
|
* Builds the provision plan. Machine/MCP callers persist it for a later `apply provision --plan`;
|
|
247
337
|
* the interactive `swallowkit provision` command keeps it in memory and executes it after confirmation.
|
|
@@ -284,6 +374,7 @@ export async function planProvisionOperation(
|
|
|
284
374
|
let whatIfResult: string | undefined;
|
|
285
375
|
let manifestAfterApply: MigrationManifest | undefined;
|
|
286
376
|
let baselineDrift: string[] = [];
|
|
377
|
+
let recoveryDeployment: DeploymentReference | undefined;
|
|
287
378
|
|
|
288
379
|
const manifestPath = path.join(projectRoot, MIGRATION_MANIFEST_PATH);
|
|
289
380
|
const manifest = fs.existsSync(manifestPath) ? loadMigrationManifest(projectRoot) : null;
|
|
@@ -300,6 +391,18 @@ export async function planProvisionOperation(
|
|
|
300
391
|
}
|
|
301
392
|
|
|
302
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
|
+
}
|
|
303
406
|
const remoteGroup = inspectRemoteGroup(options);
|
|
304
407
|
if (!manifest) {
|
|
305
408
|
if (remoteGroup.exists) {
|
|
@@ -314,7 +417,32 @@ export async function planProvisionOperation(
|
|
|
314
417
|
}
|
|
315
418
|
|
|
316
419
|
migrationTagKey = getMigrationTagKey(manifest.projectId);
|
|
317
|
-
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) {
|
|
318
446
|
mode = "bootstrap";
|
|
319
447
|
const baselineFiles = collectLegacyBaselineFiles(projectRoot);
|
|
320
448
|
manifestAfterApply = {
|
|
@@ -340,7 +468,7 @@ export async function planProvisionOperation(
|
|
|
340
468
|
commandSpecs = [];
|
|
341
469
|
if (options.reconcile && !manifest.baseline.legacy) {
|
|
342
470
|
whatIfResult = runBaseWhatIf(options, mainBicepPath, parametersPath);
|
|
343
|
-
warnings.push("
|
|
471
|
+
warnings.push("Explicit reconcile redeploys the untagged baseline. For an interrupted wait/tag/ledger update use --resume-deployment instead.");
|
|
344
472
|
mode = "reconcile";
|
|
345
473
|
const baselineFiles = collectLegacyBaselineFiles(projectRoot);
|
|
346
474
|
manifestAfterApply = {
|
|
@@ -398,12 +526,13 @@ export async function planProvisionOperation(
|
|
|
398
526
|
file: "az",
|
|
399
527
|
args: [
|
|
400
528
|
"deployment", "group", "create",
|
|
401
|
-
"--name",
|
|
529
|
+
"--name", `swallowkit-migration-${migration.version}-${migration.checksum.slice(0, 12)}`,
|
|
402
530
|
"--resource-group", options.resourceGroup,
|
|
403
531
|
"--template-file", path.join(projectRoot, migration.template),
|
|
404
532
|
...(migration.parameters ? ["--parameters", path.join(projectRoot, migration.parameters)] : []),
|
|
405
533
|
"--parameters", `projectName=${projectName}`, `location=${options.location}`,
|
|
406
534
|
...subscriptionArgs(options.subscription),
|
|
535
|
+
"--no-wait", "--query", DEPLOYMENT_QUERY, "-o", "json",
|
|
407
536
|
],
|
|
408
537
|
kind: "migration",
|
|
409
538
|
expectedState,
|
|
@@ -414,11 +543,11 @@ export async function planProvisionOperation(
|
|
|
414
543
|
}
|
|
415
544
|
}
|
|
416
545
|
}
|
|
417
|
-
} else if (options.whatIf || options.adoptExisting || options.reconcile) {
|
|
546
|
+
} else if (options.whatIf || options.adoptExisting || options.reconcile || options.resumeDeployment) {
|
|
418
547
|
const detail = azCli.available ? "" : ` ${azCliUnavailableWarning(azCli)}`;
|
|
419
548
|
throw new MachineCommandError(
|
|
420
549
|
!azCli.available && azCli.reason === "not-runnable" ? "az-cli-not-runnable" : "az-cli-not-found",
|
|
421
|
-
`Azure CLI is required for --what-if, --adopt-existing, and --
|
|
550
|
+
`Azure CLI is required for --what-if, --adopt-existing, --reconcile, and --resume-deployment.${detail}`,
|
|
422
551
|
undefined,
|
|
423
552
|
"blocked"
|
|
424
553
|
);
|
|
@@ -453,6 +582,8 @@ export async function planProvisionOperation(
|
|
|
453
582
|
mode,
|
|
454
583
|
remoteState,
|
|
455
584
|
pendingMigrations: pendingMigrations.map((migration) => ({ version: migration.version, checksum: migration.checksum })),
|
|
585
|
+
recoveryDeployment,
|
|
586
|
+
nonce: crypto.randomUUID(),
|
|
456
587
|
baselineSnapshotChecksum,
|
|
457
588
|
fingerprints,
|
|
458
589
|
})
|
|
@@ -483,6 +614,7 @@ export async function planProvisionOperation(
|
|
|
483
614
|
requiresApproval: true,
|
|
484
615
|
fingerprints,
|
|
485
616
|
...(baselineSnapshotChecksum ? { baselineSnapshotChecksum } : {}),
|
|
617
|
+
...(recoveryDeployment ? { recoveryDeployment } : {}),
|
|
486
618
|
};
|
|
487
619
|
|
|
488
620
|
if (planOptions.persist !== false) savePlanState(plan, projectRoot);
|
|
@@ -492,67 +624,221 @@ export async function planProvisionOperation(
|
|
|
492
624
|
export interface ApplyProvisionOptions {
|
|
493
625
|
planId: string;
|
|
494
626
|
approve?: boolean;
|
|
627
|
+
waitSeconds?: number;
|
|
495
628
|
}
|
|
496
629
|
|
|
497
630
|
export interface ApplyProvisionResult {
|
|
631
|
+
status: MachineOperationStatus;
|
|
632
|
+
outcome: "succeeded" | "in-progress" | "unknown" | "partial" | "failed";
|
|
633
|
+
runId: string;
|
|
498
634
|
planId: string;
|
|
499
635
|
resourceGroup: string;
|
|
500
636
|
location: string;
|
|
501
637
|
swaLocation: string;
|
|
502
638
|
executedCommands: string[];
|
|
503
|
-
deploymentOutputs?: Record<string, unknown>;
|
|
504
639
|
warnings: string[];
|
|
640
|
+
diagnostics: AzureCliDiagnostic[];
|
|
641
|
+
deployment?: DeploymentObservation;
|
|
642
|
+
nextActions: MachineNextAction[];
|
|
643
|
+
interrupted?: NodeJS.Signals;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
export interface ProvisionRun {
|
|
647
|
+
schemaVersion: 1;
|
|
648
|
+
runId: string;
|
|
649
|
+
projectRoot: string;
|
|
650
|
+
plan: ProvisionPlanData;
|
|
651
|
+
cursor: number;
|
|
652
|
+
attemptedIndex?: number;
|
|
653
|
+
deployment?: DeploymentReference;
|
|
654
|
+
observation?: DeploymentObservation;
|
|
655
|
+
outcome: ApplyProvisionResult["outcome"];
|
|
656
|
+
executedCommands: string[];
|
|
657
|
+
warnings: string[];
|
|
658
|
+
diagnostics: AzureCliDiagnostic[];
|
|
659
|
+
updatedAt: string;
|
|
505
660
|
}
|
|
506
661
|
|
|
507
662
|
function statesEqual(left: MigrationState | null | undefined, right: MigrationState | null | undefined): boolean {
|
|
508
663
|
return left?.version === right?.version && left?.checksum === right?.checksum;
|
|
509
664
|
}
|
|
510
665
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
return
|
|
666
|
+
const MACHINE_RETRY_HINT = "Re-run plan provision.";
|
|
667
|
+
export const INTERACTIVE_PROVISION_RETRY_HINT = 'Run "swallowkit provision" again to review the current state.';
|
|
668
|
+
|
|
669
|
+
export function provisionNextActions(runId: string): MachineNextAction[] {
|
|
670
|
+
return [
|
|
671
|
+
{ command: `swallowkit machine inspect provision --plan ${runId}`, description: "Read the original Azure deployment state without creating, cancelling, deleting, tagging, or updating the ledger." },
|
|
672
|
+
{ 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." },
|
|
673
|
+
];
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function resultForRun(run: ProvisionRun, outcome = run.outcome, interrupted?: NodeJS.Signals): ApplyProvisionResult {
|
|
677
|
+
run.warnings = run.warnings.slice(-20);
|
|
678
|
+
run.diagnostics = run.diagnostics.slice(-20);
|
|
679
|
+
return {
|
|
680
|
+
status: outcome === "succeeded" ? "complete" : outcome === "in-progress" ? "in-progress" : outcome === "failed" ? "failed" : "blocked",
|
|
681
|
+
outcome, runId: run.runId, planId: run.plan.planId,
|
|
682
|
+
resourceGroup: run.plan.resourceGroup, location: run.plan.location, swaLocation: run.plan.swaLocation,
|
|
683
|
+
executedCommands: run.executedCommands, warnings: run.warnings, diagnostics: run.diagnostics,
|
|
684
|
+
...(run.observation ? { deployment: run.observation } : {}),
|
|
685
|
+
nextActions: outcome === "succeeded"
|
|
686
|
+
? [{ command: "swallowkit machine inspect infra", description: "Inspect local infrastructure assets after deployment and migration bookkeeping completed." }]
|
|
687
|
+
: provisionNextActions(run.runId),
|
|
688
|
+
...(interrupted ? { interrupted } : {}),
|
|
689
|
+
};
|
|
516
690
|
}
|
|
517
691
|
|
|
518
|
-
function
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
if (current.group.exists) {
|
|
523
|
-
throw new MachineCommandError("stale-azure-state", `The resource group was created after the preview was computed. ${retryHint}`, undefined, "blocked");
|
|
524
|
-
}
|
|
525
|
-
return;
|
|
692
|
+
function readRun(runId: string, projectRoot: string): ProvisionRun | null {
|
|
693
|
+
let run: ProvisionRun | null;
|
|
694
|
+
try { run = loadProvisionRun<ProvisionRun>(runId, projectRoot); } catch {
|
|
695
|
+
throw new MachineCommandError("invalid-provision-checkpoint", "The execution checkpoint cannot be read. Do not re-submit Bicep; inspect the original deployment first.", { runId }, "blocked");
|
|
526
696
|
}
|
|
527
|
-
if (
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
);
|
|
697
|
+
if (run && (run.schemaVersion !== 1 || run.runId !== runId || run.plan?.planId !== runId
|
|
698
|
+
|| run.projectRoot !== path.resolve(projectRoot) || !Number.isInteger(run.cursor)
|
|
699
|
+
|| !Array.isArray(run.plan.commandSpecs) || !run.plan.fingerprints
|
|
700
|
+
|| !Array.isArray(run.warnings) || !Array.isArray(run.diagnostics) || !Array.isArray(run.executedCommands)
|
|
701
|
+
|| !["succeeded", "in-progress", "unknown", "partial", "failed"].includes(run.outcome)
|
|
702
|
+
|| run.cursor < 0 || run.cursor > run.plan.commandSpecs.length)) {
|
|
703
|
+
throw new MachineCommandError("invalid-provision-checkpoint", "The execution checkpoint is invalid or belongs to another checkout. Nothing will be re-submitted.", { runId }, "blocked");
|
|
534
704
|
}
|
|
705
|
+
return run;
|
|
535
706
|
}
|
|
536
707
|
|
|
537
|
-
function
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
708
|
+
function readRuns(projectRoot: string): ProvisionRun[] {
|
|
709
|
+
try {
|
|
710
|
+
return listProvisionRuns<ProvisionRun>(projectRoot).map(record => {
|
|
711
|
+
if (!record || typeof record.runId !== "string") throw new Error("Invalid checkpoint identity.");
|
|
712
|
+
const run = readRun(record.runId, projectRoot);
|
|
713
|
+
if (!run) throw new Error("Missing execution checkpoint.");
|
|
714
|
+
return run;
|
|
715
|
+
});
|
|
716
|
+
} catch (error) {
|
|
717
|
+
if (error instanceof MachineCommandError) throw error;
|
|
718
|
+
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");
|
|
546
719
|
}
|
|
547
720
|
}
|
|
548
721
|
|
|
549
|
-
|
|
550
|
-
|
|
722
|
+
function validateWaitSeconds(value = DEFAULT_PROVISION_WAIT_SECONDS): number {
|
|
723
|
+
if (!Number.isInteger(value) || value < 0 || value > MAX_PROVISION_WAIT_SECONDS) {
|
|
724
|
+
throw new MachineCommandError("invalid-arguments", `--wait-seconds must be an integer from 0 to ${MAX_PROVISION_WAIT_SECONDS}.`, undefined, "blocked");
|
|
725
|
+
}
|
|
726
|
+
return value;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function assertLocalFiles(plan: ProvisionPlanData, projectRoot: string, retryHint: string, allowRecordedManifest = false): void {
|
|
730
|
+
const changedFiles = Object.entries(plan.fingerprints).filter(([relativePath, expectedHash]) => {
|
|
731
|
+
const file = path.join(projectRoot, relativePath);
|
|
732
|
+
if (allowRecordedManifest && relativePath === MIGRATION_MANIFEST_PATH && plan.manifestAfterApply && fs.existsSync(file)
|
|
733
|
+
&& fs.readFileSync(file, "utf-8") === `${JSON.stringify(plan.manifestAfterApply, null, 2)}\n`) return false;
|
|
734
|
+
return hashFileIfExists(file) !== expectedHash;
|
|
735
|
+
}).map(([file]) => file);
|
|
736
|
+
if (changedFiles.length || (plan.baselineSnapshotChecksum
|
|
737
|
+
&& computeBaselineChecksum(collectLegacyBaselineFiles(projectRoot)) !== plan.baselineSnapshotChecksum)) {
|
|
738
|
+
throw new MachineCommandError("stale-plan", `${changedFiles.join(", ") || "Baseline infrastructure files"} changed after the preview was computed. ${retryHint}`, { planId: plan.planId, changedFiles }, "blocked");
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
async function readMigrationState(plan: ProvisionPlanData, signal?: AbortSignal, secretValues: readonly string[] = []): Promise<{ group: RemoteGroupState; state: MigrationState | null }> {
|
|
743
|
+
const exists = await runAzCliAsync(["group", "exists", "--name", plan.resourceGroup, ...subscriptionArgs(plan.subscription)], { timeout: 30_000, signal });
|
|
744
|
+
if (exists.status !== 0 || exists.failureKind || !/^(true|false)$/.test(exists.stdout.trim())) {
|
|
745
|
+
throw azQueryError("azure-state-query-failed", "Could not query resource group", exists, secretValues);
|
|
746
|
+
}
|
|
747
|
+
if (exists.stdout.trim() === "false") return { group: { exists: false, tags: {} }, state: null };
|
|
748
|
+
const show = await runAzCliAsync(["group", "show", "--name", plan.resourceGroup, ...subscriptionArgs(plan.subscription), "--query", "{id:id,tags:tags}", "-o", "json"], { timeout: 30_000, signal });
|
|
749
|
+
if (show.status !== 0 || show.failureKind || show.stdoutTruncated) throw azQueryError("azure-state-query-failed", "Could not read resource group tags", show, secretValues);
|
|
750
|
+
let parsed: { id?: unknown; tags?: Record<string, string> };
|
|
751
|
+
try { parsed = JSON.parse(show.stdout); } catch { throw azQueryError("azure-state-query-failed", "Invalid resource group response", show, secretValues); }
|
|
752
|
+
const expectedId = `/subscriptions/${plan.subscription}/resourceGroups/${plan.resourceGroup}`;
|
|
753
|
+
if (typeof parsed.id !== "string" || parsed.id.toLowerCase() !== expectedId.toLowerCase()) {
|
|
754
|
+
throw new MachineCommandError("azure-resource-group-mismatch", "Azure returned a different resource group/subscription. Nothing will be updated.", undefined, "blocked");
|
|
755
|
+
}
|
|
756
|
+
const group = { exists: true, id: parsed.id, tags: parsed.tags ?? {} };
|
|
757
|
+
const tag = plan.migrationTagKey ? group.tags[plan.migrationTagKey] : undefined;
|
|
758
|
+
return { group, state: tag ? decodeMigrationState(tag) : null };
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function desiredTagState(command: ProvisionCommand, key?: string): MigrationState {
|
|
762
|
+
const value = command.args.find((arg) => arg.startsWith(`${key}=`) || arg.startsWith(`tags.${key}=`));
|
|
763
|
+
if (!value) throw new MachineCommandError("incompatible-plan", "The reviewed migration tag is missing.", undefined, "blocked");
|
|
764
|
+
return decodeMigrationState(value.slice(value.indexOf("=") + 1));
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function provisionSecretValues(plan: Pick<ProvisionPlanData, "fingerprints">, projectRoot: string): string[] {
|
|
768
|
+
const files = Object.keys(plan.fingerprints);
|
|
769
|
+
const secureBicep = files.filter(file => file.endsWith(".bicep")).some(file => /@secure\s*\(/.test(fs.readFileSync(path.join(projectRoot, file), "utf-8")));
|
|
770
|
+
const values: string[] = [];
|
|
771
|
+
const collect = (value: unknown): void => {
|
|
772
|
+
if (typeof value === "string" && value) values.push(value);
|
|
773
|
+
else if (Array.isArray(value)) value.forEach(collect);
|
|
774
|
+
else if (value && typeof value === "object") Object.values(value).forEach(collect);
|
|
775
|
+
};
|
|
776
|
+
for (const file of files.filter(file => file.endsWith(".json") && file !== MIGRATION_MANIFEST_PATH)) {
|
|
777
|
+
let parsed: { parameters?: Record<string, { value?: unknown }> };
|
|
778
|
+
try { parsed = JSON.parse(fs.readFileSync(path.join(projectRoot, file), "utf-8")); } catch {
|
|
779
|
+
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");
|
|
780
|
+
}
|
|
781
|
+
for (const [name, parameter] of Object.entries(parsed.parameters ?? {})) {
|
|
782
|
+
if (secureBicep || /secret|password|token|key|connection.?string/i.test(name)) collect(parameter?.value);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
return [...new Set(values)].sort((left, right) => right.length - left.length);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function rememberDiagnostic(run: ProvisionRun, result: AzCliResult, phase: string, secretValues: readonly string[]): void {
|
|
789
|
+
run.diagnostics.push(azureCliDiagnostic(result, phase, secretValues));
|
|
790
|
+
run.diagnostics = run.diagnostics.slice(-20);
|
|
791
|
+
if (result.stderr.trim()) {
|
|
792
|
+
run.warnings.push(result.stderrTruncated ? "Azure CLI stderr exceeded the capture limit; output omitted." : redactAzureText(result.stderr, secretValues).slice(0, 4_000));
|
|
793
|
+
run.warnings = run.warnings.slice(-20);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
export async function inspectProvisionOperation(options: { planId: string; waitSeconds?: number }): Promise<ApplyProvisionResult> {
|
|
798
|
+
const run = readRun(options.planId, process.cwd());
|
|
799
|
+
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");
|
|
800
|
+
const waitSeconds = validateWaitSeconds(options.waitSeconds ?? 0);
|
|
801
|
+
if (run.deployment) {
|
|
802
|
+
let secretValues: string[] = [];
|
|
803
|
+
let omitOutput = false;
|
|
804
|
+
try {
|
|
805
|
+
assertLocalFiles(run.plan, process.cwd(), "Reviewed files no longer match.", true);
|
|
806
|
+
secretValues = provisionSecretValues(run.plan, process.cwd());
|
|
807
|
+
} catch {
|
|
808
|
+
omitOutput = true;
|
|
809
|
+
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.");
|
|
810
|
+
}
|
|
811
|
+
run.observation = await waitForAzureDeployment(run.deployment, { deadline: Date.now() + waitSeconds * 1_000, secretValues });
|
|
812
|
+
if (omitOutput) {
|
|
813
|
+
run.observation.errors = [];
|
|
814
|
+
run.observation.diagnostic = { ...run.observation.diagnostic, stdout: "[output omitted: reviewed parameters unavailable]", stderr: "", outputTruncated: true };
|
|
815
|
+
}
|
|
816
|
+
run.diagnostics = [...run.diagnostics, run.observation.diagnostic].slice(-20);
|
|
817
|
+
let outcome: ApplyProvisionResult["outcome"] = run.observation.state === "Failed" || run.observation.state === "Canceled" ? "failed"
|
|
818
|
+
: run.observation.state === "Succeeded" ? run.outcome === "succeeded" ? "succeeded" : "partial"
|
|
819
|
+
: run.observation.state === "unknown" ? "unknown" : "in-progress";
|
|
820
|
+
if (outcome === "succeeded") {
|
|
821
|
+
try {
|
|
822
|
+
assertLocalFiles(run.plan, process.cwd(), "The original reviewed files no longer match.", true);
|
|
823
|
+
const tag = [...run.plan.commandSpecs].reverse().find(command => command.kind === "tag");
|
|
824
|
+
if (tag && !statesEqual((await readMigrationState(run.plan, undefined, secretValues)).state, desiredTagState(tag, run.plan.migrationTagKey))) {
|
|
825
|
+
outcome = "partial";
|
|
826
|
+
run.warnings.push("Azure Succeeded, but the completed run's migration tag no longer matches. No bookkeeping or deployment write was performed.");
|
|
827
|
+
}
|
|
828
|
+
} catch {
|
|
829
|
+
outcome = "partial";
|
|
830
|
+
run.warnings.push("Azure Succeeded, but current local files or migration bookkeeping could not be verified. No writes were performed.");
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
return resultForRun(run, outcome);
|
|
834
|
+
}
|
|
835
|
+
return resultForRun(run);
|
|
836
|
+
}
|
|
551
837
|
|
|
552
838
|
export async function applyProvisionOperation(options: ApplyProvisionOptions): Promise<ApplyProvisionResult> {
|
|
553
839
|
const projectRoot = process.cwd();
|
|
554
|
-
|
|
555
|
-
const plan = loadPlanState<ProvisionPlanData>(options.planId, projectRoot);
|
|
840
|
+
const run = /^[a-f0-9]{12}$/.test(options.planId) ? readRun(options.planId, projectRoot) : null;
|
|
841
|
+
const plan = run?.plan ?? loadPlanState<ProvisionPlanData>(options.planId, projectRoot);
|
|
556
842
|
if (!plan) {
|
|
557
843
|
throw new MachineCommandError(
|
|
558
844
|
"plan-not-found",
|
|
@@ -562,8 +848,12 @@ export async function applyProvisionOperation(options: ApplyProvisionOptions): P
|
|
|
562
848
|
);
|
|
563
849
|
}
|
|
564
850
|
|
|
565
|
-
const result = await executeProvisionPlan(plan, { approve: options.approve, retryHint: MACHINE_RETRY_HINT, projectRoot });
|
|
566
|
-
|
|
851
|
+
const result = await executeProvisionPlan(plan, { approve: options.approve, waitSeconds: options.waitSeconds, retryHint: MACHINE_RETRY_HINT, projectRoot });
|
|
852
|
+
if (result.status === "complete") {
|
|
853
|
+
try { deletePlanState(plan.planId, projectRoot); } catch {
|
|
854
|
+
result.warnings.push("Azure and migration bookkeeping completed, but the old preview could not be removed. The completed execution checkpoint prevents replay.");
|
|
855
|
+
}
|
|
856
|
+
}
|
|
567
857
|
return result;
|
|
568
858
|
}
|
|
569
859
|
|
|
@@ -573,43 +863,18 @@ export async function applyProvisionOperation(options: ApplyProvisionOptions): P
|
|
|
573
863
|
*/
|
|
574
864
|
export async function executeProvisionPlan(
|
|
575
865
|
plan: ProvisionPlanData,
|
|
576
|
-
options: { approve?: boolean; retryHint?: string; projectRoot?: string } = {}
|
|
866
|
+
options: { approve?: boolean; retryHint?: string; projectRoot?: string; waitSeconds?: number; signal?: AbortSignal; pollIntervalMs?: number } = {}
|
|
577
867
|
): Promise<ApplyProvisionResult> {
|
|
578
868
|
const projectRoot = options.projectRoot ?? process.cwd();
|
|
579
869
|
const retryHint = options.retryHint ?? MACHINE_RETRY_HINT;
|
|
580
|
-
|
|
581
|
-
// Bicep が plan 後に変更されていないか確認
|
|
582
|
-
const changedFiles: string[] = [];
|
|
583
|
-
for (const [relativePath, expectedHash] of Object.entries(plan.fingerprints)) {
|
|
584
|
-
const currentHash = hashFileIfExists(path.join(projectRoot, relativePath));
|
|
585
|
-
if (currentHash !== expectedHash) changedFiles.push(relativePath);
|
|
586
|
-
}
|
|
587
|
-
if (changedFiles.length > 0) {
|
|
588
|
-
throw new MachineCommandError(
|
|
589
|
-
"stale-plan",
|
|
590
|
-
`${changedFiles.join(", ")} changed after the preview was computed. ${retryHint}`,
|
|
591
|
-
{ planId: plan.planId, changedFiles },
|
|
592
|
-
"blocked"
|
|
593
|
-
);
|
|
594
|
-
}
|
|
595
|
-
if (plan.baselineSnapshotChecksum) {
|
|
596
|
-
const currentBaselineChecksum = computeBaselineChecksum(collectLegacyBaselineFiles(projectRoot));
|
|
597
|
-
if (currentBaselineChecksum !== plan.baselineSnapshotChecksum) {
|
|
598
|
-
throw new MachineCommandError(
|
|
599
|
-
"stale-plan",
|
|
600
|
-
`Baseline infrastructure files changed after the preview was computed. ${retryHint}`,
|
|
601
|
-
{ planId: plan.planId },
|
|
602
|
-
"blocked"
|
|
603
|
-
);
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
|
|
870
|
+
const waitSeconds = validateWaitSeconds(options.waitSeconds);
|
|
607
871
|
if (!options.approve) {
|
|
608
872
|
throw new MachineCommandError(
|
|
609
873
|
"approval-required",
|
|
610
874
|
"Provisioning creates billable Azure resources and always requires human approval. Review plan.commands and re-run with --approve.",
|
|
611
875
|
{ planId: plan.planId, commands: plan.commands },
|
|
612
|
-
"requires-human"
|
|
876
|
+
"requires-human",
|
|
877
|
+
[{ command: `swallowkit machine apply provision --plan ${plan.planId} --approve`, description: "Review the commands and obtain explicit human consent before applying or resuming." }]
|
|
613
878
|
);
|
|
614
879
|
}
|
|
615
880
|
if (!plan.mode || !plan.commandSpecs) {
|
|
@@ -627,62 +892,249 @@ export async function executeProvisionPlan(
|
|
|
627
892
|
if (plan.mode === "adoption-required") {
|
|
628
893
|
throw new MachineCommandError(
|
|
629
894
|
"adoption-required",
|
|
630
|
-
'This
|
|
895
|
+
'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.',
|
|
631
896
|
{ planId: plan.planId },
|
|
632
897
|
"requires-human"
|
|
633
898
|
);
|
|
634
899
|
}
|
|
635
|
-
|
|
900
|
+
let run = readRun(plan.planId, projectRoot);
|
|
901
|
+
const resuming = run !== null;
|
|
902
|
+
if (run) plan = run.plan;
|
|
903
|
+
try {
|
|
904
|
+
assertLocalFiles(plan, projectRoot, run ? "Restore the original reviewed files before resuming this checkpoint; do not resubmit Bicep." : retryHint, run?.cursor === plan.commandSpecs.length);
|
|
905
|
+
} catch (error) {
|
|
906
|
+
if (run && error instanceof MachineCommandError) {
|
|
907
|
+
throw new MachineCommandError(error.code, error.message, { ...resultForRun(run), cause: error.details }, error.status, provisionNextActions(run.runId));
|
|
908
|
+
}
|
|
909
|
+
throw error;
|
|
910
|
+
}
|
|
911
|
+
const secretValues = provisionSecretValues(plan, projectRoot);
|
|
912
|
+
if (!plan.subscription || plan.commandSpecs.some((command) => (command.kind === "deployment" || command.kind === "migration")
|
|
913
|
+
&& !plan.recoveryDeployment && !command.args.includes("--no-wait"))) {
|
|
914
|
+
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");
|
|
915
|
+
}
|
|
636
916
|
const azCli = checkAzCliAvailability();
|
|
637
917
|
if (!azCli.available) {
|
|
638
918
|
throw new MachineCommandError(azCli.reason === "not-found" ? "az-cli-not-found" : "az-cli-not-runnable", azCliUnavailableWarning(azCli), undefined, "blocked");
|
|
639
919
|
}
|
|
640
920
|
|
|
641
|
-
const
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
921
|
+
const targetKey = crypto.createHash("sha256").update(`${plan.subscription}/${plan.resourceGroup.toLowerCase()}`).digest("hex");
|
|
922
|
+
let unlock: () => void;
|
|
923
|
+
try { unlock = lockProvisionTarget(targetKey, projectRoot); } catch (error) {
|
|
924
|
+
throw new MachineCommandError("provision-target-locked", error instanceof Error ? error.message : "Provision target is locked.", { runId: plan.planId }, "blocked", provisionNextActions(plan.planId));
|
|
925
|
+
}
|
|
926
|
+
const controller = new AbortController();
|
|
927
|
+
let interrupted: NodeJS.Signals | undefined;
|
|
928
|
+
const interrupt = (signal: NodeJS.Signals) => {
|
|
929
|
+
interrupted = signal;
|
|
930
|
+
controller.abort();
|
|
931
|
+
};
|
|
932
|
+
const sigint = () => interrupt("SIGINT");
|
|
933
|
+
const sigterm = () => interrupt("SIGTERM");
|
|
934
|
+
const externalAbort = () => controller.abort();
|
|
935
|
+
process.on("SIGINT", sigint);
|
|
936
|
+
process.on("SIGTERM", sigterm);
|
|
937
|
+
options.signal?.addEventListener("abort", externalAbort, { once: true });
|
|
938
|
+
if (options.signal?.aborted) controller.abort();
|
|
939
|
+
const signal = controller.signal;
|
|
940
|
+
const deadline = Date.now() + waitSeconds * 1_000;
|
|
941
|
+
const persist = () => {
|
|
942
|
+
if (!run) return;
|
|
943
|
+
run.warnings = run.warnings.slice(-20);
|
|
944
|
+
run.diagnostics = run.diagnostics.slice(-20);
|
|
945
|
+
run.updatedAt = new Date().toISOString();
|
|
946
|
+
saveProvisionRun(run, projectRoot);
|
|
947
|
+
};
|
|
948
|
+
const pause = (outcome: ApplyProvisionResult["outcome"], warning: string) => {
|
|
949
|
+
run!.outcome = outcome;
|
|
950
|
+
run!.warnings.push(warning);
|
|
951
|
+
persist();
|
|
952
|
+
return resultForRun(run!, outcome, interrupted);
|
|
953
|
+
};
|
|
954
|
+
const observe = async () => {
|
|
955
|
+
const observation = await waitForAzureDeployment(run!.deployment!, {
|
|
956
|
+
deadline: signal.aborted ? Date.now() : deadline,
|
|
957
|
+
// A final short read after Ctrl+C must not inherit the aborted wait signal.
|
|
958
|
+
...(signal.aborted ? {} : { signal }),
|
|
959
|
+
...(signal.aborted ? { queryTimeoutMs: 5_000 } : {}),
|
|
960
|
+
pollIntervalMs: options.pollIntervalMs,
|
|
961
|
+
secretValues,
|
|
962
|
+
onObservation: (value) => {
|
|
963
|
+
run!.observation = value;
|
|
964
|
+
if (value.state !== "unknown") run!.deployment = value.reference;
|
|
965
|
+
persist();
|
|
966
|
+
},
|
|
967
|
+
});
|
|
968
|
+
run!.observation = observation;
|
|
969
|
+
run!.diagnostics.push(observation.diagnostic);
|
|
970
|
+
run!.diagnostics = run!.diagnostics.slice(-20);
|
|
971
|
+
return observation;
|
|
972
|
+
};
|
|
973
|
+
try {
|
|
974
|
+
if (!run) {
|
|
975
|
+
const unfinished = readRuns(projectRoot).find((other) =>
|
|
976
|
+
other.outcome !== "succeeded" && other.outcome !== "failed"
|
|
977
|
+
&& other.plan.resourceGroup === plan.resourceGroup && other.plan.subscription === plan.subscription);
|
|
978
|
+
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));
|
|
979
|
+
const current = await readMigrationState(plan, signal, secretValues);
|
|
980
|
+
if (plan.mode === "bootstrap" ? current.group.exists : !current.group.exists || !statesEqual(current.state, plan.remoteState)) {
|
|
981
|
+
throw new MachineCommandError("stale-azure-state", plan.mode === "bootstrap"
|
|
982
|
+
? `The resource group was created after the preview was computed. ${retryHint}`
|
|
983
|
+
: `Azure migration state changed after the preview was computed. ${retryHint}`, { expected: plan.remoteState, actual: current.state }, "blocked");
|
|
984
|
+
}
|
|
985
|
+
const checkpointPlan = { ...plan };
|
|
986
|
+
delete checkpointPlan.whatIfResult;
|
|
987
|
+
run = {
|
|
988
|
+
schemaVersion: 1, runId: plan.planId, projectRoot: path.resolve(projectRoot), plan: checkpointPlan,
|
|
989
|
+
cursor: 0, outcome: "in-progress", executedCommands: [], warnings: [...plan.warnings], diagnostics: [],
|
|
990
|
+
updatedAt: new Date().toISOString(),
|
|
991
|
+
};
|
|
992
|
+
persist();
|
|
650
993
|
}
|
|
651
|
-
|
|
652
|
-
|
|
994
|
+
for (; run.cursor < plan.commandSpecs.length;) {
|
|
995
|
+
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.");
|
|
996
|
+
assertLocalFiles(plan, projectRoot, "Restore the original reviewed files before resuming; do not resubmit Bicep.");
|
|
997
|
+
const command = plan.commandSpecs[run.cursor];
|
|
998
|
+
if (command.kind === "deployment" || command.kind === "migration") {
|
|
999
|
+
if (command.kind === "migration" && run.attemptedIndex !== run.cursor && run.observation?.state === "Succeeded" && Date.now() >= deadline) {
|
|
1000
|
+
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.");
|
|
1001
|
+
}
|
|
1002
|
+
if (run.attemptedIndex !== run.cursor) {
|
|
1003
|
+
let observationOnly = Boolean(plan.recoveryDeployment);
|
|
1004
|
+
if (plan.recoveryDeployment) {
|
|
1005
|
+
run.deployment = plan.recoveryDeployment;
|
|
1006
|
+
} else {
|
|
1007
|
+
const current = await readMigrationState(plan, signal, secretValues);
|
|
1008
|
+
if (!current.group.exists || !statesEqual(current.state, command.expectedState ?? plan.remoteState)) {
|
|
1009
|
+
throw new MachineCommandError("stale-azure-state", `Azure migration state changed before deployment. ${retryHint}`, { expected: command.expectedState ?? plan.remoteState, actual: current.state }, "blocked");
|
|
1010
|
+
}
|
|
1011
|
+
const name = command.args[command.args.indexOf("--name") + 1];
|
|
1012
|
+
const reference: DeploymentReference = { name, resourceGroup: plan.resourceGroup, subscription: plan.subscription! };
|
|
1013
|
+
const previous = await queryAzureDeployment(reference, { signal, secretValues });
|
|
1014
|
+
if (previous.state === "unknown" && previous.reason !== "not-found") {
|
|
1015
|
+
run.deployment = reference;
|
|
1016
|
+
run.observation = previous;
|
|
1017
|
+
return pause("unknown", "The deployment name could not be checked before submission. No create request was sent; inspect the original deployment before proceeding.");
|
|
1018
|
+
}
|
|
1019
|
+
if (previous.state !== "unknown" && (plan.mode !== "reconcile" || previous.state === "Running" || previous.state === "Accepted")) {
|
|
1020
|
+
run.deployment = previous.reference;
|
|
1021
|
+
run.observation = previous;
|
|
1022
|
+
if (!resuming || command.kind !== "deployment") {
|
|
1023
|
+
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.");
|
|
1024
|
+
}
|
|
1025
|
+
run.deployment = await verifyExistingBaseline({ ...plan, resumeDeployment: name },
|
|
1026
|
+
path.join(projectRoot, "infra", "main.bicep"), path.join(projectRoot, "infra", "main.parameters.json"));
|
|
1027
|
+
observationOnly = true;
|
|
1028
|
+
} else {
|
|
1029
|
+
run.deployment = {
|
|
1030
|
+
...reference,
|
|
1031
|
+
...(previous.state !== "unknown" ? { previousCorrelationId: previous.reference.correlationId } : {}),
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
// Persist intent before spawn: even a hard process termination must never replay this create.
|
|
1036
|
+
run.attemptedIndex = run.cursor;
|
|
1037
|
+
persist();
|
|
1038
|
+
if (!observationOnly) {
|
|
1039
|
+
const result = await runAzCliAsync(command.args, { timeout: 60_000, signal });
|
|
1040
|
+
run.executedCommands.push(displayCommand(command));
|
|
1041
|
+
rememberDiagnostic(run, result, command.kind === "migration" ? "migration-submit" : "baseline-submit", secretValues);
|
|
1042
|
+
persist();
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
const observation = await observe();
|
|
1046
|
+
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.");
|
|
1047
|
+
if (observation.state === "Failed" || observation.state === "Canceled") {
|
|
1048
|
+
const operations = await queryDeploymentOperationErrors(observation.reference, secretValues, signal);
|
|
1049
|
+
observation.errors.push(...operations.errors);
|
|
1050
|
+
run.diagnostics.push(operations.diagnostic);
|
|
1051
|
+
run.outcome = "failed";
|
|
1052
|
+
persist();
|
|
1053
|
+
throw new MachineCommandError("azure-deployment-failed",
|
|
1054
|
+
`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."}`,
|
|
1055
|
+
resultForRun(run), "failed", provisionNextActions(run.runId));
|
|
1056
|
+
}
|
|
1057
|
+
if (observation.state !== "Succeeded") {
|
|
1058
|
+
return pause(observation.state === "unknown" ? "unknown" : "in-progress",
|
|
1059
|
+
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.`
|
|
1060
|
+
: `Azure deployment is ${observation.state}. The bounded local wait ended; status/resume tracks the same deployment without submitting Bicep again.`);
|
|
1061
|
+
}
|
|
1062
|
+
} else if (command.kind === "tag") {
|
|
1063
|
+
if (run.deployment) {
|
|
1064
|
+
const observation = await queryAzureDeployment(run.deployment, { signal, secretValues });
|
|
1065
|
+
run.observation = observation;
|
|
1066
|
+
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.");
|
|
1067
|
+
}
|
|
1068
|
+
const current = await readMigrationState(plan, signal, secretValues);
|
|
1069
|
+
const desired = desiredTagState(command, plan.migrationTagKey);
|
|
1070
|
+
if (!current.group.exists) throw new MachineCommandError("stale-azure-state", "The resource group disappeared before tagging.", undefined, "blocked");
|
|
1071
|
+
if (!statesEqual(current.state, desired)) {
|
|
1072
|
+
if (!statesEqual(current.state, command.expectedState)) {
|
|
1073
|
+
throw new MachineCommandError("stale-azure-state", `Azure migration state changed before its tag could be updated. ${retryHint}`, { expected: command.expectedState, actual: current.state }, "blocked");
|
|
1074
|
+
}
|
|
1075
|
+
run.attemptedIndex = run.cursor;
|
|
1076
|
+
persist();
|
|
1077
|
+
const result = await runAzCliAsync(command.args, { timeout: 60_000, signal });
|
|
1078
|
+
run.executedCommands.push(displayCommand(command));
|
|
1079
|
+
rememberDiagnostic(run, result, "migration-tag", secretValues);
|
|
1080
|
+
if (result.status !== 0 || result.failureKind || signal.aborted) {
|
|
1081
|
+
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.");
|
|
1082
|
+
}
|
|
1083
|
+
const after = await readMigrationState(plan, signal, secretValues);
|
|
1084
|
+
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.");
|
|
1085
|
+
}
|
|
1086
|
+
} else {
|
|
1087
|
+
const current = await readMigrationState(plan, signal, secretValues);
|
|
1088
|
+
if (run.attemptedIndex === run.cursor) {
|
|
1089
|
+
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.");
|
|
1090
|
+
} else {
|
|
1091
|
+
if (current.group.exists) throw new MachineCommandError("stale-azure-state", `The resource group was created after the preview was computed. ${retryHint}`, undefined, "blocked");
|
|
1092
|
+
run.attemptedIndex = run.cursor;
|
|
1093
|
+
persist();
|
|
1094
|
+
const result = await runAzCliAsync(command.args, { timeout: 60_000, signal });
|
|
1095
|
+
run.executedCommands.push(displayCommand(command));
|
|
1096
|
+
rememberDiagnostic(run, result, "resource-group-create", secretValues);
|
|
1097
|
+
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.");
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
run.cursor++;
|
|
1101
|
+
delete run.attemptedIndex;
|
|
1102
|
+
persist();
|
|
653
1103
|
}
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
if (
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
`Command failed: ${commandText}\n${(result.stderr || result.stdout).trim().slice(0, 4000)}`,
|
|
661
|
-
{ executedCommands },
|
|
662
|
-
"failed"
|
|
663
|
-
);
|
|
1104
|
+
if (signal.aborted) return pause("partial", "Local provisioning was interrupted before ledger completion. Resume bookkeeping; no deployment will be submitted again.");
|
|
1105
|
+
assertLocalFiles(plan, projectRoot, "Restore the reviewed files before completing the local ledger.", true);
|
|
1106
|
+
const finalTag = [...plan.commandSpecs].reverse().find((command) => command.kind === "tag");
|
|
1107
|
+
if (finalTag) {
|
|
1108
|
+
const current = await readMigrationState(plan, signal, secretValues);
|
|
1109
|
+
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.");
|
|
664
1110
|
}
|
|
665
|
-
if (
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
1111
|
+
if (run.deployment) {
|
|
1112
|
+
run.observation = await queryAzureDeployment(run.deployment, { signal, secretValues });
|
|
1113
|
+
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.");
|
|
1114
|
+
}
|
|
1115
|
+
if (plan.manifestAfterApply && run.outcome !== "succeeded") replaceMigrationManifest(plan.manifestAfterApply, projectRoot);
|
|
1116
|
+
run.outcome = "succeeded";
|
|
1117
|
+
persist();
|
|
1118
|
+
return resultForRun(run);
|
|
1119
|
+
} catch (error) {
|
|
1120
|
+
if (!run) throw error;
|
|
1121
|
+
if (error instanceof MachineCommandError && error.code === "azure-deployment-failed") throw error;
|
|
1122
|
+
run.outcome = run.observation?.state === "Succeeded" ? "partial" : "unknown";
|
|
1123
|
+
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.`);
|
|
1124
|
+
try { persist(); } catch {
|
|
1125
|
+
run.warnings.push("The checkpoint could not be updated. Preserve the existing checkpoint; inspect the original Azure deployment before any further write.");
|
|
1126
|
+
}
|
|
1127
|
+
if (error instanceof MachineCommandError) {
|
|
1128
|
+
throw new MachineCommandError(error.code, error.message, { ...resultForRun(run), cause: error.details }, error.status ?? "blocked", provisionNextActions(run.runId));
|
|
1129
|
+
}
|
|
1130
|
+
return resultForRun(run, run.outcome, interrupted);
|
|
1131
|
+
} finally {
|
|
1132
|
+
process.removeListener("SIGINT", sigint);
|
|
1133
|
+
process.removeListener("SIGTERM", sigterm);
|
|
1134
|
+
options.signal?.removeEventListener("abort", externalAbort);
|
|
1135
|
+
try { unlock(); } catch {
|
|
1136
|
+
run?.warnings.push("The local execution lock could not be removed. Inspect its owner before retrying; Azure deployment/ledger results are unchanged.");
|
|
1137
|
+
console.warn("Provision lock cleanup failed; inspect the checkpoint before another apply.");
|
|
672
1138
|
}
|
|
673
1139
|
}
|
|
674
|
-
|
|
675
|
-
if (plan.manifestAfterApply) {
|
|
676
|
-
replaceMigrationManifest(plan.manifestAfterApply, projectRoot);
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
return {
|
|
680
|
-
planId: plan.planId,
|
|
681
|
-
resourceGroup: plan.resourceGroup,
|
|
682
|
-
location: plan.location,
|
|
683
|
-
swaLocation: plan.swaLocation,
|
|
684
|
-
executedCommands,
|
|
685
|
-
...(deploymentOutputs ? { deploymentOutputs } : {}),
|
|
686
|
-
warnings,
|
|
687
|
-
};
|
|
688
1140
|
}
|