swallowkit 1.0.3 → 1.1.0
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/create-migration.d.ts +3 -0
- package/dist/cli/commands/create-migration.d.ts.map +1 -0
- package/dist/cli/commands/create-migration.js +16 -0
- package/dist/cli/commands/create-migration.js.map +1 -0
- package/dist/cli/commands/index.d.ts +2 -0
- package/dist/cli/commands/index.d.ts.map +1 -1
- package/dist/cli/commands/index.js +5 -1
- package/dist/cli/commands/index.js.map +1 -1
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +2 -0
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/migrations.d.ts +3 -0
- package/dist/cli/commands/migrations.d.ts.map +1 -0
- package/dist/cli/commands/migrations.js +22 -0
- package/dist/cli/commands/migrations.js.map +1 -0
- package/dist/cli/commands/provision.d.ts +1 -1
- package/dist/cli/commands/provision.d.ts.map +1 -1
- package/dist/cli/commands/provision.js +95 -185
- 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 +6 -130
- package/dist/cli/commands/scaffold.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +4 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/core/operations/migration-generator.d.ts +10 -0
- package/dist/core/operations/migration-generator.d.ts.map +1 -0
- package/dist/core/operations/migration-generator.js +194 -0
- package/dist/core/operations/migration-generator.js.map +1 -0
- package/dist/core/operations/provision-operations.d.ts +19 -0
- package/dist/core/operations/provision-operations.d.ts.map +1 -1
- package/dist/core/operations/provision-operations.js +299 -30
- package/dist/core/operations/provision-operations.js.map +1 -1
- package/dist/core/project/capabilities.js +1 -1
- package/dist/core/project/capabilities.js.map +1 -1
- package/dist/core/project/infra.d.ts +4 -0
- package/dist/core/project/infra.d.ts.map +1 -1
- package/dist/core/project/infra.js +19 -1
- package/dist/core/project/infra.js.map +1 -1
- package/dist/core/project/migrations.d.ts +53 -0
- package/dist/core/project/migrations.d.ts.map +1 -0
- package/dist/core/project/migrations.js +262 -0
- package/dist/core/project/migrations.js.map +1 -0
- package/dist/core/project/workflows.d.ts.map +1 -1
- package/dist/core/project/workflows.js +7 -3
- package/dist/core/project/workflows.js.map +1 -1
- package/dist/machine/index.d.ts.map +1 -1
- package/dist/machine/index.js +6 -0
- package/dist/machine/index.js.map +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +11 -2
- package/dist/mcp/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/agent-loop-phase34.test.ts +139 -0
- package/src/__tests__/init.test.ts +8 -0
- package/src/__tests__/migration.test.ts +116 -0
- package/src/__tests__/scaffold.test.ts +7 -4
- package/src/cli/commands/create-migration.ts +13 -0
- package/src/cli/commands/index.ts +2 -0
- package/src/cli/commands/init.ts +2 -0
- package/src/cli/commands/migrations.ts +20 -0
- package/src/cli/commands/provision.ts +115 -184
- package/src/cli/commands/scaffold.ts +6 -145
- package/src/cli/index.ts +4 -0
- package/src/core/operations/migration-generator.ts +188 -0
- package/src/core/operations/provision-operations.ts +391 -44
- package/src/core/project/capabilities.ts +1 -1
- package/src/core/project/infra.ts +23 -1
- package/src/core/project/migrations.ts +274 -0
- package/src/core/project/workflows.ts +7 -3
- package/src/machine/index.ts +7 -1
- package/src/mcp/index.ts +8 -2
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import * as crypto from "crypto";
|
|
11
|
+
import * as fs from "fs";
|
|
11
12
|
import * as path from "path";
|
|
12
13
|
import { spawnSync } from "child_process";
|
|
13
14
|
import { MachineCommandError } from "../../machine/errors";
|
|
@@ -15,6 +16,23 @@ import { getSwallowKitVersion } from "../../version";
|
|
|
15
16
|
import { inspectInfra, InfraInspection } from "../project/infra";
|
|
16
17
|
import { deletePlanState, loadPlanState, savePlanState } from "../project/state";
|
|
17
18
|
import { hashFileIfExists } from "./file-session";
|
|
19
|
+
import {
|
|
20
|
+
MIGRATION_MANIFEST_PATH,
|
|
21
|
+
MigrationManifest,
|
|
22
|
+
MigrationState,
|
|
23
|
+
ResolvedMigration,
|
|
24
|
+
checksumAtVersion,
|
|
25
|
+
collectLegacyBaselineFiles,
|
|
26
|
+
computeBaselineChecksum,
|
|
27
|
+
decodeMigrationState,
|
|
28
|
+
encodeMigrationState,
|
|
29
|
+
findBaselineDrift,
|
|
30
|
+
getMigrationTagKey,
|
|
31
|
+
loadMigrationManifest,
|
|
32
|
+
replaceMigrationManifest,
|
|
33
|
+
resolveMigrations,
|
|
34
|
+
selectPendingMigrations,
|
|
35
|
+
} from "../project/migrations";
|
|
18
36
|
|
|
19
37
|
const RESOURCE_GROUP_PATTERN = /^[A-Za-z0-9._()-]{1,90}$/;
|
|
20
38
|
const LOCATION_PATTERN = /^[a-z0-9]{3,30}$/;
|
|
@@ -27,6 +45,18 @@ export interface PlanProvisionOptions {
|
|
|
27
45
|
subscription?: string;
|
|
28
46
|
/** 明示指定時のみ az deployment group what-if を実行(az login 済みが前提) */
|
|
29
47
|
whatIf?: boolean;
|
|
48
|
+
adoptExisting?: boolean;
|
|
49
|
+
baseline?: number;
|
|
50
|
+
reconcile?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type ProvisionMode = "offline" | "bootstrap" | "adoption-required" | "adopt" | "migrate" | "noop" | "reconcile";
|
|
54
|
+
|
|
55
|
+
interface ProvisionCommand {
|
|
56
|
+
file: "az";
|
|
57
|
+
args: string[];
|
|
58
|
+
kind: "resource-group" | "deployment" | "migration" | "tag";
|
|
59
|
+
expectedState?: MigrationState | null;
|
|
30
60
|
}
|
|
31
61
|
|
|
32
62
|
export interface ProvisionPlanData {
|
|
@@ -39,14 +69,21 @@ export interface ProvisionPlanData {
|
|
|
39
69
|
createdAt: string;
|
|
40
70
|
swallowkitVersion: string;
|
|
41
71
|
azCliAvailable: boolean;
|
|
72
|
+
mode: ProvisionMode;
|
|
42
73
|
infra: InfraInspection;
|
|
43
74
|
/** apply 時に実行されるコマンド(レビュー用) */
|
|
44
75
|
commands: string[];
|
|
76
|
+
commandSpecs: ProvisionCommand[];
|
|
77
|
+
migrationTagKey?: string;
|
|
78
|
+
remoteState?: MigrationState | null;
|
|
79
|
+
pendingMigrations: ResolvedMigration[];
|
|
80
|
+
manifestAfterApply?: MigrationManifest;
|
|
45
81
|
whatIfResult?: string;
|
|
46
82
|
warnings: string[];
|
|
47
83
|
/** provision は常に人間の承認が必要 */
|
|
48
84
|
requiresApproval: true;
|
|
49
85
|
fingerprints: Record<string, string | null>;
|
|
86
|
+
baselineSnapshotChecksum?: string;
|
|
50
87
|
}
|
|
51
88
|
|
|
52
89
|
function validateProvisionInputs(options: PlanProvisionOptions): void {
|
|
@@ -78,25 +115,122 @@ function validateProvisionInputs(options: PlanProvisionOptions): void {
|
|
|
78
115
|
|
|
79
116
|
function isAzCliAvailable(): boolean {
|
|
80
117
|
try {
|
|
81
|
-
const result = spawnSync("az", ["--version"], { shell:
|
|
118
|
+
const result = spawnSync("az", ["--version"], { shell: false, stdio: "ignore", timeout: 30_000 });
|
|
82
119
|
return result.status === 0;
|
|
83
120
|
} catch {
|
|
84
121
|
return false;
|
|
85
122
|
}
|
|
86
123
|
}
|
|
87
124
|
|
|
88
|
-
function
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
125
|
+
function displayCommand(command: ProvisionCommand): string {
|
|
126
|
+
return [command.file, ...command.args.map((arg) => /\s/.test(arg) ? JSON.stringify(arg) : arg)].join(" ");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
interface AzResult {
|
|
130
|
+
status: number | null;
|
|
131
|
+
stdout: string;
|
|
132
|
+
stderr: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function runAz(args: string[], timeout = 300_000): AzResult {
|
|
136
|
+
const result = spawnSync("az", args, { shell: false, encoding: "utf-8", timeout });
|
|
137
|
+
return {
|
|
138
|
+
status: result.status,
|
|
139
|
+
stdout: result.stdout ?? "",
|
|
140
|
+
stderr: result.stderr ?? "",
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function subscriptionArgs(subscription?: string): string[] {
|
|
145
|
+
return subscription ? ["--subscription", subscription] : [];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function buildBootstrapCommands(options: PlanProvisionOptions, mainBicepPath: string, parametersPath: string): ProvisionCommand[] {
|
|
149
|
+
const commands: ProvisionCommand[] = [];
|
|
150
|
+
commands.push({ file: "az", args: ["group", "create", "--name", options.resourceGroup, "--location", options.location, ...subscriptionArgs(options.subscription)], kind: "resource-group" });
|
|
151
|
+
commands.push({
|
|
152
|
+
file: "az",
|
|
153
|
+
args: ["deployment", "group", "create", "--name", "swallowkit-baseline", "--resource-group", options.resourceGroup, "--template-file", mainBicepPath, "--parameters", parametersPath, "--parameters", `location=${options.location}`, `swaLocation=${options.swaLocation}`, ...subscriptionArgs(options.subscription)],
|
|
154
|
+
kind: "deployment",
|
|
155
|
+
});
|
|
97
156
|
return commands;
|
|
98
157
|
}
|
|
99
158
|
|
|
159
|
+
function readProjectName(parametersPath: string): string {
|
|
160
|
+
try {
|
|
161
|
+
const parameters = JSON.parse(fs.readFileSync(parametersPath, "utf-8"));
|
|
162
|
+
const projectName = parameters?.parameters?.projectName?.value;
|
|
163
|
+
if (typeof projectName === "string" && /^[A-Za-z0-9-]+$/.test(projectName)) return projectName;
|
|
164
|
+
} catch {
|
|
165
|
+
// The caller reports the same actionable error for malformed and missing values.
|
|
166
|
+
}
|
|
167
|
+
throw new MachineCommandError("invalid-infra-parameters", "infra/main.parameters.json must define parameters.projectName.value.", undefined, "blocked");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
interface RemoteGroupState {
|
|
171
|
+
exists: boolean;
|
|
172
|
+
id?: string;
|
|
173
|
+
tags: Record<string, string>;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function inspectRemoteGroup(options: PlanProvisionOptions): RemoteGroupState {
|
|
177
|
+
const existsResult = runAz(["group", "exists", "--name", options.resourceGroup, ...subscriptionArgs(options.subscription)], 60_000);
|
|
178
|
+
if (existsResult.status !== 0) {
|
|
179
|
+
throw new MachineCommandError("azure-state-query-failed", `Could not query resource group: ${(existsResult.stderr || existsResult.stdout || "").trim()}`, undefined, "blocked");
|
|
180
|
+
}
|
|
181
|
+
if ((existsResult.stdout || "").trim() !== "true") return { exists: false, tags: {} };
|
|
182
|
+
const showResult = runAz(["group", "show", "--name", options.resourceGroup, ...subscriptionArgs(options.subscription), "--query", "{id:id,tags:tags}", "-o", "json"], 60_000);
|
|
183
|
+
if (showResult.status !== 0) {
|
|
184
|
+
throw new MachineCommandError("azure-state-query-failed", `Could not read resource group tags: ${(showResult.stderr || showResult.stdout || "").trim()}`, undefined, "blocked");
|
|
185
|
+
}
|
|
186
|
+
const parsed = JSON.parse(showResult.stdout || "{}");
|
|
187
|
+
return { exists: true, id: parsed.id, tags: parsed.tags ?? {} };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function tagCommand(resourceId: string, key: string, state: MigrationState, expectedState: MigrationState | null): ProvisionCommand {
|
|
191
|
+
return { file: "az", args: ["tag", "update", "--resource-id", resourceId, "--operation", "Merge", "--tags", `${key}=${encodeMigrationState(state)}`], kind: "tag", expectedState };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function bootstrapTagCommand(options: PlanProvisionOptions, key: string, state: MigrationState): ProvisionCommand {
|
|
195
|
+
return { file: "az", args: ["group", "update", "--name", options.resourceGroup, "--set", `tags.${key}=${encodeMigrationState(state)}`, ...subscriptionArgs(options.subscription)], kind: "tag", expectedState: null };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function runBaseWhatIf(options: PlanProvisionOptions, mainBicepPath: string, parametersPath: string): string {
|
|
199
|
+
const result = runAz([
|
|
200
|
+
"deployment", "group", "what-if",
|
|
201
|
+
"--resource-group", options.resourceGroup,
|
|
202
|
+
"--template-file", mainBicepPath,
|
|
203
|
+
"--parameters", parametersPath,
|
|
204
|
+
"--parameters", `location=${options.location}`, `swaLocation=${options.swaLocation}`,
|
|
205
|
+
...subscriptionArgs(options.subscription),
|
|
206
|
+
"--result-format", "FullResourcePayloads",
|
|
207
|
+
"--no-pretty-print",
|
|
208
|
+
"-o", "json",
|
|
209
|
+
]);
|
|
210
|
+
if (result.status !== 0) {
|
|
211
|
+
throw new MachineCommandError("what-if-failed", `az deployment group what-if failed: ${(result.stderr || result.stdout).trim().slice(0, 2000)}`, undefined, "blocked");
|
|
212
|
+
}
|
|
213
|
+
return result.stdout.trim();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function assertAdoptionWhatIfIsSafe(whatIfResult: string): void {
|
|
217
|
+
try {
|
|
218
|
+
const parsed = JSON.parse(whatIfResult) as { changes?: Array<{ changeType?: string; resourceId?: string }> };
|
|
219
|
+
const destructive = (parsed.changes ?? []).filter((change) => change.changeType === "Create" || change.changeType === "Delete");
|
|
220
|
+
if (destructive.length > 0) {
|
|
221
|
+
throw new MachineCommandError(
|
|
222
|
+
"adoption-drift-blocked",
|
|
223
|
+
"Legacy adoption found resources that would be created or deleted. Move undeployed additions into migrations before adopting this environment.",
|
|
224
|
+
{ changes: destructive },
|
|
225
|
+
"blocked"
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
} catch (error) {
|
|
229
|
+
if (error instanceof MachineCommandError) throw error;
|
|
230
|
+
throw new MachineCommandError("what-if-invalid", "Azure what-if did not return valid JSON for legacy adoption.", undefined, "blocked");
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
100
234
|
export async function planProvisionOperation(options: PlanProvisionOptions): Promise<ProvisionPlanData> {
|
|
101
235
|
const projectRoot = process.cwd();
|
|
102
236
|
validateProvisionInputs(options);
|
|
@@ -123,43 +257,165 @@ export async function planProvisionOperation(options: PlanProvisionOptions): Pro
|
|
|
123
257
|
|
|
124
258
|
const mainBicepPath = path.join(projectRoot, "infra", "main.bicep");
|
|
125
259
|
const parametersPath = path.join(projectRoot, "infra", "main.parameters.json");
|
|
126
|
-
|
|
127
|
-
|
|
260
|
+
let mode: ProvisionMode = "offline";
|
|
261
|
+
let commandSpecs: ProvisionCommand[] = buildBootstrapCommands(options, mainBicepPath, parametersPath);
|
|
262
|
+
let pendingMigrations: ResolvedMigration[] = [];
|
|
263
|
+
let migrationTagKey: string | undefined;
|
|
264
|
+
let remoteState: MigrationState | null | undefined;
|
|
128
265
|
let whatIfResult: string | undefined;
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
);
|
|
266
|
+
let manifestAfterApply: MigrationManifest | undefined;
|
|
267
|
+
let baselineDrift: string[] = [];
|
|
268
|
+
|
|
269
|
+
const manifestPath = path.join(projectRoot, MIGRATION_MANIFEST_PATH);
|
|
270
|
+
const manifest = fs.existsSync(manifestPath) ? loadMigrationManifest(projectRoot) : null;
|
|
271
|
+
if (manifest) {
|
|
272
|
+
for (const migration of manifest.migrations) {
|
|
273
|
+
const files = [migration.template, migration.parameters].filter((value): value is string => Boolean(value));
|
|
274
|
+
for (const relativePath of files) {
|
|
275
|
+
if (!fs.existsSync(path.join(projectRoot, relativePath))) {
|
|
276
|
+
throw new MachineCommandError("migration-file-not-found", `Migration file not found: ${relativePath}`, undefined, "blocked");
|
|
277
|
+
}
|
|
278
|
+
}
|
|
137
279
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
"blocked"
|
|
154
|
-
);
|
|
280
|
+
baselineDrift = findBaselineDrift(manifest, projectRoot);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (azCliAvailable) {
|
|
284
|
+
const remoteGroup = inspectRemoteGroup(options);
|
|
285
|
+
if (!manifest) {
|
|
286
|
+
if (remoteGroup.exists) {
|
|
287
|
+
throw new MachineCommandError(
|
|
288
|
+
"migration-manifest-not-found",
|
|
289
|
+
'Existing Azure resources require migration tracking. Run "swallowkit migrations init --legacy-baseline" first.',
|
|
290
|
+
undefined,
|
|
291
|
+
"blocked"
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
throw new MachineCommandError("migration-manifest-not-found", "Initialize a migration manifest before the first provision.", undefined, "blocked");
|
|
155
295
|
}
|
|
156
|
-
|
|
296
|
+
|
|
297
|
+
migrationTagKey = getMigrationTagKey(manifest.projectId);
|
|
298
|
+
if (!remoteGroup.exists) {
|
|
299
|
+
mode = "bootstrap";
|
|
300
|
+
const baselineFiles = collectLegacyBaselineFiles(projectRoot);
|
|
301
|
+
manifestAfterApply = {
|
|
302
|
+
...manifest,
|
|
303
|
+
baseline: { ...manifest.baseline, files: baselineFiles, checksum: computeBaselineChecksum(baselineFiles) },
|
|
304
|
+
};
|
|
305
|
+
const baselineState: MigrationState = { schemaVersion: 1, version: 0, checksum: manifestAfterApply.baseline.checksum };
|
|
306
|
+
commandSpecs = [...buildBootstrapCommands(options, mainBicepPath, parametersPath), bootstrapTagCommand(options, migrationTagKey, baselineState)];
|
|
307
|
+
if (options.whatIf) warnings.push("what-if-skipped: Resource-group-scope what-if requires the target resource group to exist.");
|
|
308
|
+
} else {
|
|
309
|
+
if (baselineDrift.length > 0 && !options.reconcile) {
|
|
310
|
+
throw new MachineCommandError(
|
|
311
|
+
"baseline-drift",
|
|
312
|
+
"Baseline infrastructure files changed after migration tracking was initialized. Move additive changes into a migration or use --reconcile for an intentional full deployment.",
|
|
313
|
+
{ changedFiles: baselineDrift },
|
|
314
|
+
"blocked"
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
const tagValue = remoteGroup.tags[migrationTagKey];
|
|
318
|
+
remoteState = tagValue ? decodeMigrationState(tagValue) : null;
|
|
319
|
+
if (!remoteState) {
|
|
320
|
+
mode = "adoption-required";
|
|
321
|
+
commandSpecs = [];
|
|
322
|
+
if (options.reconcile && !manifest.baseline.legacy) {
|
|
323
|
+
whatIfResult = runBaseWhatIf(options, mainBicepPath, parametersPath);
|
|
324
|
+
warnings.push("Recovering an interrupted bootstrap by reconciling the baseline before recording migration state.");
|
|
325
|
+
mode = "reconcile";
|
|
326
|
+
const baselineFiles = collectLegacyBaselineFiles(projectRoot);
|
|
327
|
+
manifestAfterApply = {
|
|
328
|
+
...manifest,
|
|
329
|
+
baseline: { ...manifest.baseline, files: baselineFiles, checksum: computeBaselineChecksum(baselineFiles) },
|
|
330
|
+
};
|
|
331
|
+
commandSpecs = buildBootstrapCommands(options, mainBicepPath, parametersPath).filter((command) => command.kind !== "resource-group");
|
|
332
|
+
commandSpecs.push(tagCommand(
|
|
333
|
+
remoteGroup.id!,
|
|
334
|
+
migrationTagKey,
|
|
335
|
+
{ schemaVersion: 1, version: 0, checksum: manifestAfterApply.baseline.checksum },
|
|
336
|
+
null
|
|
337
|
+
));
|
|
338
|
+
} else if (options.adoptExisting) {
|
|
339
|
+
if (options.baseline !== 0) {
|
|
340
|
+
throw new MachineCommandError("invalid-baseline", "Legacy adoption currently supports only --baseline 0.", undefined, "blocked");
|
|
341
|
+
}
|
|
342
|
+
if (!options.whatIf) {
|
|
343
|
+
throw new MachineCommandError("adoption-what-if-required", "Legacy adoption requires --what-if before the baseline tag can be recorded.", undefined, "requires-human");
|
|
344
|
+
}
|
|
345
|
+
if (!manifest.baseline.legacy) {
|
|
346
|
+
throw new MachineCommandError("invalid-baseline", "--adopt-existing requires a manifest created with migrations init --legacy-baseline.", undefined, "blocked");
|
|
347
|
+
}
|
|
348
|
+
whatIfResult = runBaseWhatIf(options, mainBicepPath, parametersPath);
|
|
349
|
+
assertAdoptionWhatIfIsSafe(whatIfResult);
|
|
350
|
+
warnings.push("Legacy adoption records the current environment as baseline 0 without deploying main.bicep. Review all Modify results before approval.");
|
|
351
|
+
mode = "adopt";
|
|
352
|
+
commandSpecs = [tagCommand(remoteGroup.id!, migrationTagKey, { schemaVersion: 1, version: 0, checksum: manifest.baseline.checksum }, null)];
|
|
353
|
+
}
|
|
354
|
+
} else if (options.reconcile) {
|
|
355
|
+
selectPendingMigrations(manifest, remoteState, projectRoot);
|
|
356
|
+
mode = "reconcile";
|
|
357
|
+
warnings.push("Reconcile redeploys infra/main.bicep and may reset template-managed properties such as Function App settings.");
|
|
358
|
+
whatIfResult = runBaseWhatIf(options, mainBicepPath, parametersPath);
|
|
359
|
+
commandSpecs = buildBootstrapCommands(options, mainBicepPath, parametersPath).filter((command) => command.kind !== "resource-group");
|
|
360
|
+
const baselineFiles = collectLegacyBaselineFiles(projectRoot);
|
|
361
|
+
manifestAfterApply = {
|
|
362
|
+
...manifest,
|
|
363
|
+
baseline: { ...manifest.baseline, files: baselineFiles, checksum: computeBaselineChecksum(baselineFiles) },
|
|
364
|
+
};
|
|
365
|
+
const reconciledState: MigrationState = {
|
|
366
|
+
schemaVersion: 1,
|
|
367
|
+
version: remoteState.version,
|
|
368
|
+
checksum: checksumAtVersion(manifestAfterApply, remoteState.version, projectRoot),
|
|
369
|
+
};
|
|
370
|
+
commandSpecs.push(tagCommand(remoteGroup.id!, migrationTagKey, reconciledState, remoteState));
|
|
371
|
+
} else {
|
|
372
|
+
pendingMigrations = selectPendingMigrations(manifest, remoteState, projectRoot);
|
|
373
|
+
mode = pendingMigrations.length > 0 ? "migrate" : "noop";
|
|
374
|
+
commandSpecs = [];
|
|
375
|
+
const projectName = pendingMigrations.length > 0 ? readProjectName(parametersPath) : "";
|
|
376
|
+
let expectedState = remoteState;
|
|
377
|
+
for (const migration of pendingMigrations) {
|
|
378
|
+
commandSpecs.push({
|
|
379
|
+
file: "az",
|
|
380
|
+
args: [
|
|
381
|
+
"deployment", "group", "create",
|
|
382
|
+
"--name", "swallowkit-migration",
|
|
383
|
+
"--resource-group", options.resourceGroup,
|
|
384
|
+
"--template-file", path.join(projectRoot, migration.template),
|
|
385
|
+
...(migration.parameters ? ["--parameters", path.join(projectRoot, migration.parameters)] : []),
|
|
386
|
+
"--parameters", `projectName=${projectName}`, `location=${options.location}`,
|
|
387
|
+
...subscriptionArgs(options.subscription),
|
|
388
|
+
],
|
|
389
|
+
kind: "migration",
|
|
390
|
+
expectedState,
|
|
391
|
+
});
|
|
392
|
+
const nextState = { schemaVersion: 1 as const, version: migration.version, checksum: migration.cumulativeChecksum };
|
|
393
|
+
commandSpecs.push(tagCommand(remoteGroup.id!, migrationTagKey, nextState, expectedState));
|
|
394
|
+
expectedState = nextState;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
} else if (options.whatIf || options.adoptExisting || options.reconcile) {
|
|
399
|
+
throw new MachineCommandError("az-cli-not-found", "Azure CLI is required for --what-if, --adopt-existing, and --reconcile.", undefined, "blocked");
|
|
157
400
|
}
|
|
158
401
|
|
|
159
402
|
const fingerprints: Record<string, string | null> = {
|
|
160
403
|
"infra/main.bicep": hashFileIfExists(mainBicepPath),
|
|
161
404
|
"infra/main.parameters.json": hashFileIfExists(parametersPath),
|
|
162
405
|
};
|
|
406
|
+
const baselineSnapshotChecksum = manifest
|
|
407
|
+
? computeBaselineChecksum(collectLegacyBaselineFiles(projectRoot))
|
|
408
|
+
: undefined;
|
|
409
|
+
if (manifest) {
|
|
410
|
+
fingerprints[MIGRATION_MANIFEST_PATH] = hashFileIfExists(manifestPath);
|
|
411
|
+
for (const baselineFile of manifest.baseline.files) {
|
|
412
|
+
fingerprints[baselineFile.path] = hashFileIfExists(path.join(projectRoot, baselineFile.path));
|
|
413
|
+
}
|
|
414
|
+
for (const migration of manifest.migrations) {
|
|
415
|
+
fingerprints[migration.template] = hashFileIfExists(path.join(projectRoot, migration.template));
|
|
416
|
+
if (migration.parameters) fingerprints[migration.parameters] = hashFileIfExists(path.join(projectRoot, migration.parameters));
|
|
417
|
+
}
|
|
418
|
+
}
|
|
163
419
|
|
|
164
420
|
const planId = crypto
|
|
165
421
|
.createHash("sha256")
|
|
@@ -169,6 +425,10 @@ export async function planProvisionOperation(options: PlanProvisionOptions): Pro
|
|
|
169
425
|
location: options.location,
|
|
170
426
|
swaLocation: options.swaLocation,
|
|
171
427
|
subscription: options.subscription,
|
|
428
|
+
mode,
|
|
429
|
+
remoteState,
|
|
430
|
+
pendingMigrations: pendingMigrations.map((migration) => ({ version: migration.version, checksum: migration.checksum })),
|
|
431
|
+
baselineSnapshotChecksum,
|
|
172
432
|
fingerprints,
|
|
173
433
|
})
|
|
174
434
|
)
|
|
@@ -185,12 +445,19 @@ export async function planProvisionOperation(options: PlanProvisionOptions): Pro
|
|
|
185
445
|
createdAt: new Date().toISOString(),
|
|
186
446
|
swallowkitVersion: getSwallowKitVersion(),
|
|
187
447
|
azCliAvailable,
|
|
448
|
+
mode,
|
|
188
449
|
infra,
|
|
189
|
-
commands,
|
|
450
|
+
commands: commandSpecs.map(displayCommand),
|
|
451
|
+
commandSpecs,
|
|
452
|
+
...(migrationTagKey ? { migrationTagKey } : {}),
|
|
453
|
+
...(remoteState !== undefined ? { remoteState } : {}),
|
|
454
|
+
pendingMigrations,
|
|
455
|
+
...(manifestAfterApply ? { manifestAfterApply } : {}),
|
|
190
456
|
...(whatIfResult !== undefined ? { whatIfResult } : {}),
|
|
191
457
|
warnings,
|
|
192
458
|
requiresApproval: true,
|
|
193
459
|
fingerprints,
|
|
460
|
+
...(baselineSnapshotChecksum ? { baselineSnapshotChecksum } : {}),
|
|
194
461
|
};
|
|
195
462
|
|
|
196
463
|
savePlanState(plan, projectRoot);
|
|
@@ -212,6 +479,48 @@ export interface ApplyProvisionResult {
|
|
|
212
479
|
warnings: string[];
|
|
213
480
|
}
|
|
214
481
|
|
|
482
|
+
function statesEqual(left: MigrationState | null | undefined, right: MigrationState | null | undefined): boolean {
|
|
483
|
+
return left?.version === right?.version && left?.checksum === right?.checksum;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function readRemoteMigrationState(plan: ProvisionPlanData): { group: RemoteGroupState; state: MigrationState | null } {
|
|
487
|
+
const group = inspectRemoteGroup(plan);
|
|
488
|
+
if (!group.exists || !plan.migrationTagKey) return { group, state: null };
|
|
489
|
+
const tagValue = group.tags[plan.migrationTagKey];
|
|
490
|
+
return { group, state: tagValue ? decodeMigrationState(tagValue) : null };
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function assertPlanRemoteState(plan: ProvisionPlanData, expected: MigrationState | null | undefined): void {
|
|
494
|
+
if (plan.mode === "offline") return;
|
|
495
|
+
const current = readRemoteMigrationState(plan);
|
|
496
|
+
if (plan.mode === "bootstrap") {
|
|
497
|
+
if (current.group.exists) {
|
|
498
|
+
throw new MachineCommandError("stale-azure-state", "The resource group was created after this plan. Re-run plan provision.", undefined, "blocked");
|
|
499
|
+
}
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
if (!current.group.exists || !statesEqual(current.state, expected)) {
|
|
503
|
+
throw new MachineCommandError(
|
|
504
|
+
"stale-azure-state",
|
|
505
|
+
"Azure migration state changed after this plan was created. Re-run plan provision.",
|
|
506
|
+
{ expected, actual: current.state, resourceGroupExists: current.group.exists },
|
|
507
|
+
"blocked"
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function assertTagExpectedState(plan: ProvisionPlanData, expected: MigrationState | null | undefined): void {
|
|
513
|
+
const current = readRemoteMigrationState(plan);
|
|
514
|
+
if (!current.group.exists || !statesEqual(current.state, expected)) {
|
|
515
|
+
throw new MachineCommandError(
|
|
516
|
+
"stale-azure-state",
|
|
517
|
+
"Azure migration state changed before its tag could be updated. Re-run plan provision.",
|
|
518
|
+
{ expected, actual: current.state, resourceGroupExists: current.group.exists },
|
|
519
|
+
"blocked"
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
215
524
|
export async function applyProvisionOperation(options: ApplyProvisionOptions): Promise<ApplyProvisionResult> {
|
|
216
525
|
const projectRoot = process.cwd();
|
|
217
526
|
|
|
@@ -239,6 +548,17 @@ export async function applyProvisionOperation(options: ApplyProvisionOptions): P
|
|
|
239
548
|
"blocked"
|
|
240
549
|
);
|
|
241
550
|
}
|
|
551
|
+
if (plan.baselineSnapshotChecksum) {
|
|
552
|
+
const currentBaselineChecksum = computeBaselineChecksum(collectLegacyBaselineFiles(projectRoot));
|
|
553
|
+
if (currentBaselineChecksum !== plan.baselineSnapshotChecksum) {
|
|
554
|
+
throw new MachineCommandError(
|
|
555
|
+
"stale-plan",
|
|
556
|
+
`Plan "${options.planId}" is stale: baseline infrastructure inventory changed after the plan was created.`,
|
|
557
|
+
{ planId: options.planId },
|
|
558
|
+
"blocked"
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
242
562
|
|
|
243
563
|
if (!options.approve) {
|
|
244
564
|
throw new MachineCommandError(
|
|
@@ -248,6 +568,21 @@ export async function applyProvisionOperation(options: ApplyProvisionOptions): P
|
|
|
248
568
|
"requires-human"
|
|
249
569
|
);
|
|
250
570
|
}
|
|
571
|
+
if (!plan.mode || !plan.commandSpecs) {
|
|
572
|
+
throw new MachineCommandError("incompatible-plan", "This provision plan was created by an older SwallowKit version. Re-run plan provision.", undefined, "blocked");
|
|
573
|
+
}
|
|
574
|
+
if (plan.mode === "offline") {
|
|
575
|
+
throw new MachineCommandError("online-plan-required", "Provision apply requires a fresh plan created while Azure CLI is available and authenticated.", undefined, "blocked");
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (plan.mode === "adoption-required") {
|
|
579
|
+
throw new MachineCommandError(
|
|
580
|
+
"adoption-required",
|
|
581
|
+
'This existing resource group has no SwallowKit migration state. Re-plan with --adopt-existing --baseline 0 --what-if.',
|
|
582
|
+
{ planId: plan.planId },
|
|
583
|
+
"requires-human"
|
|
584
|
+
);
|
|
585
|
+
}
|
|
251
586
|
|
|
252
587
|
if (!isAzCliAvailable()) {
|
|
253
588
|
throw new MachineCommandError(
|
|
@@ -262,18 +597,27 @@ export async function applyProvisionOperation(options: ApplyProvisionOptions): P
|
|
|
262
597
|
const warnings = [...plan.warnings];
|
|
263
598
|
let deploymentOutputs: Record<string, unknown> | undefined;
|
|
264
599
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
600
|
+
assertPlanRemoteState(plan, plan.remoteState);
|
|
601
|
+
|
|
602
|
+
for (const command of plan.commandSpecs) {
|
|
603
|
+
if (command.kind === "migration") {
|
|
604
|
+
assertPlanRemoteState(plan, command.expectedState);
|
|
605
|
+
}
|
|
606
|
+
if (command.kind === "tag") {
|
|
607
|
+
assertTagExpectedState(plan, command.expectedState);
|
|
608
|
+
}
|
|
609
|
+
const result = runAz(command.args);
|
|
610
|
+
const commandText = displayCommand(command);
|
|
611
|
+
executedCommands.push(commandText);
|
|
268
612
|
if (result.status !== 0) {
|
|
269
613
|
throw new MachineCommandError(
|
|
270
614
|
"provision-failed",
|
|
271
|
-
`Command failed: ${
|
|
615
|
+
`Command failed: ${commandText}\n${(result.stderr || result.stdout).trim().slice(0, 4000)}`,
|
|
272
616
|
{ executedCommands },
|
|
273
617
|
"failed"
|
|
274
618
|
);
|
|
275
619
|
}
|
|
276
|
-
if (command.
|
|
620
|
+
if (command.kind === "deployment" || command.kind === "migration") {
|
|
277
621
|
try {
|
|
278
622
|
const deployment = JSON.parse(result.stdout || "{}");
|
|
279
623
|
deploymentOutputs = deployment.properties?.outputs;
|
|
@@ -283,6 +627,9 @@ export async function applyProvisionOperation(options: ApplyProvisionOptions): P
|
|
|
283
627
|
}
|
|
284
628
|
}
|
|
285
629
|
|
|
630
|
+
if (plan.manifestAfterApply) {
|
|
631
|
+
replaceMigrationManifest(plan.manifestAfterApply, projectRoot);
|
|
632
|
+
}
|
|
286
633
|
deletePlanState(plan.planId, projectRoot);
|
|
287
634
|
|
|
288
635
|
return {
|
|
@@ -179,7 +179,7 @@ export function inspectCapabilities(): CapabilitiesContract {
|
|
|
179
179
|
"inspect project | entities | routes | artifacts | boundaries | infra | drift | capabilities",
|
|
180
180
|
"validate project",
|
|
181
181
|
"generate model <names...> | generate scaffold <model>",
|
|
182
|
-
"plan scaffold <models...> | plan auth --provider <p> [--scheme <name>] [--allowed-providers <csv>] | plan provision -g <rg> --location <loc> --swa-location <loc>",
|
|
182
|
+
"plan scaffold <models...> | plan auth --provider <p> [--scheme <name>] [--allowed-providers <csv>] | plan provision -g <rg> --location <loc> --swa-location <loc> [--adopt-existing --baseline 0 --what-if] [--reconcile --what-if]",
|
|
183
183
|
"apply scaffold [models...] --plan <id> [--approve] | apply auth --plan <id> [--approve] | apply provision --plan <id> --approve",
|
|
184
184
|
"verify project [--checks <ids>] [--compact]",
|
|
185
185
|
"explain failure [--check <id>]",
|
|
@@ -47,6 +47,10 @@ export interface InfraInspection {
|
|
|
47
47
|
};
|
|
48
48
|
moduleFiles: string[];
|
|
49
49
|
containers: InfraContainer[];
|
|
50
|
+
migrations: {
|
|
51
|
+
manifestExists: boolean;
|
|
52
|
+
versions: number[];
|
|
53
|
+
};
|
|
50
54
|
boundary: {
|
|
51
55
|
zone: "shared";
|
|
52
56
|
note: string;
|
|
@@ -172,6 +176,20 @@ export function inspectInfra(projectRoot: string = process.cwd()): InfraInspecti
|
|
|
172
176
|
return { file: `infra/containers/${f}`, wired };
|
|
173
177
|
});
|
|
174
178
|
|
|
179
|
+
const migrationManifestPath = path.join(infraDir, "migrations", "manifest.json");
|
|
180
|
+
let migrationVersions: number[] = [];
|
|
181
|
+
if (fs.existsSync(migrationManifestPath)) {
|
|
182
|
+
try {
|
|
183
|
+
const manifest = JSON.parse(fs.readFileSync(migrationManifestPath, "utf-8")) as { migrations?: Array<{ version?: number }> };
|
|
184
|
+
migrationVersions = (manifest.migrations ?? [])
|
|
185
|
+
.map((migration) => migration.version)
|
|
186
|
+
.filter((version): version is number => Number.isSafeInteger(version))
|
|
187
|
+
.sort((left, right) => left - right);
|
|
188
|
+
} catch {
|
|
189
|
+
warnings.push("migration-manifest-invalid: infra/migrations/manifest.json is not valid JSON.");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
175
193
|
return {
|
|
176
194
|
hasInfraDirectory,
|
|
177
195
|
mainBicep: {
|
|
@@ -187,9 +205,13 @@ export function inspectInfra(projectRoot: string = process.cwd()): InfraInspecti
|
|
|
187
205
|
},
|
|
188
206
|
moduleFiles,
|
|
189
207
|
containers,
|
|
208
|
+
migrations: {
|
|
209
|
+
manifestExists: fs.existsSync(migrationManifestPath),
|
|
210
|
+
versions: migrationVersions,
|
|
211
|
+
},
|
|
190
212
|
boundary: {
|
|
191
213
|
zone: "shared",
|
|
192
|
-
note: "infra/main.bicep
|
|
214
|
+
note: "infra/main.bicep and modules form the baseline. New model containers and custom resources belong in immutable infra/migrations entries.",
|
|
193
215
|
},
|
|
194
216
|
warnings,
|
|
195
217
|
};
|