@intentius/chant 0.45.0 → 0.46.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/build.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon.d.ts +14 -0
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- package/dist/cli/commands/lexicon-surface-diff.d.ts +6 -0
- package/dist/cli/commands/lexicon-surface-diff.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts +13 -0
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/search.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +7 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/codegen/lexicon-regen.d.ts +11 -0
- package/dist/codegen/lexicon-regen.d.ts.map +1 -1
- package/dist/codegen/validate.d.ts +10 -0
- package/dist/codegen/validate.d.ts.map +1 -1
- package/dist/config.d.ts +28 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/env.d.ts +12 -1
- package/dist/env.d.ts.map +1 -1
- package/dist/lexicon.d.ts +182 -2
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/index.d.ts +1 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/teardown.d.ts +130 -0
- package/dist/lifecycle/teardown.d.ts.map +1 -0
- package/dist/lint/engine.d.ts +6 -2
- package/dist/lint/engine.d.ts.map +1 -1
- package/dist/lint/rule.d.ts +31 -0
- package/dist/lint/rule.d.ts.map +1 -1
- package/dist/lint/rules/cor021-env-literal-name.d.ts +3 -0
- package/dist/lint/rules/cor021-env-literal-name.d.ts.map +1 -0
- package/dist/lint/rules/index.d.ts +2 -1
- package/dist/lint/rules/index.d.ts.map +1 -1
- package/dist/op/builders.d.ts +36 -7
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/index.d.ts +1 -1
- package/dist/op/index.d.ts.map +1 -1
- package/dist/testing.d.ts +136 -0
- package/dist/testing.d.ts.map +1 -0
- package/package.json +6 -1
- package/src/cli/commands/build.test.ts +131 -0
- package/src/cli/commands/build.ts +20 -0
- package/src/cli/commands/check-lexicon.test.ts +45 -1
- package/src/cli/commands/check-lexicon.ts +45 -0
- package/src/cli/commands/lexicon-surface-diff.ts +9 -0
- package/src/cli/commands/lexicon-surface-diff.update.test.ts +112 -0
- package/src/cli/commands/lint.ts +19 -6
- package/src/cli/handlers/graph.ts +2 -2
- package/src/cli/handlers/lifecycle.test.ts +231 -1
- package/src/cli/handlers/lifecycle.ts +219 -3
- package/src/cli/handlers/search.ts +5 -2
- package/src/cli/main.ts +12 -1
- package/src/cli/registry.ts +7 -0
- package/src/codegen/lexicon-regen.ts +19 -1
- package/src/codegen/validate.test.ts +33 -0
- package/src/codegen/validate.ts +21 -2
- package/src/config.test.ts +40 -0
- package/src/config.ts +58 -1
- package/src/env.test.ts +35 -1
- package/src/env.ts +17 -3
- package/src/lexicon.ts +182 -2
- package/src/lifecycle/index.ts +1 -0
- package/src/lifecycle/teardown.test.ts +537 -0
- package/src/lifecycle/teardown.ts +357 -0
- package/src/lint/engine.ts +7 -1
- package/src/lint/rule.ts +23 -0
- package/src/lint/rules/cor021-env-literal-name.test.ts +128 -0
- package/src/lint/rules/cor021-env-literal-name.ts +114 -0
- package/src/lint/rules/index.ts +4 -1
- package/src/op/builders.ts +40 -7
- package/src/op/index.ts +1 -1
- package/src/testing.test.ts +261 -0
- package/src/testing.ts +338 -0
|
@@ -53,7 +53,7 @@ vi.mock("../../config", async () => {
|
|
|
53
53
|
};
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
-
const { runLifecycleDiff, runLifecyclePlan, runLifecycleSnapshot, runLifecycleShow, runLifecycleLog, runLifecycleUnknown } = await import("./lifecycle");
|
|
56
|
+
const { runLifecycleDiff, runLifecyclePlan, runLifecycleSnapshot, runLifecycleShow, runLifecycleLog, runLifecycleTeardown, runLifecycleUnknown } = await import("./lifecycle");
|
|
57
57
|
|
|
58
58
|
function makeArgs(overrides: Partial<ParsedArgs>): ParsedArgs {
|
|
59
59
|
return {
|
|
@@ -1220,3 +1220,233 @@ describe("runLifecycleUnknown", () => {
|
|
|
1220
1220
|
expect(stderr).toContain("log");
|
|
1221
1221
|
});
|
|
1222
1222
|
});
|
|
1223
|
+
|
|
1224
|
+
describe("runLifecycleTeardown (#1222 — plan only)", () => {
|
|
1225
|
+
let stdoutBuf: string[];
|
|
1226
|
+
let stderrBuf: string[];
|
|
1227
|
+
|
|
1228
|
+
beforeEach(() => {
|
|
1229
|
+
stdoutBuf = [];
|
|
1230
|
+
stderrBuf = [];
|
|
1231
|
+
vi.spyOn(console, "log").mockImplementation((s: string) => { stdoutBuf.push(s); });
|
|
1232
|
+
vi.spyOn(console, "error").mockImplementation((s: string) => { stderrBuf.push(s); });
|
|
1233
|
+
loadChantConfigMock.mockReset();
|
|
1234
|
+
loadChantConfigMock.mockResolvedValue({
|
|
1235
|
+
config: { environments: ["dev", "prod"], ownership: { stack: "shop", env: "dev" } },
|
|
1236
|
+
});
|
|
1237
|
+
});
|
|
1238
|
+
|
|
1239
|
+
afterEach(() => {
|
|
1240
|
+
vi.restoreAllMocks();
|
|
1241
|
+
});
|
|
1242
|
+
|
|
1243
|
+
const run = (overrides: Partial<ParsedArgs>, plugins: LexiconPlugin[]) =>
|
|
1244
|
+
runLifecycleTeardown({
|
|
1245
|
+
args: makeArgs({ command: "lifecycle", path: "teardown", ...overrides }),
|
|
1246
|
+
plugins,
|
|
1247
|
+
serializers: [],
|
|
1248
|
+
});
|
|
1249
|
+
|
|
1250
|
+
test("requires an environment", async () => {
|
|
1251
|
+
expect(await run({}, [])).toBe(1);
|
|
1252
|
+
expect(stderrBuf.join("\n")).toContain("Environment is required");
|
|
1253
|
+
});
|
|
1254
|
+
|
|
1255
|
+
test("exits nonzero on an environment the project does not declare", async () => {
|
|
1256
|
+
expect(await run({ extraPositional: "staging" }, [])).toBe(1);
|
|
1257
|
+
const stderr = stderrBuf.join("\n");
|
|
1258
|
+
expect(stderr).toContain('Unknown environment "staging"');
|
|
1259
|
+
expect(stderr).toContain("dev, prod");
|
|
1260
|
+
});
|
|
1261
|
+
|
|
1262
|
+
test("--yes executes the planned set and prints per-candidate outcomes", async () => {
|
|
1263
|
+
const executeTeardown = vi.fn(async () => ({
|
|
1264
|
+
outcomes: [{ name: "web", outcome: "deleted" as const }],
|
|
1265
|
+
}));
|
|
1266
|
+
const plugin = createMockPlugin({
|
|
1267
|
+
name: "k8s",
|
|
1268
|
+
teardownOwned: async () => ({
|
|
1269
|
+
candidates: [{ name: "web", type: "K8s::Apps::Deployment", marker: { stack: "shop", env: "dev" } }],
|
|
1270
|
+
}),
|
|
1271
|
+
executeTeardown,
|
|
1272
|
+
});
|
|
1273
|
+
expect(await run({ extraPositional: "dev", yes: true }, [plugin])).toBe(0);
|
|
1274
|
+
expect(executeTeardown).toHaveBeenCalledWith({
|
|
1275
|
+
environment: "dev",
|
|
1276
|
+
marker: { stack: "shop", env: "dev" },
|
|
1277
|
+
candidates: [{ name: "web", type: "K8s::Apps::Deployment", marker: { stack: "shop", env: "dev" } }],
|
|
1278
|
+
});
|
|
1279
|
+
const out = stdoutBuf.join("\n");
|
|
1280
|
+
expect(out).toContain("Outcomes:");
|
|
1281
|
+
expect(out).toContain("deleted");
|
|
1282
|
+
expect(out).toContain("1 deleted, 0 failed, 0 not prunable, 0 skipped");
|
|
1283
|
+
expect(out).not.toContain("plan only");
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
test("--yes exits nonzero when a candidate stays failed after the retry pass", async () => {
|
|
1287
|
+
const executeTeardown = vi.fn(async () => ({
|
|
1288
|
+
outcomes: [{ name: "web", outcome: "failed" as const, detail: "conflict" }],
|
|
1289
|
+
}));
|
|
1290
|
+
const plugin = createMockPlugin({
|
|
1291
|
+
name: "k8s",
|
|
1292
|
+
teardownOwned: async () => ({
|
|
1293
|
+
candidates: [{ name: "web", type: "K8s::Apps::Deployment", marker: { stack: "shop", env: "dev" } }],
|
|
1294
|
+
}),
|
|
1295
|
+
executeTeardown,
|
|
1296
|
+
});
|
|
1297
|
+
expect(await run({ extraPositional: "dev", yes: true }, [plugin])).toBe(1);
|
|
1298
|
+
expect(executeTeardown).toHaveBeenCalledTimes(2); // pass + bounded retry
|
|
1299
|
+
expect(stderrBuf.join("\n")).toContain("failed to delete after the retry pass");
|
|
1300
|
+
});
|
|
1301
|
+
|
|
1302
|
+
test("--yes with a lexicon that only enumerates reports skipped, not deleted", async () => {
|
|
1303
|
+
const plugin = createMockPlugin({
|
|
1304
|
+
name: "gcp",
|
|
1305
|
+
teardownOwned: async () => ({
|
|
1306
|
+
candidates: [{ name: "bucket", type: "GCP::Storage::Bucket", marker: { stack: "shop", env: "dev" } }],
|
|
1307
|
+
}),
|
|
1308
|
+
});
|
|
1309
|
+
expect(await run({ extraPositional: "dev", yes: true }, [plugin])).toBe(0);
|
|
1310
|
+
expect(stdoutBuf.join("\n")).toContain("skipped");
|
|
1311
|
+
expect(stderrBuf.join("\n")).toContain("no teardown execution in these lexicons yet");
|
|
1312
|
+
});
|
|
1313
|
+
|
|
1314
|
+
test("--yes on a production-like environment demands --confirm-prod when not interactive", async () => {
|
|
1315
|
+
const executeTeardown = vi.fn();
|
|
1316
|
+
const plugin = createMockPlugin({
|
|
1317
|
+
name: "k8s",
|
|
1318
|
+
teardownOwned: async () => ({ candidates: [] }),
|
|
1319
|
+
executeTeardown,
|
|
1320
|
+
});
|
|
1321
|
+
expect(await run({ extraPositional: "prod", yes: true }, [plugin])).toBe(1);
|
|
1322
|
+
expect(stderrBuf.join("\n")).toContain("--confirm-prod");
|
|
1323
|
+
expect(executeTeardown).not.toHaveBeenCalled();
|
|
1324
|
+
});
|
|
1325
|
+
|
|
1326
|
+
test("--yes --confirm-prod tears a production-like environment down non-interactively", async () => {
|
|
1327
|
+
const executeTeardown = vi.fn(async () => ({
|
|
1328
|
+
outcomes: [{ name: "web", outcome: "deleted" as const }],
|
|
1329
|
+
}));
|
|
1330
|
+
const plugin = createMockPlugin({
|
|
1331
|
+
name: "k8s",
|
|
1332
|
+
teardownOwned: async () => ({
|
|
1333
|
+
candidates: [{ name: "web", type: "K8s::Apps::Deployment", marker: { stack: "shop", env: "prod" } }],
|
|
1334
|
+
}),
|
|
1335
|
+
executeTeardown,
|
|
1336
|
+
});
|
|
1337
|
+
expect(await run({ extraPositional: "prod", yes: true, confirmProd: true }, [plugin])).toBe(0);
|
|
1338
|
+
expect(executeTeardown).toHaveBeenCalledTimes(1);
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1341
|
+
test("the prod guard never fires without --yes — planning production is safe", async () => {
|
|
1342
|
+
const plugin = createMockPlugin({
|
|
1343
|
+
name: "k8s",
|
|
1344
|
+
teardownOwned: async () => ({ candidates: [] }),
|
|
1345
|
+
});
|
|
1346
|
+
expect(await run({ extraPositional: "prod" }, [plugin])).toBe(0);
|
|
1347
|
+
expect(stderrBuf.join("\n")).not.toContain("--confirm-prod");
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1350
|
+
test("--yes --json emits the execution report", async () => {
|
|
1351
|
+
const plugin = createMockPlugin({
|
|
1352
|
+
name: "k8s",
|
|
1353
|
+
teardownOwned: async () => ({
|
|
1354
|
+
candidates: [{ name: "web", type: "K8s::Apps::Deployment", marker: { stack: "shop", env: "dev" } }],
|
|
1355
|
+
}),
|
|
1356
|
+
executeTeardown: async () => ({ outcomes: [{ name: "web", outcome: "deleted" as const }] }),
|
|
1357
|
+
});
|
|
1358
|
+
expect(await run({ extraPositional: "dev", yes: true, json: true }, [plugin])).toBe(0);
|
|
1359
|
+
const parsed = JSON.parse(stdoutBuf.join("\n"));
|
|
1360
|
+
expect(parsed.outcomes).toEqual([
|
|
1361
|
+
{
|
|
1362
|
+
lexicon: "k8s",
|
|
1363
|
+
name: "web",
|
|
1364
|
+
type: "K8s::Apps::Deployment",
|
|
1365
|
+
marker: { stack: "shop", env: "dev" },
|
|
1366
|
+
outcome: "deleted",
|
|
1367
|
+
},
|
|
1368
|
+
]);
|
|
1369
|
+
});
|
|
1370
|
+
|
|
1371
|
+
test("refuses when the project declares no ownership.stack", async () => {
|
|
1372
|
+
loadChantConfigMock.mockResolvedValue({ config: { environments: ["dev"] } });
|
|
1373
|
+
expect(await run({ extraPositional: "dev" }, [])).toBe(1);
|
|
1374
|
+
expect(stderrBuf.join("\n")).toContain("no ownership.stack");
|
|
1375
|
+
});
|
|
1376
|
+
|
|
1377
|
+
test("prints the would-delete set with resource, type, and marker identity", async () => {
|
|
1378
|
+
const plugin = createMockPlugin({
|
|
1379
|
+
name: "k8s",
|
|
1380
|
+
teardownOwned: async () => ({
|
|
1381
|
+
candidates: [
|
|
1382
|
+
{ name: "web", type: "K8s::Apps::Deployment", physicalId: "web-1", marker: { stack: "shop", env: "dev" } },
|
|
1383
|
+
],
|
|
1384
|
+
}),
|
|
1385
|
+
});
|
|
1386
|
+
expect(await run({ extraPositional: "dev" }, [plugin])).toBe(0);
|
|
1387
|
+
const out = stdoutBuf.join("\n");
|
|
1388
|
+
expect(out).toContain("Teardown plan");
|
|
1389
|
+
expect(out).toContain("plan only");
|
|
1390
|
+
expect(out).toContain("web");
|
|
1391
|
+
expect(out).toContain("K8s::Apps::Deployment");
|
|
1392
|
+
expect(out).toContain("shop/dev");
|
|
1393
|
+
expect(stderrBuf.join("\n")).toContain("re-run with --yes to execute");
|
|
1394
|
+
});
|
|
1395
|
+
|
|
1396
|
+
test("never selects foreign-stack or foreign-env resources on the fallback path", async () => {
|
|
1397
|
+
const plugin = createMockPlugin({
|
|
1398
|
+
name: "k8s",
|
|
1399
|
+
describeResources: staticObservation({
|
|
1400
|
+
mine: { type: "K8s::Apps::Deployment", status: "READY", marker: { stack: "shop", env: "dev" } },
|
|
1401
|
+
theirs: { type: "K8s::Apps::Deployment", status: "READY", marker: { stack: "blog", env: "dev" } },
|
|
1402
|
+
prodTwin: { type: "K8s::Apps::Deployment", status: "READY", marker: { stack: "shop", env: "prod" } },
|
|
1403
|
+
unmarked: { type: "K8s::Apps::Deployment", status: "READY" },
|
|
1404
|
+
}),
|
|
1405
|
+
});
|
|
1406
|
+
expect(await run({ extraPositional: "dev" }, [plugin])).toBe(0);
|
|
1407
|
+
const out = stdoutBuf.join("\n");
|
|
1408
|
+
expect(out).toContain("mine");
|
|
1409
|
+
expect(out).not.toContain("theirs");
|
|
1410
|
+
expect(out).not.toContain("prodTwin");
|
|
1411
|
+
expect(out).not.toContain("unmarked");
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
test("an empty would-delete set is loud, not silent", async () => {
|
|
1415
|
+
const plugin = createMockPlugin({
|
|
1416
|
+
name: "k8s",
|
|
1417
|
+
teardownOwned: async () => ({ candidates: [] }),
|
|
1418
|
+
});
|
|
1419
|
+
expect(await run({ extraPositional: "dev" }, [plugin])).toBe(0);
|
|
1420
|
+
expect(stderrBuf.join("\n")).toContain("nothing would be deleted");
|
|
1421
|
+
});
|
|
1422
|
+
|
|
1423
|
+
test("holes are surfaced loudly (#1089)", async () => {
|
|
1424
|
+
const plugin = createMockPlugin({
|
|
1425
|
+
name: "k8s",
|
|
1426
|
+
teardownOwned: async () => ({
|
|
1427
|
+
candidates: [],
|
|
1428
|
+
holes: [{ name: "crds", type: "K8s::CRD", reason: "unsupported-kind" as const, detail: "no reader" }],
|
|
1429
|
+
}),
|
|
1430
|
+
});
|
|
1431
|
+
expect(await run({ extraPositional: "dev" }, [plugin])).toBe(0);
|
|
1432
|
+
expect(stderrBuf.join("\n")).toContain("incomplete, not clean");
|
|
1433
|
+
const out = stdoutBuf.join("\n");
|
|
1434
|
+
expect(out).toContain("HOLES");
|
|
1435
|
+
expect(out).toContain("crds");
|
|
1436
|
+
expect(out).toContain("unsupported-kind");
|
|
1437
|
+
});
|
|
1438
|
+
|
|
1439
|
+
test("--json emits the plan as JSON", async () => {
|
|
1440
|
+
const plugin = createMockPlugin({
|
|
1441
|
+
name: "k8s",
|
|
1442
|
+
teardownOwned: async () => ({
|
|
1443
|
+
candidates: [{ name: "web", type: "K8s::Apps::Deployment", marker: { stack: "shop", env: "dev" } }],
|
|
1444
|
+
}),
|
|
1445
|
+
});
|
|
1446
|
+
expect(await run({ extraPositional: "dev", json: true }, [plugin])).toBe(0);
|
|
1447
|
+
const parsed = JSON.parse(stdoutBuf.join("\n"));
|
|
1448
|
+
expect(parsed.environment).toBe("dev");
|
|
1449
|
+
expect(parsed.stack).toBe("shop");
|
|
1450
|
+
expect(parsed.entries).toHaveLength(1);
|
|
1451
|
+
});
|
|
1452
|
+
});
|
|
@@ -30,7 +30,9 @@ import { discoverComponents } from "../../components/discover";
|
|
|
30
30
|
import { cfnDeployStacks } from "./components";
|
|
31
31
|
import { affectedStacks } from "../../lifecycle/affected";
|
|
32
32
|
import { rollbackToRevision } from "../../lifecycle/rollback";
|
|
33
|
-
import { loadChantConfig, environmentNames } from "../../config";
|
|
33
|
+
import { loadChantConfig, environmentNames, matchesDeclaredEnvironment, resolveOwnershipStack } from "../../config";
|
|
34
|
+
import { unknownEnvError, isProdLikeEnvironment } from "../../env";
|
|
35
|
+
import { planTeardown, executeTeardown, type TeardownPlan, type TeardownReport } from "../../lifecycle/teardown";
|
|
34
36
|
import { collectBuildRootContributors } from "../plugins";
|
|
35
37
|
import { applyLiveEndpoint } from "../../live-endpoint";
|
|
36
38
|
import { isResourceDeclarable } from "../../declarable";
|
|
@@ -109,7 +111,7 @@ export async function runLifecycleSnapshot(ctx: CommandContext): Promise<number>
|
|
|
109
111
|
const declaredParams = await commandBuildParams(config.buildParams, args);
|
|
110
112
|
if (!declaredParams) return 1;
|
|
111
113
|
const declaredEnvNames = environmentNames(config.environments);
|
|
112
|
-
if (declaredEnvNames && !
|
|
114
|
+
if (declaredEnvNames && !matchesDeclaredEnvironment(config.environments, environment)) {
|
|
113
115
|
console.error(formatError({
|
|
114
116
|
message: `Unknown environment "${environment}"`,
|
|
115
117
|
hint: `Defined environments: ${declaredEnvNames.join(", ")}`,
|
|
@@ -1240,6 +1242,220 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
|
|
|
1240
1242
|
return 0;
|
|
1241
1243
|
}
|
|
1242
1244
|
|
|
1245
|
+
/**
|
|
1246
|
+
* chant lifecycle teardown <environment> (#1222).
|
|
1247
|
+
*
|
|
1248
|
+
* Enumerates what deleting the environment would remove: every live resource
|
|
1249
|
+
* carrying this project's ownership marker (managed-by + `ownership.stack`)
|
|
1250
|
+
* with the requested env identity. Stateless — live markers only, no build, no
|
|
1251
|
+
* snapshot. Without `--yes` nothing is deleted; with it the planned set is
|
|
1252
|
+
* executed per lexicon with one bounded retry pass over failures, and every
|
|
1253
|
+
* candidate's outcome is reported (deleted / failed / not-prunable / skipped —
|
|
1254
|
+
* never silence). A production-like environment name additionally requires an
|
|
1255
|
+
* interactive confirmation, or `--confirm-prod` non-interactively.
|
|
1256
|
+
*/
|
|
1257
|
+
export async function runLifecycleTeardown(ctx: CommandContext): Promise<number> {
|
|
1258
|
+
const { args, plugins } = ctx;
|
|
1259
|
+
const environment = args.extraPositional;
|
|
1260
|
+
|
|
1261
|
+
if (!environment) {
|
|
1262
|
+
console.error(formatError({ message: "Environment is required: chant lifecycle teardown <environment>" }));
|
|
1263
|
+
return 1;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
const { config } = await loadChantConfig(resolve("."));
|
|
1267
|
+
|
|
1268
|
+
// Refuse an env the project does not declare — a typo here is the difference
|
|
1269
|
+
// between tearing down `dev` and tearing down `prod`. Literal `environments`
|
|
1270
|
+
// entries only (#1221's pattern entries would extend this same check).
|
|
1271
|
+
const envErr = unknownEnvError(environment, config.environments);
|
|
1272
|
+
if (envErr) {
|
|
1273
|
+
console.error(formatError({ message: envErr }));
|
|
1274
|
+
return 1;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// Teardown selects on the ownership marker; a project that stamps none has
|
|
1278
|
+
// nothing to key on, and "delete what looks like mine" is not a fallback.
|
|
1279
|
+
const stack = resolveOwnershipStack(config);
|
|
1280
|
+
if (stack === undefined) {
|
|
1281
|
+
console.error(formatError({
|
|
1282
|
+
message: "This project declares no ownership.stack — teardown is marker-scoped and has nothing to select on",
|
|
1283
|
+
hint: 'Set `ownership: { stack: "<name>" }` in chant.config.ts and deploy, so resources carry the marker teardown keys on.',
|
|
1284
|
+
}));
|
|
1285
|
+
return 1;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// The prod guard (#1222): a production-like name never falls to `--yes`
|
|
1289
|
+
// alone. Interactive runs re-type the environment name; non-interactive
|
|
1290
|
+
// runs say `--confirm-prod` explicitly. Checked before any live read so a
|
|
1291
|
+
// refused teardown touches nothing at all.
|
|
1292
|
+
if (args.yes && isProdLikeEnvironment(environment) && !args.confirmProd) {
|
|
1293
|
+
if (!process.stdin.isTTY) {
|
|
1294
|
+
console.error(formatError({
|
|
1295
|
+
message: `"${environment}" looks like a production environment — --yes alone is not enough`,
|
|
1296
|
+
hint: "Re-run with --yes --confirm-prod to tear it down non-interactively.",
|
|
1297
|
+
}));
|
|
1298
|
+
return 1;
|
|
1299
|
+
}
|
|
1300
|
+
const confirmed = await promptProdTeardown(environment);
|
|
1301
|
+
if (!confirmed) {
|
|
1302
|
+
console.error(formatError({ message: "Confirmation did not match — nothing was deleted." }));
|
|
1303
|
+
return 1;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
// #1166 — teardown is always a live read (and with --yes, a live write), so
|
|
1308
|
+
// an environment's declared endpoint applies here too, unless the ambient
|
|
1309
|
+
// shell already set it.
|
|
1310
|
+
const readingPlugins = plugins.filter((p) => p.teardownOwned || p.describeResources || p.executeTeardown);
|
|
1311
|
+
const endpointResult = applyLiveEndpoint(config.environments, environment, readingPlugins);
|
|
1312
|
+
if (endpointResult.notice) console.error(formatWarning({ message: endpointResult.notice }));
|
|
1313
|
+
|
|
1314
|
+
let plan: TeardownPlan;
|
|
1315
|
+
let report: TeardownReport | undefined;
|
|
1316
|
+
try {
|
|
1317
|
+
// A multi-stack project's declared stacks, for stack-shaped teardowns
|
|
1318
|
+
// (aws enumerates and deletes whole CloudFormation stacks). A single-stack
|
|
1319
|
+
// project passes nothing and the env-named default convention applies.
|
|
1320
|
+
const deployedStacks = (config.stacks ?? []).map((s) => ({
|
|
1321
|
+
name: s.name,
|
|
1322
|
+
...(s.region ? { region: s.region } : {}),
|
|
1323
|
+
}));
|
|
1324
|
+
plan = await planTeardown({
|
|
1325
|
+
environment,
|
|
1326
|
+
stack,
|
|
1327
|
+
plugins,
|
|
1328
|
+
...(deployedStacks.length > 0 ? { deployedStacks } : {}),
|
|
1329
|
+
});
|
|
1330
|
+
if (args.yes) {
|
|
1331
|
+
report = await executeTeardown({
|
|
1332
|
+
environment,
|
|
1333
|
+
stack,
|
|
1334
|
+
plugins,
|
|
1335
|
+
plan,
|
|
1336
|
+
...(deployedStacks.length > 0 ? { deployedStacks } : {}),
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
} finally {
|
|
1340
|
+
endpointResult.restore();
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
if (args.json) {
|
|
1344
|
+
console.log(JSON.stringify(report ?? plan, null, 2));
|
|
1345
|
+
return report && report.outcomes.some((o) => o.outcome === "failed") ? 1 : 0;
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
console.log(formatBold(
|
|
1349
|
+
args.yes
|
|
1350
|
+
? `Teardown — environment: ${environment}, stack: ${stack}`
|
|
1351
|
+
: `Teardown plan — environment: ${environment}, stack: ${stack} (plan only — nothing is deleted)`,
|
|
1352
|
+
));
|
|
1353
|
+
|
|
1354
|
+
if (plan.entries.length === 0) {
|
|
1355
|
+
console.error(formatWarning({
|
|
1356
|
+
message: `No live resources carry the marker stack "${stack}" + env "${environment}" — nothing would be deleted.` +
|
|
1357
|
+
(plan.holes.length > 0
|
|
1358
|
+
? " But this plan has holes (below) — parts of the estate could not be read, so \"nothing\" is a claim about what was readable, not about the environment."
|
|
1359
|
+
: " If this environment is deployed, check that its resources were stamped (ownership marking on, and the env identity resolved at build time)."),
|
|
1360
|
+
}));
|
|
1361
|
+
} else {
|
|
1362
|
+
console.log(`\n${plan.entries.length} resource(s) would be deleted:`);
|
|
1363
|
+
console.log("RESOURCE".padEnd(28) + "TYPE".padEnd(32) + "MARKER".padEnd(24) + "LEXICON");
|
|
1364
|
+
console.log("-".repeat(96));
|
|
1365
|
+
for (const entry of plan.entries) {
|
|
1366
|
+
console.log(
|
|
1367
|
+
entry.name.padEnd(28) +
|
|
1368
|
+
entry.type.padEnd(32) +
|
|
1369
|
+
`${entry.marker.stack}/${entry.marker.env}`.padEnd(24) +
|
|
1370
|
+
entry.lexicon,
|
|
1371
|
+
);
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
// Holes are loud (#1089): an unreadable kind is unknown, not absent, and the
|
|
1376
|
+
// execution half must not treat this plan as the whole delete set.
|
|
1377
|
+
if (plan.holes.length > 0) {
|
|
1378
|
+
console.error(formatWarning({
|
|
1379
|
+
message: `${plan.holes.length} hole(s) — resources chant may own but could not read. This plan is incomplete, not clean.`,
|
|
1380
|
+
}));
|
|
1381
|
+
console.log(formatBold("\nHOLES (stamped kinds the read could not cover):"));
|
|
1382
|
+
for (const hole of plan.holes) {
|
|
1383
|
+
console.log(` ? ${hole.lexicon}: ${hole.name}${hole.type ? ` (${hole.type})` : ""} — ${hole.reason}${hole.detail ? `: ${hole.detail}` : ""}`);
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
if (plan.skipped.length > 0) {
|
|
1388
|
+
console.error(formatWarning({
|
|
1389
|
+
message: `Skipped (no teardown enumeration and no describeResources): ${plan.skipped.join(", ")} — those lexicons' resources are not in this plan.`,
|
|
1390
|
+
}));
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
if (!report) {
|
|
1394
|
+
console.error(formatWarning({ message: "Plan only — re-run with --yes to execute." }));
|
|
1395
|
+
return 0;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
if (report.outcomes.length > 0) {
|
|
1399
|
+
console.log(formatBold("\nOutcomes:"));
|
|
1400
|
+
console.log("RESOURCE".padEnd(28) + "OUTCOME".padEnd(14) + "LEXICON".padEnd(12) + "DETAIL");
|
|
1401
|
+
console.log("-".repeat(96));
|
|
1402
|
+
for (const o of report.outcomes) {
|
|
1403
|
+
console.log(
|
|
1404
|
+
o.name.padEnd(28) +
|
|
1405
|
+
o.outcome.padEnd(14) +
|
|
1406
|
+
o.lexicon.padEnd(12) +
|
|
1407
|
+
(o.detail ?? "") +
|
|
1408
|
+
(o.retried ? " (after retry)" : ""),
|
|
1409
|
+
);
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
const counts = { deleted: 0, failed: 0, "not-prunable": 0, skipped: 0 };
|
|
1414
|
+
for (const o of report.outcomes) counts[o.outcome]++;
|
|
1415
|
+
console.log(
|
|
1416
|
+
`\n${counts.deleted} deleted, ${counts.failed} failed, ${counts["not-prunable"]} not prunable, ${counts.skipped} skipped`,
|
|
1417
|
+
);
|
|
1418
|
+
|
|
1419
|
+
if (report.unimplemented.length > 0) {
|
|
1420
|
+
console.error(formatWarning({
|
|
1421
|
+
message: `Not executed (no teardown execution in these lexicons yet): ${report.unimplemented.join(", ")} — their candidates are reported as skipped, not deleted.`,
|
|
1422
|
+
}));
|
|
1423
|
+
}
|
|
1424
|
+
if (plan.holes.length > 0) {
|
|
1425
|
+
console.error(formatWarning({
|
|
1426
|
+
message: "This teardown ran over an incomplete plan (holes above) — the environment cannot be called clean.",
|
|
1427
|
+
}));
|
|
1428
|
+
}
|
|
1429
|
+
if (counts.failed > 0) {
|
|
1430
|
+
console.error(formatError({
|
|
1431
|
+
message: `${counts.failed} candidate(s) failed to delete after the retry pass — see the outcomes above.`,
|
|
1432
|
+
}));
|
|
1433
|
+
return 1;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
return 0;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
/**
|
|
1440
|
+
* The interactive half of the prod guard: the operator re-types the
|
|
1441
|
+
* environment name. Anything else — including EOF — refuses.
|
|
1442
|
+
*/
|
|
1443
|
+
async function promptProdTeardown(environment: string): Promise<boolean> {
|
|
1444
|
+
const { createInterface } = await import("node:readline");
|
|
1445
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1446
|
+
return new Promise((resolvePrompt) => {
|
|
1447
|
+
rl.question(
|
|
1448
|
+
`"${environment}" looks like a production environment. Type the environment name to confirm teardown: `,
|
|
1449
|
+
(answer) => {
|
|
1450
|
+
resolvePrompt(answer.trim() === environment);
|
|
1451
|
+
rl.close();
|
|
1452
|
+
},
|
|
1453
|
+
);
|
|
1454
|
+
// EOF (Ctrl-D) closes the interface without answering — that is a refusal.
|
|
1455
|
+
rl.on("close", () => resolvePrompt(false));
|
|
1456
|
+
});
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1243
1459
|
/**
|
|
1244
1460
|
* chant lifecycle log [environment]
|
|
1245
1461
|
*/
|
|
@@ -1268,7 +1484,7 @@ export async function runLifecycleLog(ctx: CommandContext): Promise<number> {
|
|
|
1268
1484
|
export async function runLifecycleUnknown(ctx: CommandContext): Promise<number> {
|
|
1269
1485
|
console.error(formatError({
|
|
1270
1486
|
message: `Unknown state subcommand: ${ctx.args.extraPositional ?? ctx.args.path}`,
|
|
1271
|
-
hint: "Available: chant lifecycle snapshot, chant lifecycle show, chant lifecycle diff, chant lifecycle plan, chant lifecycle log",
|
|
1487
|
+
hint: "Available: chant lifecycle snapshot, chant lifecycle show, chant lifecycle diff, chant lifecycle plan, chant lifecycle teardown, chant lifecycle log",
|
|
1272
1488
|
}));
|
|
1273
1489
|
return 1;
|
|
1274
1490
|
}
|
|
@@ -9,7 +9,7 @@ import { discover } from "../../discovery/index";
|
|
|
9
9
|
import { observeResources } from "../../lifecycle/observe";
|
|
10
10
|
import { replaySnapshots, hasSnapshot } from "../../lifecycle/replay";
|
|
11
11
|
import type { LiveObservation } from "../../graph-ir";
|
|
12
|
-
import { loadChantConfig } from "../../config";
|
|
12
|
+
import { loadChantConfig, matchesDeclaredEnvironment } from "../../config";
|
|
13
13
|
import { loadPlugins, resolveProjectLexicons } from "../plugins";
|
|
14
14
|
import { formatError, formatWarning } from "../format";
|
|
15
15
|
import type { CommandContext } from "../registry";
|
|
@@ -89,7 +89,10 @@ export async function runSearch(ctx: CommandContext): Promise<number> {
|
|
|
89
89
|
}));
|
|
90
90
|
return 1;
|
|
91
91
|
}
|
|
92
|
-
|
|
92
|
+
// Membership via matchesDeclaredEnvironment (#1221): handles `{ name,
|
|
93
|
+
// endpoint }` entries (#1166) — a plain `.includes` never matched those —
|
|
94
|
+
// and glob-pattern entries like `"pr-*"`.
|
|
95
|
+
if (config.environments && config.environments.length > 0 && !matchesDeclaredEnvironment(config.environments, environment)) {
|
|
93
96
|
console.error(formatError({ message: `Unknown environment "${environment}"` }));
|
|
94
97
|
return 1;
|
|
95
98
|
}
|
package/src/cli/main.ts
CHANGED
|
@@ -23,7 +23,7 @@ import { runCarveAdvise, runCarveUnknown } from "./handlers/carve";
|
|
|
23
23
|
import { runCarveEmit } from "./handlers/carve-emit";
|
|
24
24
|
import { runCarveBridge } from "./handlers/carve-bridge";
|
|
25
25
|
import { runCarveApply } from "./handlers/carve-apply";
|
|
26
|
-
import { runLifecycleSnapshot, runLifecycleShow, runLifecycleDiff, runLifecycleRollback, runLifecyclePlan, runLifecycleAffected, runLifecycleLog, runLifecycleUnknown } from "./handlers/lifecycle";
|
|
26
|
+
import { runLifecycleSnapshot, runLifecycleShow, runLifecycleDiff, runLifecycleRollback, runLifecyclePlan, runLifecycleAffected, runLifecycleLog, runLifecycleTeardown, runLifecycleUnknown } from "./handlers/lifecycle";
|
|
27
27
|
import { runComponentsStatus, runComponentsReleaseRecord, runComponentsUnknown } from "./handlers/components";
|
|
28
28
|
import { runGraph } from "./handlers/graph";
|
|
29
29
|
import { runExplain } from "./handlers/explain";
|
|
@@ -78,6 +78,8 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
78
78
|
"--fold",
|
|
79
79
|
"--no-fold",
|
|
80
80
|
"--sandbox",
|
|
81
|
+
"--yes",
|
|
82
|
+
"--confirm-prod",
|
|
81
83
|
]);
|
|
82
84
|
|
|
83
85
|
/**
|
|
@@ -228,6 +230,10 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
228
230
|
result.emit = args[++i];
|
|
229
231
|
} else if (arg === "--dry-run") {
|
|
230
232
|
result.dryRun = true;
|
|
233
|
+
} else if (arg === "--yes") {
|
|
234
|
+
result.yes = true;
|
|
235
|
+
} else if (arg === "--confirm-prod") {
|
|
236
|
+
result.confirmProd = true;
|
|
231
237
|
} else if (arg === "--strict") {
|
|
232
238
|
result.strict = true;
|
|
233
239
|
} else if (arg === "--validate") {
|
|
@@ -484,6 +490,10 @@ Lifecycle (alias: lc):
|
|
|
484
490
|
lifecycle plan <env> Typed change set (create/update/delete/adopt) vs live
|
|
485
491
|
lifecycle affected Stacks a change affects (--base <ref> [--include-dependents])
|
|
486
492
|
--json: emit the ChangeSet as JSON
|
|
493
|
+
lifecycle teardown <env> Plan what deleting the environment would remove —
|
|
494
|
+
marker-scoped (this project's stack + env); --yes
|
|
495
|
+
executes the plan (production-like names also need
|
|
496
|
+
--confirm-prod, or an interactive confirmation)
|
|
487
497
|
lifecycle log [env] History of lifecycle snapshots
|
|
488
498
|
|
|
489
499
|
Component release ledger + status:
|
|
@@ -780,6 +790,7 @@ const registry: CommandDef[] = [
|
|
|
780
790
|
{ name: "lifecycle rollback", handler: runLifecycleRollback },
|
|
781
791
|
{ name: "lifecycle plan", requiresPlugins: true, handler: runLifecyclePlan },
|
|
782
792
|
{ name: "lifecycle affected", requiresPlugins: true, handler: runLifecycleAffected },
|
|
793
|
+
{ name: "lifecycle teardown", requiresPlugins: true, handler: runLifecycleTeardown },
|
|
783
794
|
{ name: "lifecycle log", handler: runLifecycleLog },
|
|
784
795
|
|
|
785
796
|
// Component release ledger + status surface (#568, epic #551)
|
package/src/cli/registry.ts
CHANGED
|
@@ -85,6 +85,13 @@ export interface ParsedArgs {
|
|
|
85
85
|
namespace?: string;
|
|
86
86
|
/** `chant lifecycle rollback --dry-run` — compute the rollback delta and print it; open no PR, push nothing, leave no branch. */
|
|
87
87
|
dryRun?: boolean;
|
|
88
|
+
/** `chant lifecycle teardown <env> --yes` — execute the planned deletion
|
|
89
|
+
* (#1222). Without it the command plans and stops. */
|
|
90
|
+
yes?: boolean;
|
|
91
|
+
/** `chant lifecycle teardown <env> --yes --confirm-prod` — the non-interactive
|
|
92
|
+
* form of the extra confirmation a production-like environment name demands
|
|
93
|
+
* (#1222). Meaningless without `--yes`. */
|
|
94
|
+
confirmProd?: boolean;
|
|
88
95
|
/** `chant import --verbatim` — keep server-defaulted fields in live import */
|
|
89
96
|
verbatim?: boolean;
|
|
90
97
|
/** `chant lifecycle … --src <dir>` — build root override for lifecycle commands */
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
import { existsSync, readFileSync, writeFileSync, readdirSync, type Dirent } from "fs";
|
|
26
26
|
import { join } from "path";
|
|
27
27
|
import { execSync } from "child_process";
|
|
28
|
+
import { SNAPSHOT_UPDATE_ENV } from "./validate";
|
|
28
29
|
import {
|
|
29
30
|
extractSurface,
|
|
30
31
|
diffSurface,
|
|
@@ -114,6 +115,17 @@ export interface RegenOptions {
|
|
|
114
115
|
* sha256:<64-hex-chars> <relative-path-to-spec-file>
|
|
115
116
|
*/
|
|
116
117
|
pinnedDigestPath?: string;
|
|
118
|
+
/**
|
|
119
|
+
* This run is about to rewrite the baseline snapshot (#1825).
|
|
120
|
+
*
|
|
121
|
+
* The `validate` step runs with {@link SNAPSHOT_UPDATE_ENV} set so its
|
|
122
|
+
* `surface-matches-snapshot` check is skipped. A lexicon whose snapshot gate
|
|
123
|
+
* runs in `"always"` mode (#1475) fails validate on a stale baseline, and a
|
|
124
|
+
* stale baseline is exactly what a re-baseline starts from — without this
|
|
125
|
+
* exemption the documented update flow deadlocks. Every other validate check
|
|
126
|
+
* still gates the run, so a broken generate cannot be baselined.
|
|
127
|
+
*/
|
|
128
|
+
updatingSnapshot?: boolean;
|
|
117
129
|
}
|
|
118
130
|
|
|
119
131
|
// ── Pipeline ──────────────────────────────────────────────────────────
|
|
@@ -132,6 +144,7 @@ export async function regenLexicon(opts: RegenOptions): Promise<RegenResult> {
|
|
|
132
144
|
skipBuild = false,
|
|
133
145
|
skipLint = false,
|
|
134
146
|
skipExamples = true,
|
|
147
|
+
updatingSnapshot = false,
|
|
135
148
|
} = opts;
|
|
136
149
|
|
|
137
150
|
const failures: RegenFailure[] = [];
|
|
@@ -177,7 +190,12 @@ export async function regenLexicon(opts: RegenOptions): Promise<RegenResult> {
|
|
|
177
190
|
}
|
|
178
191
|
|
|
179
192
|
// Step 4: validate
|
|
180
|
-
|
|
193
|
+
// A re-baseline run is exempt from the surface-matches-snapshot check only
|
|
194
|
+
// (#1825) — see RegenOptions.updatingSnapshot.
|
|
195
|
+
const validateEnv: NodeJS.ProcessEnv = updatingSnapshot
|
|
196
|
+
? { ...process.env, [SNAPSHOT_UPDATE_ENV]: "1" }
|
|
197
|
+
: process.env;
|
|
198
|
+
const validateFail = runScript(lexiconDir, "validate", validateEnv, verbose);
|
|
181
199
|
if (validateFail) {
|
|
182
200
|
failures.push(validateFail);
|
|
183
201
|
}
|
|
@@ -182,4 +182,37 @@ describe("surface snapshot gate (#1473)", () => {
|
|
|
182
182
|
const check = result.checks.find((c) => c.name === "surface-matches-snapshot");
|
|
183
183
|
expect(check?.ok).toBe(false);
|
|
184
184
|
});
|
|
185
|
+
|
|
186
|
+
test("an update run skips the staleness check it is about to fix (#1825)", async () => {
|
|
187
|
+
// `--update-snapshot` sets CHANT_SNAPSHOT_UPDATE for its validate run.
|
|
188
|
+
// Without this, an "always" gate deadlocks the documented re-baseline flow:
|
|
189
|
+
// validate fails on the stale snapshot, and the update writes only after a
|
|
190
|
+
// green validate.
|
|
191
|
+
const stale = JSON.stringify({ schemaVersion: 1, generatedAt: "2026-01-01T00:00:00.000Z", entries: {} });
|
|
192
|
+
const result = await validateLexiconArtifacts({
|
|
193
|
+
lexiconJsonFilename: "lexicon-test.json",
|
|
194
|
+
requiredNames: [],
|
|
195
|
+
basePath: fixture({ snapshot: stale }),
|
|
196
|
+
checkSurfaceSnapshot: "always",
|
|
197
|
+
env: { CHANT_SNAPSHOT_UPDATE: "1" },
|
|
198
|
+
});
|
|
199
|
+
expect(result.checks.find((c) => c.name === "surface-matches-snapshot")).toBeUndefined();
|
|
200
|
+
expect(result.success).toBe(true);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("an update run is exempt from the staleness check and nothing else", async () => {
|
|
204
|
+
// A failing check other than surface-matches-snapshot must still refuse
|
|
205
|
+
// the run, so a broken generate cannot be baselined.
|
|
206
|
+
const stale = JSON.stringify({ schemaVersion: 1, generatedAt: "2026-01-01T00:00:00.000Z", entries: {} });
|
|
207
|
+
const result = await validateLexiconArtifacts({
|
|
208
|
+
lexiconJsonFilename: "lexicon-test.json",
|
|
209
|
+
requiredNames: ["NotThere"],
|
|
210
|
+
basePath: fixture({ snapshot: stale }),
|
|
211
|
+
checkSurfaceSnapshot: "always",
|
|
212
|
+
env: { CHANT_SNAPSHOT_UPDATE: "1" },
|
|
213
|
+
});
|
|
214
|
+
expect(result.checks.find((c) => c.name === "surface-matches-snapshot")).toBeUndefined();
|
|
215
|
+
expect(result.checks.find((c) => c.name === "required-names")?.ok).toBe(false);
|
|
216
|
+
expect(result.success).toBe(false);
|
|
217
|
+
});
|
|
185
218
|
});
|