swallowkit 2.0.0-alpha.6 → 2.0.0-alpha.8
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/provision.d.ts +2 -0
- package/dist/cli/commands/provision.d.ts.map +1 -1
- package/dist/cli/commands/provision.js +214 -109
- package/dist/cli/commands/provision.js.map +1 -1
- package/dist/cli/index.js +1 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/core/operations/provision-deployment.d.ts +7 -0
- package/dist/core/operations/provision-deployment.d.ts.map +1 -1
- package/dist/core/operations/provision-deployment.js +16 -3
- package/dist/core/operations/provision-deployment.js.map +1 -1
- package/dist/core/operations/provision-operations.d.ts +4 -0
- package/dist/core/operations/provision-operations.d.ts.map +1 -1
- package/dist/core/operations/provision-operations.js +8 -3
- package/dist/core/operations/provision-operations.js.map +1 -1
- package/dist/core/project/capabilities.js +3 -3
- package/dist/core/project/capabilities.js.map +1 -1
- package/dist/core/project/workflows.d.ts.map +1 -1
- package/dist/core/project/workflows.js +2 -0
- package/dist/core/project/workflows.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/agent-instructions.test.ts +2 -0
- package/src/__tests__/fixtures/azure-cli.cjs +10 -1
- package/src/__tests__/provision-runtime.test.ts +137 -2
- package/src/cli/commands/provision.ts +220 -108
- package/src/cli/index.ts +2 -2
- package/src/core/operations/provision-deployment.ts +17 -1
- package/src/core/operations/provision-operations.ts +17 -5
- package/src/core/project/agent-templates/AGENTS.md +4 -0
- package/src/core/project/agent-templates/CLAUDE.md +2 -0
- package/src/core/project/agent-templates/copilot-instructions.md +2 -0
- package/src/core/project/capabilities.ts +3 -3
- package/src/core/project/workflows.ts +2 -0
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
} from "../core/operations/provision-operations";
|
|
9
9
|
import * as migrations from "../core/project/migrations";
|
|
10
10
|
import { loadProvisionRun, saveProvisionRun } from "../core/project/state";
|
|
11
|
-
import { formatProvisionResult } from "../cli/commands/provision";
|
|
11
|
+
import { formatProvisionResult, formatProvisionError } from "../cli/commands/provision";
|
|
12
12
|
import { formatCliError, runCli } from "../cli";
|
|
13
13
|
import { runMachineCli } from "../machine";
|
|
14
14
|
import { buildSwallowKitToolDefinitions } from "../mcp";
|
|
@@ -120,11 +120,14 @@ interface FixtureState {
|
|
|
120
120
|
submitStderr?: string;
|
|
121
121
|
submitDelay?: number;
|
|
122
122
|
queryMode?: string;
|
|
123
|
+
transientQueryFailures?: number;
|
|
123
124
|
template: Record<string, unknown>;
|
|
124
125
|
remoteTemplate?: Record<string, unknown>;
|
|
125
126
|
parameters: Record<string, { value: unknown; type?: string }>;
|
|
126
127
|
error?: unknown;
|
|
127
128
|
operations?: unknown[];
|
|
129
|
+
outputs?: Record<string, string>;
|
|
130
|
+
outputQueryFails?: boolean;
|
|
128
131
|
}
|
|
129
132
|
|
|
130
133
|
describe("provision execution, diagnosis and recovery using an az subprocess fixture", () => {
|
|
@@ -226,6 +229,53 @@ describe("provision execution, diagnosis and recovery using an az subprocess fix
|
|
|
226
229
|
expect(read().deploymentCreates).toBe(1);
|
|
227
230
|
});
|
|
228
231
|
|
|
232
|
+
function advanceClock(stepMs: number) {
|
|
233
|
+
const realNow = Date.now.bind(Date);
|
|
234
|
+
let offset = 0;
|
|
235
|
+
jest.spyOn(Date, "now").mockImplementation(() => realNow() + (offset += stepMs));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
it("waits past the old bounded budget until Azure finishes when the human CLI asks for completion", async () => {
|
|
239
|
+
update({ states: ["Accepted", "Running", "Running", "Running", "Running", "Succeeded"] });
|
|
240
|
+
advanceClock(120_000);
|
|
241
|
+
const result = await executeProvisionPlan(await planProvisionOperation(options), { approve: true, waitUntilComplete: true, pollIntervalMs: 10 });
|
|
242
|
+
expect(result).toMatchObject({ status: "complete", outcome: "succeeded" });
|
|
243
|
+
expect(read()).toMatchObject({ deploymentCreates: 1, tagAttempts: 1 });
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("keeps the bounded default wait for machine/MCP apply", async () => {
|
|
247
|
+
update({ states: ["Accepted", "Running", "Running", "Running", "Running", "Succeeded"] });
|
|
248
|
+
const plan = await planProvisionOperation(options);
|
|
249
|
+
advanceClock(120_000);
|
|
250
|
+
const result = await executeProvisionPlan(plan, { approve: true, pollIntervalMs: 10 });
|
|
251
|
+
expect(result).toMatchObject({ status: "in-progress", outcome: "in-progress" });
|
|
252
|
+
expect(read()).toMatchObject({ deploymentCreates: 1, tagAttempts: 0 });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("reports an Azure terminal failure after a long unbounded wait", async () => {
|
|
256
|
+
update({ states: ["Running", "Running", "Failed"], error: { code: "Conflict", message: "fixture conflict" } });
|
|
257
|
+
advanceClock(200_000);
|
|
258
|
+
await expect(executeProvisionPlan(await planProvisionOperation(options), { approve: true, waitUntilComplete: true, pollIntervalMs: 10 }))
|
|
259
|
+
.rejects.toMatchObject({ code: "azure-deployment-failed", message: expect.stringContaining("Conflict: fixture conflict") });
|
|
260
|
+
expect(read()).toMatchObject({ deploymentCreates: 1, tagAttempts: 0 });
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("rides through transient state-read failures while waiting until completion", async () => {
|
|
264
|
+
update({ states: ["Running", "Succeeded"] });
|
|
265
|
+
const plan = await planProvisionOperation(options);
|
|
266
|
+
update({ transientQueryFailures: 3 });
|
|
267
|
+
const result = await executeProvisionPlan(plan, { approve: true, waitUntilComplete: true, pollIntervalMs: 10 });
|
|
268
|
+
expect(result.status).toBe("complete");
|
|
269
|
+
expect(read()).toMatchObject({ deploymentCreates: 1, tagAttempts: 1 });
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("does not poll forever when the submitted deployment never becomes visible", async () => {
|
|
273
|
+
update({ queryMode: "not-found" });
|
|
274
|
+
const result = await executeProvisionPlan(await planProvisionOperation(options), { approve: true, waitUntilComplete: true, pollIntervalMs: 1 });
|
|
275
|
+
expect(result).toMatchObject({ status: "blocked", outcome: "unknown" });
|
|
276
|
+
expect(read()).toMatchObject({ deploymentCreates: 1, tagAttempts: 0 });
|
|
277
|
+
}, 60_000);
|
|
278
|
+
|
|
229
279
|
it.each(["communication", "nonzero", "slow"])("distinguishes local %s from Azure Running", async (submitMode) => {
|
|
230
280
|
update({ submitMode, states: ["Running"] });
|
|
231
281
|
const original = azureCli.runAzCliAsync;
|
|
@@ -511,7 +561,7 @@ describe("provision execution, diagnosis and recovery using an az subprocess fix
|
|
|
511
561
|
expect(read().deploymentCreates).toBe(3);
|
|
512
562
|
expect(read().calls.filter(args => args[0] === "deployment" && args[2] === "create").map(args => args[args.indexOf("--name") + 1]))
|
|
513
563
|
.toEqual(expect.arrayContaining(["swallowkit-baseline", ...plan.pendingMigrations.map(migration => `swallowkit-migration-${migration.version}-${migration.checksum.slice(0, 12)}`)]));
|
|
514
|
-
});
|
|
564
|
+
}, 20000);
|
|
515
565
|
|
|
516
566
|
it("recovers an older untracked Running deployment using matching ARM/parameter evidence and no create", async () => {
|
|
517
567
|
update({ groupExists: true, states: ["Running"], deployments: { "swallowkit-baseline": { correlationId: "old-correlation", templateHash: "fixture-template-hash" } } });
|
|
@@ -559,4 +609,89 @@ describe("provision execution, diagnosis and recovery using an az subprocess fix
|
|
|
559
609
|
expect(runner).toHaveBeenCalledWith(["inspect", "provision", "--plan", runId, "--wait-seconds", "0"]);
|
|
560
610
|
expect(read()).toMatchObject({ deploymentCreates: 1, tagAttempts: 0 });
|
|
561
611
|
});
|
|
612
|
+
|
|
613
|
+
it("waits for a long normal provision to finish and then prints CI/CD settings", async () => {
|
|
614
|
+
update({ states: ["Running", "Succeeded"], outputs: { functionsAppName: "func-fixture", staticWebAppName: "swa-fixture" } });
|
|
615
|
+
advanceClock(400_000);
|
|
616
|
+
const logs: string[] = [];
|
|
617
|
+
jest.spyOn(console, "log").mockImplementation((text: unknown) => { logs.push(String(text)); });
|
|
618
|
+
await runCli(["node", "swallowkit", "provision", "-g", options.resourceGroup, "--location", options.location, "--swa-location", options.swaLocation]);
|
|
619
|
+
const output = logs.join("\n");
|
|
620
|
+
expect(output).toContain("Provisioning completed");
|
|
621
|
+
expect(output).toContain("AZURE_FUNCTIONAPP_NAME: func-fixture");
|
|
622
|
+
expect(output).not.toMatch(/still in progress|local wait ended|--wait-seconds/);
|
|
623
|
+
expect(read()).toMatchObject({ deploymentCreates: 1, tagAttempts: 1 });
|
|
624
|
+
}, 30_000);
|
|
625
|
+
|
|
626
|
+
it("treats normal provision invocation as consent without an extra prompt or machine instructions", async () => {
|
|
627
|
+
update({ outputs: { functionsAppName: "func-fixture", staticWebAppName: "swa-fixture", apiKey: "secret-not-for-terminal" } });
|
|
628
|
+
const logs: string[] = [];
|
|
629
|
+
jest.spyOn(console, "log").mockImplementation((text: unknown) => { logs.push(String(text)); });
|
|
630
|
+
await runCli(["node", "swallowkit", "provision", "-g", options.resourceGroup, "--location", options.location, "--swa-location", options.swaLocation]);
|
|
631
|
+
const output = logs.join("\n");
|
|
632
|
+
expect(output).toContain("Provisioning completed");
|
|
633
|
+
expect(output).toContain("AZURE_FUNCTIONAPP_NAME: func-fixture");
|
|
634
|
+
expect(output).toContain("AZURE_STATIC_WEB_APPS_API_TOKEN");
|
|
635
|
+
expect(output).not.toContain("secret-not-for-terminal");
|
|
636
|
+
expect(output).not.toMatch(/machine |--approve|Run these Azure commands|Plan /);
|
|
637
|
+
expect(read()).toMatchObject({ deploymentCreates: 1, tagAttempts: 1 });
|
|
638
|
+
expect(read().calls.some(args => args[0] === "staticwebapp" && args[1] === "secrets")).toBe(false);
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
it("keeps normal CLI terminal failure instructions human-only and does not suggest resuming as a repair", async () => {
|
|
642
|
+
update({ states: ["Failed"], error: { code: "NotFound", message: "Request url is invalid." } });
|
|
643
|
+
let failure: unknown;
|
|
644
|
+
try {
|
|
645
|
+
await runCli(["node", "swallowkit", "provision", "-g", options.resourceGroup, "--location", options.location, "--swa-location", options.swaLocation]);
|
|
646
|
+
} catch (error) { failure = error; }
|
|
647
|
+
const output = formatCliError(failure);
|
|
648
|
+
expect(output).toContain("NotFound: Request url is invalid.");
|
|
649
|
+
expect(output).toContain("swallowkit provision --status");
|
|
650
|
+
expect(output).not.toMatch(/machine |--plan|--approve|Resume:/);
|
|
651
|
+
expect(read()).toMatchObject({ deploymentCreates: 1, tagAttempts: 0 });
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it("shows human commands when existing execution state blocks a new provision", async () => {
|
|
655
|
+
update({ states: ["Running"] });
|
|
656
|
+
const running = await executeProvisionPlan(await planProvisionOperation(options), { approve: true, waitSeconds: 0 });
|
|
657
|
+
let failure: unknown;
|
|
658
|
+
try { await planProvisionOperation(options); } catch (error) { failure = error; }
|
|
659
|
+
const output = formatProvisionError(failure);
|
|
660
|
+
expect(output).toContain(`swallowkit provision --status ${running.runId}`);
|
|
661
|
+
expect(output).not.toMatch(/machine |--plan|--approve/);
|
|
662
|
+
expect(read().deploymentCreates).toBe(1);
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
it("does not redeploy or report deployment failure when resource outputs cannot be read after success", async () => {
|
|
666
|
+
update({ outputQueryFails: true });
|
|
667
|
+
const warnings: string[] = [];
|
|
668
|
+
jest.spyOn(console, "log").mockImplementation(() => {});
|
|
669
|
+
jest.spyOn(console, "warn").mockImplementation((text: unknown) => { warnings.push(String(text)); });
|
|
670
|
+
await runCli(["node", "swallowkit", "provision", "-g", options.resourceGroup, "--location", options.location, "--swa-location", options.swaLocation]);
|
|
671
|
+
expect(warnings.join("\n")).toContain("Deployment succeeded, but resource information could not be read");
|
|
672
|
+
expect(read()).toMatchObject({ deploymentCreates: 1, tagAttempts: 1 });
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
it("resumes a human-invoked run without an additional approval and never resubmits Bicep", async () => {
|
|
676
|
+
update({ states: ["Running"] });
|
|
677
|
+
jest.spyOn(console, "log").mockImplementation(() => {});
|
|
678
|
+
await runCli(["node", "swallowkit", "provision", "-g", options.resourceGroup, "--location", options.location, "--swa-location", options.swaLocation, "--wait-seconds", "0"]);
|
|
679
|
+
const file = fs.readdirSync(path.join(project, ".swallowkit", "state", "provision")).find(name => name.endsWith(".json"))!;
|
|
680
|
+
const runId = file.slice(0, -5);
|
|
681
|
+
update({ states: ["Succeeded"] });
|
|
682
|
+
await runCli(["node", "swallowkit", "provision", "--resume", runId]);
|
|
683
|
+
expect(read()).toMatchObject({ deploymentCreates: 1, tagAttempts: 1 });
|
|
684
|
+
expect(loadProvisionRun<{ outcome: string }>(runId)?.outcome).toBe("succeeded");
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
it("retains successful deployment when the output-reading helper throws", async () => {
|
|
688
|
+
const original = azureCli.runAzCliAsync;
|
|
689
|
+
jest.spyOn(azureCli, "runAzCliAsync").mockImplementation((args, runOptions) =>
|
|
690
|
+
args.some(arg => arg.includes("properties.outputs.")) ? Promise.reject(new Error("Fixture output transport failed")) : original(args, runOptions));
|
|
691
|
+
jest.spyOn(console, "log").mockImplementation(() => {});
|
|
692
|
+
const warning = jest.spyOn(console, "warn").mockImplementation(() => {});
|
|
693
|
+
await runCli(["node", "swallowkit", "provision", "-g", options.resourceGroup, "--location", options.location, "--swa-location", options.swaLocation]);
|
|
694
|
+
expect(warning).toHaveBeenCalledWith(expect.stringContaining("Deployment succeeded, but resource information could not be read"));
|
|
695
|
+
expect(read()).toMatchObject({ deploymentCreates: 1, tagAttempts: 1 });
|
|
696
|
+
});
|
|
562
697
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Command } from "commander";
|
|
1
|
+
import { Command, Option } from "commander";
|
|
2
2
|
import prompts from "prompts";
|
|
3
3
|
import { ensureSwallowKitProject } from "../../core/config";
|
|
4
4
|
import {
|
|
@@ -12,6 +12,88 @@ import {
|
|
|
12
12
|
} from "../../core/operations/provision-operations";
|
|
13
13
|
import { MachineCommandError } from "../../machine/errors";
|
|
14
14
|
import { formatAzureDiagnostic } from "../../utils/azure-diagnostics";
|
|
15
|
+
import { runAzCliAsync } from "../../utils/azure-cli";
|
|
16
|
+
|
|
17
|
+
function humanProvisionMessage(message: string): string {
|
|
18
|
+
return message
|
|
19
|
+
.replace(/swallowkit machine inspect provision --plan/g, "swallowkit provision --status")
|
|
20
|
+
.replace(/swallowkit machine apply provision --plan/g, "swallowkit provision --resume")
|
|
21
|
+
.replace(/plan provision/g, "swallowkit provision")
|
|
22
|
+
.replace(/\b[Pp]lan\b/g, "Execution")
|
|
23
|
+
.replace(/preview/g, "deployment preparation")
|
|
24
|
+
.replace(/after approval/g, "")
|
|
25
|
+
.replace(/--approve/g, "")
|
|
26
|
+
.replace(/ {2,}/g, " ");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function formatProvisionError(error: unknown): string {
|
|
30
|
+
if (!(error instanceof MachineCommandError)) return error instanceof Error ? error.message : String(error);
|
|
31
|
+
const message = error.code === "azure-deployment-failed" ? error.message : humanProvisionMessage(error.message);
|
|
32
|
+
const lines = [...new Set(message.split("\n"))];
|
|
33
|
+
const details = error.details as Partial<ApplyProvisionResult> | undefined;
|
|
34
|
+
if (details?.deployment) {
|
|
35
|
+
lines.push(`Deployment: ${details.deployment.reference.name}; state: ${details.deployment.state}`);
|
|
36
|
+
}
|
|
37
|
+
for (const diagnostic of details?.diagnostics ?? []) {
|
|
38
|
+
if (diagnostic.failureKind || diagnostic.outputTruncated) lines.push(formatAzureDiagnostic(diagnostic));
|
|
39
|
+
}
|
|
40
|
+
const runId = details?.runId;
|
|
41
|
+
if (runId) {
|
|
42
|
+
lines.push(`Check: swallowkit provision --status ${runId}`);
|
|
43
|
+
if (error.code !== "azure-deployment-failed") lines.push(`Resume: swallowkit provision --resume ${runId}`);
|
|
44
|
+
else lines.push("Azure reported a terminal failure. Resuming only observes that failed deployment; correct its cause before explicitly deploying again.");
|
|
45
|
+
} else {
|
|
46
|
+
for (const action of error.nextActions ?? []) {
|
|
47
|
+
const command = humanProvisionMessage(action.command);
|
|
48
|
+
if (!command.includes("machine ")) lines.push(command);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return lines.join("\n");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function printProvisionSetup(result: ApplyProvisionResult): Promise<void> {
|
|
55
|
+
if (!result.deployment || result.status !== "complete") return;
|
|
56
|
+
const reference = result.deployment.reference;
|
|
57
|
+
const fields = ["staticWebAppName", "staticWebAppUrl", "functionsAppName", "functionsAppUrl", "cosmosDbAccountName", "cosmosDatabaseName"] as const;
|
|
58
|
+
let outputs: Record<string, unknown>;
|
|
59
|
+
try {
|
|
60
|
+
const response = await runAzCliAsync(
|
|
61
|
+
[
|
|
62
|
+
"deployment",
|
|
63
|
+
"group",
|
|
64
|
+
"show",
|
|
65
|
+
"--name",
|
|
66
|
+
reference.name.startsWith("swallowkit-migration-") ? "swallowkit-baseline" : reference.name,
|
|
67
|
+
"--resource-group",
|
|
68
|
+
reference.resourceGroup,
|
|
69
|
+
"--subscription",
|
|
70
|
+
reference.subscription,
|
|
71
|
+
"--query",
|
|
72
|
+
`{${fields.map((field) => `${field}:properties.outputs.${field}.value`).join(",")}}`,
|
|
73
|
+
"-o",
|
|
74
|
+
"json",
|
|
75
|
+
],
|
|
76
|
+
{ timeout: 30_000 }
|
|
77
|
+
);
|
|
78
|
+
if (response.status !== 0 || response.failureKind || response.stdoutTruncated) throw new Error("Output query did not complete.");
|
|
79
|
+
const parsed: unknown = JSON.parse(response.stdout);
|
|
80
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Invalid resource output.");
|
|
81
|
+
outputs = parsed as Record<string, unknown>;
|
|
82
|
+
} catch {
|
|
83
|
+
console.warn(
|
|
84
|
+
"Deployment succeeded, but resource information could not be read. Inspect the deployment's named outputs in Azure Portal; do not redeploy just to retrieve them."
|
|
85
|
+
);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
console.log("\nResource information:");
|
|
89
|
+
for (const field of fields) {
|
|
90
|
+
if (typeof outputs[field] === "string") console.log(` ${field}: ${outputs[field]}`);
|
|
91
|
+
}
|
|
92
|
+
console.log("\nCI/CD settings:");
|
|
93
|
+
console.log(` AZURE_FUNCTIONAPP_NAME: ${typeof outputs.functionsAppName === "string" ? outputs.functionsAppName : "<functions-app-name>"}`);
|
|
94
|
+
console.log(" Retrieve AZURE_STATIC_WEB_APPS_API_TOKEN and AZURE_FUNCTIONAPP_PUBLISH_PROFILE privately from Azure Portal and save them in your CI/CD secrets.");
|
|
95
|
+
console.log(" Secret values are not printed or stored by SwallowKit. Trigger the first application deployment after configuring CI/CD.");
|
|
96
|
+
}
|
|
15
97
|
|
|
16
98
|
export function describeProvisionAction(plan: Pick<ProvisionPlanData, "mode" | "resourceGroup" | "location" | "pendingMigrations">): string {
|
|
17
99
|
switch (plan.mode) {
|
|
@@ -67,25 +149,31 @@ interface ProvisionCliOptions {
|
|
|
67
149
|
}
|
|
68
150
|
|
|
69
151
|
export function formatProvisionResult(result: ApplyProvisionResult): string {
|
|
70
|
-
const outcome =
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
152
|
+
const outcome =
|
|
153
|
+
result.outcome === "succeeded"
|
|
154
|
+
? "✅ Provisioning completed, including migration tags and the local ledger."
|
|
155
|
+
: result.outcome === "in-progress"
|
|
156
|
+
? "⏳ Azure deployment is still in progress. The local wait ended; this is not an Azure failure."
|
|
157
|
+
: result.outcome === "partial"
|
|
158
|
+
? "⚠️ Provisioning is partially complete. Finish migration bookkeeping from the checkpoint; do not redeploy the baseline."
|
|
159
|
+
: result.outcome === "failed"
|
|
160
|
+
? "❌ Azure deployment reached a failed/cancelled terminal state."
|
|
161
|
+
: "⚠️ Azure deployment result is unknown. A local az interruption does not prove an Azure failure.";
|
|
162
|
+
const lines = [outcome, `Resource group: ${result.resourceGroup}`];
|
|
76
163
|
if (result.deployment) {
|
|
77
164
|
const reference = result.deployment.reference;
|
|
78
165
|
lines.push(`Deployment: ${reference.name}; subscription: ${reference.subscription}; state: ${result.deployment.state}${reference.id ? `; ID: ${reference.id}` : ""}`);
|
|
79
166
|
for (const error of result.deployment.errors) lines.push(`${error.code}: ${error.message}`);
|
|
80
167
|
}
|
|
81
|
-
for (const warning of result.warnings) lines.push(`Warning: ${warning}`);
|
|
168
|
+
for (const warning of result.warnings) lines.push(`Warning: ${humanProvisionMessage(warning)}`);
|
|
82
169
|
for (const diagnostic of result.diagnostics.filter((entry) => entry.failureKind || entry.outputTruncated)) {
|
|
83
170
|
lines.push(formatAzureDiagnostic(diagnostic));
|
|
84
171
|
}
|
|
85
172
|
if (result.status !== "complete") {
|
|
86
|
-
lines.push(`Check
|
|
87
|
-
lines.push(`Resume
|
|
88
|
-
lines.push("Azure may continue after Ctrl+C or process termination. No Azure cancellation or deletion is performed.");
|
|
173
|
+
lines.push(`Check status: swallowkit provision --status ${result.runId}`);
|
|
174
|
+
if (result.outcome !== "failed") lines.push(`Resume and wait until completion: swallowkit provision --resume ${result.runId}`);
|
|
175
|
+
if (result.outcome !== "failed") lines.push("Azure may continue after Ctrl+C or process termination. No Azure cancellation or deletion is performed.");
|
|
176
|
+
else lines.push("Azure reported a terminal failure. Correct its cause before explicitly deploying again; resuming does not repair a failed deployment.");
|
|
89
177
|
}
|
|
90
178
|
return lines.join("\n");
|
|
91
179
|
}
|
|
@@ -119,106 +207,130 @@ async function resolveLocations(options: ProvisionCliOptions): Promise<{ locatio
|
|
|
119
207
|
return { location, swaLocation };
|
|
120
208
|
}
|
|
121
209
|
|
|
122
|
-
export
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
210
|
+
export function createProvisionCommand(): Command {
|
|
211
|
+
return new Command("provision")
|
|
212
|
+
.description("Deploy Azure infrastructure and show resource information and CI/CD setup")
|
|
213
|
+
.option("-g, --resource-group <name>", "Resource group name (required for deployment)")
|
|
214
|
+
.option("--subscription <id>", "Azure subscription ID")
|
|
215
|
+
.option("--location <region>", "Primary location for Functions and Cosmos DB")
|
|
216
|
+
.option("--swa-location <region>", "Static Web App location")
|
|
217
|
+
.option("--what-if", "Check Azure what-if before deployment", false)
|
|
218
|
+
.option("--adopt-existing", "Adopt an existing untagged resource group without deploying main.bicep", false)
|
|
219
|
+
.option("--baseline <version>", "Baseline version for adoption (currently 0)")
|
|
220
|
+
.option("--reconcile", "Explicitly redeploy infra/main.bicep", false)
|
|
221
|
+
.addOption(new Option("--approve", "Compatibility option; invoking provision already requests execution").hideHelp())
|
|
222
|
+
.option("--status <runId>", "Read an existing execution checkpoint and Azure deployment state without writes")
|
|
223
|
+
.option("--resume <runId>", "Continue the original execution without resubmitting attempted deployments")
|
|
224
|
+
.option("--resume-deployment <name>", "Recover an older untracked baseline deployment after verifying its template and parameters")
|
|
225
|
+
.addOption(new Option("--wait-seconds <seconds>", "Optional limit on waiting, 0-3600 seconds (default: wait until Azure finishes; status: 0)").hideHelp())
|
|
226
|
+
.action(async (options: ProvisionCliOptions) => {
|
|
227
|
+
try {
|
|
228
|
+
ensureSwallowKitProject("provision");
|
|
229
|
+
const waitSeconds = options.waitSeconds === undefined ? undefined : Number(options.waitSeconds);
|
|
230
|
+
if (
|
|
231
|
+
(options.resume && options.status) ||
|
|
232
|
+
((options.resume || options.status) &&
|
|
233
|
+
(options.resourceGroup ||
|
|
234
|
+
options.resumeDeployment ||
|
|
235
|
+
options.reconcile ||
|
|
236
|
+
options.adoptExisting ||
|
|
237
|
+
options.subscription ||
|
|
238
|
+
options.location ||
|
|
239
|
+
options.swaLocation ||
|
|
240
|
+
options.whatIf ||
|
|
241
|
+
options.baseline !== undefined))
|
|
242
|
+
) {
|
|
243
|
+
throw new MachineCommandError(
|
|
244
|
+
"invalid-arguments",
|
|
245
|
+
"Use --status or --resume with the checkpoint ID alone; its original Azure target and reviewed files are preserved.",
|
|
246
|
+
undefined,
|
|
247
|
+
"blocked"
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
if (options.status) {
|
|
251
|
+
const result = await inspectProvisionOperation({
|
|
252
|
+
planId: options.status,
|
|
253
|
+
waitSeconds,
|
|
254
|
+
});
|
|
255
|
+
console.log(formatProvisionResult(result));
|
|
256
|
+
if (result.status === "blocked" || result.status === "failed") process.exitCode = 1;
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (options.resume) {
|
|
260
|
+
const result = await applyProvisionOperation({
|
|
261
|
+
planId: options.resume,
|
|
262
|
+
approve: true,
|
|
263
|
+
waitSeconds,
|
|
264
|
+
waitUntilComplete: waitSeconds === undefined,
|
|
265
|
+
});
|
|
266
|
+
console.log(formatProvisionResult(result));
|
|
267
|
+
await printProvisionSetup(result);
|
|
268
|
+
if (result.status === "blocked" || result.status === "failed") process.exitCode = 1;
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (!options.resourceGroup) throw new MachineCommandError("invalid-arguments", "Provision requires --resource-group.", undefined, "blocked");
|
|
272
|
+
const locations = await resolveLocations(options);
|
|
273
|
+
const baseline = options.baseline === undefined ? undefined : Number(options.baseline);
|
|
274
|
+
// Preview in memory: the same Azure checks as the agent workflow, but no saved plan file.
|
|
275
|
+
const preview = await planProvisionOperation(
|
|
276
|
+
{
|
|
277
|
+
resourceGroup: options.resourceGroup,
|
|
278
|
+
subscription: options.subscription,
|
|
279
|
+
location: locations.location,
|
|
280
|
+
swaLocation: locations.swaLocation,
|
|
281
|
+
whatIf: options.whatIf,
|
|
282
|
+
adoptExisting: options.adoptExisting,
|
|
283
|
+
baseline,
|
|
284
|
+
reconcile: options.reconcile,
|
|
285
|
+
resumeDeployment: options.resumeDeployment,
|
|
286
|
+
},
|
|
287
|
+
{ persist: false }
|
|
288
|
+
);
|
|
189
289
|
|
|
190
|
-
|
|
191
|
-
throw new Error('The existing resource group has no migration tag. For an interrupted baseline use --resume-deployment <original-name> to verify/observe it without redeploying. Legacy adoption applies only to a legacy manifest; do not replace an existing manifest.');
|
|
192
|
-
}
|
|
290
|
+
for (const warning of preview.warnings) console.warn(`Warning: ${warning}`);
|
|
193
291
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
292
|
+
if (preview.mode === "offline") {
|
|
293
|
+
console.log(
|
|
294
|
+
[
|
|
295
|
+
"",
|
|
296
|
+
"❌ Azure CLI is unavailable, so the current state of Azure could not be checked. Nothing was changed in Azure.",
|
|
297
|
+
" 1. Make sure `az --version` works in this terminal (install: https://aka.ms/azure-cli).",
|
|
298
|
+
" 2. Sign in with `az login` (and `az account set --subscription <id>` if needed).",
|
|
299
|
+
" 3. Run this command again.",
|
|
300
|
+
].join("\n")
|
|
301
|
+
);
|
|
302
|
+
process.exitCode = 1;
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (preview.mode === "adoption-required") {
|
|
307
|
+
throw new Error(
|
|
308
|
+
"The existing resource group has no migration tag. For an interrupted baseline use --resume-deployment <original-name> to verify/observe it without redeploying. Legacy adoption applies only to a legacy manifest; do not replace an existing manifest."
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (preview.mode === "noop") {
|
|
313
|
+
console.log(`Resource group ${preview.resourceGroup} is already up to date. Nothing to do.`);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
198
316
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
317
|
+
console.log(`\n${describeProvisionAction(preview)}`);
|
|
318
|
+
console.log("Deploying Azure infrastructure. Azure resources may incur charges.");
|
|
319
|
+
console.log("Waiting for the Azure deployment to finish. This often takes 10-20 minutes; resource information and CI/CD settings are shown when it completes.");
|
|
202
320
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
321
|
+
const result = await executeProvisionPlan(preview, {
|
|
322
|
+
approve: true,
|
|
323
|
+
retryHint: INTERACTIVE_PROVISION_RETRY_HINT,
|
|
324
|
+
waitSeconds,
|
|
325
|
+
waitUntilComplete: waitSeconds === undefined,
|
|
326
|
+
});
|
|
327
|
+
console.log(formatProvisionResult(result));
|
|
328
|
+
await printProvisionSetup(result);
|
|
329
|
+
if (result.status === "blocked" || result.status === "failed") process.exitCode = 1;
|
|
330
|
+
} catch (error) {
|
|
331
|
+
throw new Error(formatProvisionError(error));
|
|
207
332
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
name: "approved",
|
|
211
|
-
message: "Run these Azure commands? This may create billable resources.",
|
|
212
|
-
initial: false,
|
|
213
|
-
});
|
|
214
|
-
approved = answer.approved === true;
|
|
215
|
-
}
|
|
216
|
-
if (!approved) {
|
|
217
|
-
console.log("Provision cancelled. Nothing was changed in Azure.");
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
333
|
+
});
|
|
334
|
+
}
|
|
220
335
|
|
|
221
|
-
|
|
222
|
-
console.log(formatProvisionResult(result));
|
|
223
|
-
if (result.status === "blocked" || result.status === "failed") process.exitCode = 1;
|
|
224
|
-
});
|
|
336
|
+
export const provisionCommand = createProvisionCommand();
|
package/src/cli/index.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { readFileSync } from "fs";
|
|
|
5
5
|
import * as path from "path";
|
|
6
6
|
import { Command } from "commander";
|
|
7
7
|
import { initCommand, devCommand, devSeedsCommand, scaffoldCommand, createModelCommand } from "./commands";
|
|
8
|
-
import {
|
|
8
|
+
import { createProvisionCommand } from "./commands/provision";
|
|
9
9
|
import { AzureCliDiagnostic, formatAzureDiagnostic } from "../utils/azure-diagnostics";
|
|
10
10
|
import { createMigrationCommand } from "./commands/create-migration";
|
|
11
11
|
import { migrationsCommand } from "./commands/migrations";
|
|
@@ -140,7 +140,7 @@ export function createProgram(devCommandOverride: Command = devCommand): Command
|
|
|
140
140
|
program.addCommand(devCommandOverride);
|
|
141
141
|
program.addCommand(devSeedsCommand);
|
|
142
142
|
|
|
143
|
-
program.addCommand(
|
|
143
|
+
program.addCommand(createProvisionCommand());
|
|
144
144
|
program.addCommand(migrationsCommand);
|
|
145
145
|
program.addCommand(createMigrationCommand);
|
|
146
146
|
program.addCommand(configureMcpCommand);
|
|
@@ -4,6 +4,8 @@ import { AzureCliDiagnostic, azureCliDiagnostic, redactAzureText } from "../../u
|
|
|
4
4
|
export const DEPLOYMENT_QUERY = "{id:id,name:name,state:properties.provisioningState,correlationId:properties.correlationId,templateHash:properties.templateHash,timestamp:properties.timestamp,error:properties.error}";
|
|
5
5
|
export const DEFAULT_PROVISION_WAIT_SECONDS = 300;
|
|
6
6
|
export const MAX_PROVISION_WAIT_SECONDS = 3_600;
|
|
7
|
+
/** Consecutive transient state-read failures tolerated while the human CLI waits for a terminal state. */
|
|
8
|
+
export const UNBOUNDED_WAIT_TRANSIENT_RETRIES = 24;
|
|
7
9
|
|
|
8
10
|
export interface DeploymentReference {
|
|
9
11
|
subscription: string;
|
|
@@ -116,9 +118,15 @@ export async function waitForAzureDeployment(
|
|
|
116
118
|
onObservation?: (observation: DeploymentObservation) => void;
|
|
117
119
|
secretValues?: readonly string[];
|
|
118
120
|
queryTimeoutMs?: number;
|
|
121
|
+
/**
|
|
122
|
+
* Consecutive non-terminal "unknown" reads (query failure, not yet visible) to keep polling through.
|
|
123
|
+
* Required for an unbounded deadline so a missing deployment cannot be polled forever.
|
|
124
|
+
*/
|
|
125
|
+
transientRetries?: number;
|
|
119
126
|
}
|
|
120
127
|
): Promise<DeploymentObservation> {
|
|
121
128
|
let observation: DeploymentObservation;
|
|
129
|
+
let unknownReads = 0;
|
|
122
130
|
do {
|
|
123
131
|
const remaining = options.deadline - Date.now();
|
|
124
132
|
// Even when the wait budget is exhausted, one bounded read can identify an accepted submission.
|
|
@@ -130,7 +138,15 @@ export async function waitForAzureDeployment(
|
|
|
130
138
|
if (observation.state !== "unknown") reference = observation.reference;
|
|
131
139
|
options.onObservation?.(observation);
|
|
132
140
|
if (observation.state === "Succeeded" || observation.state === "Failed" || observation.state === "Canceled") return observation;
|
|
133
|
-
if (observation.state === "unknown"
|
|
141
|
+
if (observation.state === "unknown") {
|
|
142
|
+
unknownReads += 1;
|
|
143
|
+
const retryable = observation.reason === "not-yet-observed" || observation.reason === "not-found"
|
|
144
|
+
|| (options.transientRetries !== undefined && (observation.reason === "query-failed" || observation.reason === "invalid-response"));
|
|
145
|
+
if (!retryable) return observation;
|
|
146
|
+
if (!Number.isFinite(options.deadline) && unknownReads > (options.transientRetries ?? 0)) return observation;
|
|
147
|
+
} else {
|
|
148
|
+
unknownReads = 0;
|
|
149
|
+
}
|
|
134
150
|
if (Date.now() >= options.deadline || options.signal?.aborted) return observation;
|
|
135
151
|
await new Promise<void>((resolve) => {
|
|
136
152
|
const done = () => {
|