@intentius/chant 0.27.0 → 0.29.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/lint.d.ts.map +1 -1
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts +5 -3
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/config.d.ts +46 -4
- package/dist/config.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +153 -17
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/discovery/sandbox/config-run.d.ts.map +1 -1
- package/dist/discovery/sandbox/config-wire.d.ts +3 -2
- package/dist/discovery/sandbox/config-wire.d.ts.map +1 -1
- package/dist/discovery/sandbox/fork.d.ts +25 -0
- package/dist/discovery/sandbox/fork.d.ts.map +1 -1
- package/dist/discovery/sandbox/policy-run.d.ts.map +1 -1
- package/dist/discovery/sandbox/run.d.ts.map +1 -1
- package/dist/env.d.ts +5 -2
- package/dist/env.d.ts.map +1 -1
- package/dist/fold/fold.d.ts +12 -0
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +29 -4
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/kubectl-context.d.ts +100 -0
- package/dist/kubectl-context.d.ts.map +1 -0
- package/dist/lexicon.d.ts +31 -6
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/change-set.d.ts +26 -5
- package/dist/lifecycle/change-set.d.ts.map +1 -1
- package/dist/lifecycle/live-diff.d.ts +25 -1
- package/dist/lifecycle/live-diff.d.ts.map +1 -1
- package/dist/lifecycle/observe.d.ts +4 -2
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/lifecycle/snapshot.d.ts.map +1 -1
- package/dist/lifecycle/status.d.ts +26 -1
- package/dist/lifecycle/status.d.ts.map +1 -1
- package/dist/lifecycle/types.d.ts +8 -0
- package/dist/lifecycle/types.d.ts.map +1 -1
- package/dist/lint/config.d.ts +80 -0
- package/dist/lint/config.d.ts.map +1 -1
- package/dist/lint/policy.d.ts +8 -2
- package/dist/lint/policy.d.ts.map +1 -1
- package/dist/lint/post-synth.d.ts +18 -1
- package/dist/lint/post-synth.d.ts.map +1 -1
- package/dist/live-endpoint.d.ts +92 -0
- package/dist/live-endpoint.d.ts.map +1 -0
- package/dist/observation.d.ts +123 -0
- package/dist/observation.d.ts.map +1 -0
- package/dist/stack-output.d.ts +9 -4
- package/dist/stack-output.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/build.test.ts +190 -0
- package/src/cli/commands/build.ts +34 -2
- package/src/cli/commands/lint.ts +17 -25
- package/src/cli/handlers/components.test.ts +63 -4
- package/src/cli/handlers/components.ts +78 -35
- package/src/cli/handlers/graph.test.ts +69 -6
- package/src/cli/handlers/graph.ts +61 -27
- package/src/cli/handlers/lifecycle.test.ts +285 -6
- package/src/cli/handlers/lifecycle.ts +297 -185
- package/src/config.test.ts +75 -0
- package/src/config.ts +61 -3
- package/src/discovery/fold-composite.test.ts +594 -0
- package/src/discovery/fold-import.ts +987 -43
- package/src/discovery/sandbox/config-boundary.test.ts +55 -1
- package/src/discovery/sandbox/config-run.ts +3 -0
- package/src/discovery/sandbox/config-wire.ts +3 -2
- package/src/discovery/sandbox/fork.ts +75 -1
- package/src/discovery/sandbox/policy-boundary.test.ts +56 -1
- package/src/discovery/sandbox/policy-run.ts +15 -1
- package/src/discovery/sandbox/run.test.ts +85 -1
- package/src/discovery/sandbox/run.ts +3 -0
- package/src/env.test.ts +12 -0
- package/src/env.ts +12 -4
- package/src/fold/fold.ts +12 -2
- package/src/graph-ir-live.test.ts +28 -1
- package/src/graph-ir.ts +68 -12
- package/src/index.ts +1 -0
- package/src/kubectl-context.test.ts +94 -0
- package/src/kubectl-context.ts +207 -0
- package/src/lexicon.ts +41 -6
- package/src/lifecycle/change-set.test.ts +93 -1
- package/src/lifecycle/change-set.ts +65 -13
- package/src/lifecycle/live-diff.test.ts +39 -0
- package/src/lifecycle/live-diff.ts +51 -5
- package/src/lifecycle/observe.test.ts +74 -3
- package/src/lifecycle/observe.ts +82 -22
- package/src/lifecycle/snapshot.test.ts +39 -1
- package/src/lifecycle/snapshot.ts +34 -9
- package/src/lifecycle/status.test.ts +89 -8
- package/src/lifecycle/status.ts +53 -3
- package/src/lifecycle/types.ts +8 -0
- package/src/lint/config.test.ts +93 -1
- package/src/lint/config.ts +108 -0
- package/src/lint/policy.test.ts +90 -0
- package/src/lint/policy.ts +17 -5
- package/src/lint/post-synth.test.ts +4 -0
- package/src/lint/post-synth.ts +30 -1
- package/src/live-endpoint.test.ts +115 -0
- package/src/live-endpoint.ts +148 -0
- package/src/observation.test.ts +96 -0
- package/src/observation.ts +213 -0
- package/src/stack-output.test.ts +76 -3
- package/src/stack-output.ts +59 -26
|
@@ -4,12 +4,21 @@ import { takeSnapshot } from "../../lifecycle/snapshot";
|
|
|
4
4
|
import { readSnapshot, readSnapshotAt, readEnvironmentSnapshots, listSnapshots, fetchLifecycle, snapshotStorageKey, StaleLifecycleBranchError } from "../../lifecycle/git";
|
|
5
5
|
import { computeBuildDigest, diffDigests } from "../../lifecycle/digest";
|
|
6
6
|
import { diffLive, diffLiveArtifacts, diffSnapshots, type LiveDiffResult, type LiveArtifactDiffResult, type SnapshotDiffResult } from "../../lifecycle/live-diff";
|
|
7
|
-
import { buildChangeSet, renderChangeSet, gitlabMrReport, type ChangeSet } from "../../lifecycle/change-set";
|
|
7
|
+
import { buildChangeSet, renderChangeSet, gitlabMrReport, summarize, type ChangeSet } from "../../lifecycle/change-set";
|
|
8
|
+
import {
|
|
9
|
+
formatUnobserved,
|
|
10
|
+
mergeObservations,
|
|
11
|
+
normalizeObservation,
|
|
12
|
+
unobservedAll,
|
|
13
|
+
type NormalizedObservation,
|
|
14
|
+
type UnobservedEntity,
|
|
15
|
+
} from "../../observation";
|
|
8
16
|
import { discoverComponents } from "../../components/discover";
|
|
9
17
|
import { cfnDeployStacks } from "./components";
|
|
10
18
|
import { affectedStacks } from "../../lifecycle/affected";
|
|
11
19
|
import { rollbackToRevision } from "../../lifecycle/rollback";
|
|
12
|
-
import { loadChantConfig } from "../../config";
|
|
20
|
+
import { loadChantConfig, environmentNames } from "../../config";
|
|
21
|
+
import { applyLiveEndpoint } from "../../live-endpoint";
|
|
13
22
|
import { formatError, formatWarning, formatSuccess, formatBold } from "../format";
|
|
14
23
|
import type { CommandContext } from "../registry";
|
|
15
24
|
import type { LifecycleSnapshot } from "../../lifecycle/types";
|
|
@@ -75,10 +84,11 @@ export async function runLifecycleSnapshot(ctx: CommandContext): Promise<number>
|
|
|
75
84
|
// Validate environment against config
|
|
76
85
|
const projectPath = resolve(".");
|
|
77
86
|
const { config } = await loadChantConfig(projectPath);
|
|
78
|
-
|
|
87
|
+
const declaredEnvNames = environmentNames(config.environments);
|
|
88
|
+
if (declaredEnvNames && !declaredEnvNames.includes(environment)) {
|
|
79
89
|
console.error(formatError({
|
|
80
90
|
message: `Unknown environment "${environment}"`,
|
|
81
|
-
hint: `Defined environments: ${
|
|
91
|
+
hint: `Defined environments: ${declaredEnvNames.join(", ")}`,
|
|
82
92
|
}));
|
|
83
93
|
return 1;
|
|
84
94
|
}
|
|
@@ -105,48 +115,58 @@ export async function runLifecycleSnapshot(ctx: CommandContext): Promise<number>
|
|
|
105
115
|
let anySnapshotSaved = false;
|
|
106
116
|
let anyHardError = false;
|
|
107
117
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
anyHardError = true;
|
|
114
|
-
continue;
|
|
115
|
-
}
|
|
118
|
+
// #1166 — same self-sufficiency as `chant graph --live`: a snapshot is
|
|
119
|
+
// always a live read, so an environment's declared endpoint applies here
|
|
120
|
+
// too, unless the ambient shell already set it.
|
|
121
|
+
const endpointResult = applyLiveEndpoint(config.environments, environment, observingPlugins.map((p) => p.name));
|
|
122
|
+
if (endpointResult.notice) console.error(formatWarning({ message: endpointResult.notice }));
|
|
116
123
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (
|
|
122
|
-
console.error(formatError({
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}));
|
|
126
|
-
return 1;
|
|
124
|
+
try {
|
|
125
|
+
for (const target of targets) {
|
|
126
|
+
const label = target.stack ? `stack "${target.stack}"` : "project";
|
|
127
|
+
const buildResult = await build(target.root, targetSerializers);
|
|
128
|
+
if (buildResult.errors.length > 0) {
|
|
129
|
+
console.error(formatError({ message: `Build failed for ${label} — fix errors before taking a snapshot` }));
|
|
130
|
+
anyHardError = true;
|
|
131
|
+
continue;
|
|
127
132
|
}
|
|
128
|
-
throw err;
|
|
129
|
-
}
|
|
130
133
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
134
|
+
let result;
|
|
135
|
+
try {
|
|
136
|
+
result = await takeSnapshot(environment, observingPlugins, buildResult, { stack: target.stack });
|
|
137
|
+
} catch (err) {
|
|
138
|
+
if (err instanceof StaleLifecycleBranchError) {
|
|
139
|
+
console.error(formatError({
|
|
140
|
+
message: `Another snapshot completed for chant/lifecycle after this run started (env: ${environment}).`,
|
|
141
|
+
hint: `Pull and retry: \`git fetch origin ${"chant/lifecycle"}:${"chant/lifecycle"}\` && \`chant lifecycle snapshot ${environment}\`.`,
|
|
142
|
+
}));
|
|
143
|
+
return 1;
|
|
144
|
+
}
|
|
145
|
+
throw err;
|
|
146
|
+
}
|
|
137
147
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
.
|
|
143
|
-
|
|
144
|
-
|
|
148
|
+
for (const w of result.warnings) {
|
|
149
|
+
console.error(formatWarning({ message: w }));
|
|
150
|
+
}
|
|
151
|
+
for (const e of result.errors) {
|
|
152
|
+
console.error(formatError({ message: e }));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (result.snapshots.length > 0) {
|
|
156
|
+
anySnapshotSaved = true;
|
|
157
|
+
const prefix = target.stack ? `${target.stack}: ` : "";
|
|
158
|
+
const counts = result.snapshots
|
|
159
|
+
.map((s) => `${s.lexicon}(${Object.keys(s.resources).length})`)
|
|
160
|
+
.join(" ");
|
|
161
|
+
console.error(formatSuccess(`${prefix}Snapshot saved to chant/lifecycle (${counts})`));
|
|
162
|
+
}
|
|
163
|
+
if (result.errors.length > 0) anyHardError = true;
|
|
145
164
|
}
|
|
146
|
-
if (result.errors.length > 0) anyHardError = true;
|
|
147
|
-
}
|
|
148
165
|
|
|
149
|
-
|
|
166
|
+
return anyHardError && !anySnapshotSaved ? 1 : 0;
|
|
167
|
+
} finally {
|
|
168
|
+
endpointResult.restore();
|
|
169
|
+
}
|
|
150
170
|
}
|
|
151
171
|
|
|
152
172
|
/**
|
|
@@ -263,71 +283,93 @@ export async function runLifecycleDiff(ctx: CommandContext): Promise<number> {
|
|
|
263
283
|
const perStackJson: Record<string, unknown> = {};
|
|
264
284
|
let combinedLexiconsJson: Record<string, unknown> | undefined;
|
|
265
285
|
let totalDrift = 0;
|
|
286
|
+
let totalUnobserved = 0;
|
|
266
287
|
let totalChecked = 0;
|
|
267
288
|
let anyBuildError = false;
|
|
268
289
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
}
|
|
290
|
+
// #1166 — an environment can declare its own endpoint (a local emulator like
|
|
291
|
+
// Floci), so `--live` is self-sufficient even when the ambient shell never
|
|
292
|
+
// exported e.g. AWS_ENDPOINT_URL. Ambient always wins when it's already set.
|
|
293
|
+
// Scoped to just the live reads below — restored in `finally`.
|
|
294
|
+
const liveLexicons = args.live ? plugins.filter((p) => p.describeResources || p.listArtifacts).map((p) => p.name) : [];
|
|
295
|
+
const endpointResult = applyLiveEndpoint(config.environments, environment, liveLexicons);
|
|
296
|
+
if (endpointResult.notice) console.error(formatWarning({ message: endpointResult.notice }));
|
|
277
297
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
298
|
+
try {
|
|
299
|
+
for (const target of targets) {
|
|
300
|
+
const buildResult = await build(target.root, targetSerializers);
|
|
301
|
+
if (buildResult.errors.length > 0) {
|
|
302
|
+
const label = target.stack ? `stack "${target.stack}"` : "project";
|
|
303
|
+
console.error(formatError({ message: `Build failed for ${label} — fix errors before diffing` }));
|
|
304
|
+
anyBuildError = true;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
281
307
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
308
|
+
const lexicons = lexiconFilter
|
|
309
|
+
? [lexiconFilter]
|
|
310
|
+
: Array.from(buildResult.manifest.lexicons);
|
|
311
|
+
|
|
312
|
+
if (args.live) {
|
|
313
|
+
// Multi-stack component projects: observe each component's own cfn stack
|
|
314
|
+
// and union (same fix runGraphLive / plan use), so a deployed resource
|
|
315
|
+
// isn't reported "missing". Only when this target has no explicit stack.
|
|
316
|
+
let componentStacks: string[] = [];
|
|
317
|
+
if (!target.stack) {
|
|
318
|
+
try {
|
|
319
|
+
const disc = await discoverComponents(target.root, { sandbox: args.sandbox });
|
|
320
|
+
const set = new Set<string>();
|
|
321
|
+
for (const { component } of disc.components.values()) for (const s of cfnDeployStacks(component.deploy)) set.add(s);
|
|
322
|
+
componentStacks = [...set];
|
|
323
|
+
} catch {
|
|
324
|
+
// no components / discovery failed → single-stack observe path
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const r = await runLifecycleDiffLive({ environment, lexicons, plugins, buildResult, json, stack: target.stack, componentStacks });
|
|
328
|
+
totalDrift += r.totalDrift;
|
|
329
|
+
totalUnobserved += r.totalUnobserved;
|
|
330
|
+
totalChecked += r.totalLexiconsChecked;
|
|
331
|
+
if (json) {
|
|
332
|
+
if (target.stack) perStackJson[target.stack] = r.byLexicon;
|
|
333
|
+
else combinedLexiconsJson = r.byLexicon;
|
|
295
334
|
}
|
|
335
|
+
} else {
|
|
336
|
+
await runLifecycleDiffDigest({ environment, lexicons, buildResult, stack: target.stack });
|
|
296
337
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (args.live) {
|
|
300
341
|
if (json) {
|
|
301
|
-
|
|
302
|
-
|
|
342
|
+
// Single-stack keeps the original `{ environment, lexicons }` shape
|
|
343
|
+
// (behold's inspect diff, #852); multi-stack nests under `stacks`.
|
|
344
|
+
console.log(
|
|
345
|
+
JSON.stringify(
|
|
346
|
+
combinedLexiconsJson !== undefined
|
|
347
|
+
? { environment, lexicons: combinedLexiconsJson }
|
|
348
|
+
: { environment, stacks: perStackJson },
|
|
349
|
+
),
|
|
350
|
+
);
|
|
351
|
+
} else if (totalChecked === 0) {
|
|
352
|
+
console.error(formatWarning({
|
|
353
|
+
message: "No lexicons implement describeResources or listArtifacts — nothing to diff in --live mode",
|
|
354
|
+
}));
|
|
355
|
+
return 1;
|
|
356
|
+
} else if (totalDrift === 0) {
|
|
357
|
+
// Qualify the all-clear when part of the estate was never read (#1089):
|
|
358
|
+
// "no drift" over an incomplete observation is not the same claim.
|
|
359
|
+
console.error(
|
|
360
|
+
totalUnobserved > 0
|
|
361
|
+
? formatWarning({
|
|
362
|
+
message: `No drift detected across ${totalChecked} lexicon(s), but ${totalUnobserved} declared entity(ies) could not be observed — that part of the estate is unknown, not clean`,
|
|
363
|
+
})
|
|
364
|
+
: formatSuccess(`No drift detected across ${totalChecked} lexicon(s)`),
|
|
365
|
+
);
|
|
303
366
|
}
|
|
304
|
-
} else {
|
|
305
|
-
await runLifecycleDiffDigest({ environment, lexicons, buildResult, stack: target.stack });
|
|
306
367
|
}
|
|
307
|
-
}
|
|
308
368
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
// (behold's inspect diff, #852); multi-stack nests under `stacks`.
|
|
313
|
-
console.log(
|
|
314
|
-
JSON.stringify(
|
|
315
|
-
combinedLexiconsJson !== undefined
|
|
316
|
-
? { environment, lexicons: combinedLexiconsJson }
|
|
317
|
-
: { environment, stacks: perStackJson },
|
|
318
|
-
),
|
|
319
|
-
);
|
|
320
|
-
} else if (totalChecked === 0) {
|
|
321
|
-
console.error(formatWarning({
|
|
322
|
-
message: "No lexicons implement describeResources or listArtifacts — nothing to diff in --live mode",
|
|
323
|
-
}));
|
|
324
|
-
return 1;
|
|
325
|
-
} else if (totalDrift === 0) {
|
|
326
|
-
console.error(formatSuccess(`No drift detected across ${totalChecked} lexicon(s)`));
|
|
327
|
-
}
|
|
369
|
+
return anyBuildError ? 1 : 0;
|
|
370
|
+
} finally {
|
|
371
|
+
endpointResult.restore();
|
|
328
372
|
}
|
|
329
|
-
|
|
330
|
-
return anyBuildError ? 1 : 0;
|
|
331
373
|
}
|
|
332
374
|
|
|
333
375
|
interface BetweenDiffArgs {
|
|
@@ -457,17 +499,81 @@ interface LiveDiffArgs {
|
|
|
457
499
|
interface LiveDiffOutcome {
|
|
458
500
|
byLexicon: Record<
|
|
459
501
|
string,
|
|
460
|
-
{
|
|
502
|
+
{
|
|
503
|
+
resources?: LiveDiffResult;
|
|
504
|
+
observed?: Record<string, ResourceMetadata>;
|
|
505
|
+
/** Declared entities the lexicon could not read (#1089), keyed by name. */
|
|
506
|
+
unobserved?: Record<string, UnobservedEntity>;
|
|
507
|
+
artifacts?: LiveArtifactDiffResult;
|
|
508
|
+
}
|
|
461
509
|
>;
|
|
462
510
|
totalDrift: number;
|
|
511
|
+
/** Declared entities nobody could read. Not drift — a hole in the report. */
|
|
512
|
+
totalUnobserved: number;
|
|
463
513
|
totalLexiconsChecked: number;
|
|
464
514
|
}
|
|
465
515
|
|
|
516
|
+
/**
|
|
517
|
+
* Read one lexicon's live resources, resolving the observation tri-state
|
|
518
|
+
* (#1089) for every declared entity: present, confirmed-absent, or not
|
|
519
|
+
* observed with a reason.
|
|
520
|
+
*
|
|
521
|
+
* Two behaviours the old inline call sites did not have. A throw no longer
|
|
522
|
+
* skips the lexicon — every entity it was asked about comes back
|
|
523
|
+
* NOT-OBSERVED (`read-failed`), so a failed read shows up in the plan instead
|
|
524
|
+
* of vanishing from it. And a multi-stack read merges with
|
|
525
|
+
* present > not-observed > absent, so one unreadable stack cannot un-observe a
|
|
526
|
+
* resource another stack returned.
|
|
527
|
+
*/
|
|
528
|
+
async function observeLexicon(
|
|
529
|
+
plugin: ObservationLexicon,
|
|
530
|
+
opts: {
|
|
531
|
+
environment: string;
|
|
532
|
+
buildOutput: string;
|
|
533
|
+
declared: Set<string>;
|
|
534
|
+
entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
|
|
535
|
+
stack?: string;
|
|
536
|
+
componentStacks?: string[];
|
|
537
|
+
owned?: boolean;
|
|
538
|
+
},
|
|
539
|
+
): Promise<NormalizedObservation> {
|
|
540
|
+
const entityNames = Array.from(opts.declared);
|
|
541
|
+
const base = {
|
|
542
|
+
environment: opts.environment,
|
|
543
|
+
buildOutput: opts.buildOutput,
|
|
544
|
+
entityNames,
|
|
545
|
+
entities: opts.entities,
|
|
546
|
+
...(opts.owned !== undefined ? { owned: opts.owned } : {}),
|
|
547
|
+
};
|
|
548
|
+
try {
|
|
549
|
+
if (opts.componentStacks && opts.componentStacks.length > 0) {
|
|
550
|
+
const parts: NormalizedObservation[] = [];
|
|
551
|
+
for (const stack of opts.componentStacks) {
|
|
552
|
+
parts.push(normalizeObservation(await plugin.describeResources!({ ...base, stack })));
|
|
553
|
+
}
|
|
554
|
+
return mergeObservations(parts);
|
|
555
|
+
}
|
|
556
|
+
return normalizeObservation(
|
|
557
|
+
await plugin.describeResources!({ ...base, ...(opts.stack ? { stack: opts.stack } : {}) }),
|
|
558
|
+
);
|
|
559
|
+
} catch (err) {
|
|
560
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
561
|
+
console.error(formatError({
|
|
562
|
+
message: `${plugin.name}: describeResources failed — ${message} (reporting ${entityNames.length} declared entity(ies) as not observed, not as absent)`,
|
|
563
|
+
}));
|
|
564
|
+
return {
|
|
565
|
+
resources: {},
|
|
566
|
+
unobserved: unobservedAll(entityNames, "read-failed", message, opts.entities),
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
466
571
|
/** Diff current build vs live cloud for one stack. Renders the human report
|
|
467
572
|
* inline; returns per-lexicon results so the caller emits the aggregate `--json`
|
|
468
573
|
* once (single-stack keeps the original shape; multi-stack nests under `stacks`). */
|
|
469
574
|
async function runLifecycleDiffLive(args: LiveDiffArgs): Promise<LiveDiffOutcome> {
|
|
470
575
|
let totalDrift = 0;
|
|
576
|
+
let totalUnobserved = 0;
|
|
471
577
|
let totalLexiconsChecked = 0;
|
|
472
578
|
const byLexicon: LiveDiffOutcome["byLexicon"] = {};
|
|
473
579
|
if (!args.json && args.stack) console.log(`\n${formatBold(`■ stack ${args.stack}`)}`);
|
|
@@ -515,38 +621,27 @@ async function runLifecycleDiffLive(args: LiveDiffArgs): Promise<LiveDiffOutcome
|
|
|
515
621
|
|
|
516
622
|
// ── Resources path (entity-keyed) ──────────────────────────────────────
|
|
517
623
|
if (plugin.describeResources) {
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
}
|
|
528
|
-
} else {
|
|
529
|
-
observedNow = await plugin.describeResources({
|
|
530
|
-
environment: args.environment,
|
|
531
|
-
buildOutput,
|
|
532
|
-
entityNames: Array.from(declared),
|
|
533
|
-
entities,
|
|
534
|
-
stack: args.stack,
|
|
535
|
-
});
|
|
536
|
-
}
|
|
537
|
-
} catch (err) {
|
|
538
|
-
console.error(formatError({
|
|
539
|
-
message: `${lexiconName}: describeResources failed — ${err instanceof Error ? err.message : String(err)}`,
|
|
540
|
-
}));
|
|
541
|
-
continue;
|
|
542
|
-
}
|
|
624
|
+
const observed = await observeLexicon(plugin, {
|
|
625
|
+
environment: args.environment,
|
|
626
|
+
buildOutput,
|
|
627
|
+
declared,
|
|
628
|
+
entities,
|
|
629
|
+
stack: args.stack,
|
|
630
|
+
componentStacks: args.componentStacks,
|
|
631
|
+
});
|
|
632
|
+
const observedNow = observed.resources;
|
|
543
633
|
const observedThen = prevSnapshot?.resources;
|
|
544
|
-
const diff = diffLive({ declared, observedNow, observedThen });
|
|
634
|
+
const diff = diffLive({ declared, observedNow, observedThen, unobserved: observed.unobserved });
|
|
635
|
+
// Unobserved entities are deliberately NOT drift: a hole in the read is
|
|
636
|
+
// not a change in the cloud. They are reported separately (#1089) so a
|
|
637
|
+
// "no drift detected" line can never be built on top of a failed read.
|
|
545
638
|
totalDrift += diff.driftedSinceSnapshot.length + diff.missing.length + diff.orphan.length + diff.disappeared.length;
|
|
639
|
+
totalUnobserved += diff.unobserved.length;
|
|
546
640
|
if (args.json) {
|
|
547
641
|
const entry = (byLexicon[lexiconName] ??= {});
|
|
548
642
|
entry.resources = diff;
|
|
549
643
|
entry.observed = observedNow; // live state (#862) — status/attributes per resource
|
|
644
|
+
if (Object.keys(observed.unobserved).length > 0) entry.unobserved = observed.unobserved;
|
|
550
645
|
} else renderLiveDiff(lexiconName, args.environment, diff);
|
|
551
646
|
lexiconChecked = true;
|
|
552
647
|
}
|
|
@@ -573,21 +668,28 @@ async function runLifecycleDiffLive(args: LiveDiffArgs): Promise<LiveDiffOutcome
|
|
|
573
668
|
if (lexiconChecked) totalLexiconsChecked++;
|
|
574
669
|
}
|
|
575
670
|
|
|
576
|
-
return { byLexicon, totalDrift, totalLexiconsChecked };
|
|
671
|
+
return { byLexicon, totalDrift, totalUnobserved, totalLexiconsChecked };
|
|
577
672
|
}
|
|
578
673
|
|
|
579
674
|
function renderLiveDiff(lexiconName: string, environment: string, diff: LiveDiffResult): void {
|
|
580
675
|
const counts =
|
|
581
676
|
`${diff.missing.length} missing, ${diff.orphan.length} orphan, ` +
|
|
582
677
|
`${diff.disappeared.length} disappeared, ${diff.newlyObserved.length} newly observed, ` +
|
|
583
|
-
`${diff.driftedSinceSnapshot.length} drifted, ${diff.unchanged.length} unchanged
|
|
678
|
+
`${diff.driftedSinceSnapshot.length} drifted, ${diff.unchanged.length} unchanged` +
|
|
679
|
+
(diff.unobserved.length > 0 ? `, ${diff.unobserved.length} unobserved` : "");
|
|
584
680
|
|
|
585
681
|
console.log(`\n${formatBold(lexiconName)} — environment: ${environment}`);
|
|
586
682
|
console.log(counts);
|
|
587
683
|
console.log("-".repeat(80));
|
|
588
684
|
|
|
685
|
+
if (diff.unobserved.length > 0) {
|
|
686
|
+
console.log(formatBold("\nUNOBSERVED (declared; chant could not read live state — status unknown):"));
|
|
687
|
+
for (const u of diff.unobserved) {
|
|
688
|
+
console.log(` ? ${formatUnobserved(u.name, u)}`);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
589
691
|
if (diff.missing.length > 0) {
|
|
590
|
-
console.log(formatBold("\nMISSING (declared, not in cloud):"));
|
|
692
|
+
console.log(formatBold("\nMISSING (declared, provider reports not in cloud):"));
|
|
591
693
|
for (const name of diff.missing) console.log(` - ${name}`);
|
|
592
694
|
}
|
|
593
695
|
if (diff.orphan.length > 0) {
|
|
@@ -662,9 +764,11 @@ function renderLiveArtifactDiff(lexiconName: string, environment: string, diff:
|
|
|
662
764
|
* chant lifecycle plan <environment> [lexicon]
|
|
663
765
|
*
|
|
664
766
|
* Promote the live diff to a typed, read-only change set: per-entity
|
|
665
|
-
* create / update / delete / adopt / noop. Strictly read-only —
|
|
666
|
-
* mutates, never deploys. Deletes are never proposed without ownership
|
|
667
|
-
* data (added in #121); an undeclared live resource is `adopt
|
|
767
|
+
* create / update / delete / adopt / noop / unobserved. Strictly read-only —
|
|
768
|
+
* never mutates, never deploys. Deletes are never proposed without ownership
|
|
769
|
+
* data (added in #121); an undeclared live resource is `adopt`; a declared
|
|
770
|
+
* entity chant could not read is `unobserved` and gets no proposal at all
|
|
771
|
+
* (#1089) — a create is only ever proposed against a confirmed absence.
|
|
668
772
|
*/
|
|
669
773
|
export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
|
|
670
774
|
const { args, plugins, serializers } = ctx;
|
|
@@ -709,71 +813,70 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
|
|
|
709
813
|
const merged: ChangeSet = { env: environment, entries: [] };
|
|
710
814
|
let checked = 0;
|
|
711
815
|
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
console.error(formatWarning({
|
|
719
|
-
message: `${lexiconName}: lexicon does not implement describeResources — skipping (no declared axis to plan against)`,
|
|
720
|
-
}));
|
|
721
|
-
}
|
|
722
|
-
continue;
|
|
723
|
-
}
|
|
816
|
+
// #1166 — same self-sufficiency as `chant graph --live`: an environment can
|
|
817
|
+
// declare its own endpoint, applied here unless the ambient shell already
|
|
818
|
+
// set it. `chant lifecycle plan` is always a live read (no `--live` flag of
|
|
819
|
+
// its own), so this applies unconditionally.
|
|
820
|
+
const endpointResult = applyLiveEndpoint(config.environments, environment, lexicons);
|
|
821
|
+
if (endpointResult.notice) console.error(formatWarning({ message: endpointResult.notice }));
|
|
724
822
|
|
|
725
|
-
|
|
726
|
-
const
|
|
727
|
-
|
|
728
|
-
if (
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
823
|
+
try {
|
|
824
|
+
for (const lexiconName of lexicons) {
|
|
825
|
+
const plugin = plugins.find((p) => p.name === lexiconName);
|
|
826
|
+
if (!plugin) continue;
|
|
827
|
+
if (!plugin.describeResources) {
|
|
828
|
+
// Plan is entity-keyed; artifact-only lexicons have no declared axis.
|
|
829
|
+
if (!args.json) {
|
|
830
|
+
console.error(formatWarning({
|
|
831
|
+
message: `${lexiconName}: lexicon does not implement describeResources — skipping (no declared axis to plan against)`,
|
|
832
|
+
}));
|
|
833
|
+
}
|
|
834
|
+
continue;
|
|
734
835
|
}
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
const rawOutput = buildResult.outputs.get(lexiconName);
|
|
738
|
-
const buildOutput =
|
|
739
|
-
rawOutput === undefined
|
|
740
|
-
? ""
|
|
741
|
-
: typeof rawOutput === "string"
|
|
742
|
-
? rawOutput
|
|
743
|
-
: (rawOutput as SerializerResult).primary;
|
|
744
836
|
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
);
|
|
837
|
+
const declared = new Set<string>();
|
|
838
|
+
const entities = new Map<string, { entityType: string; props: Record<string, unknown> }>();
|
|
839
|
+
for (const [name, entity] of buildResult.entities) {
|
|
840
|
+
if (entity.lexicon === lexiconName) {
|
|
841
|
+
declared.add(name);
|
|
842
|
+
entities.set(name, {
|
|
843
|
+
entityType: entity.entityType,
|
|
844
|
+
props: ("props" in entity && entity.props != null ? entity.props : {}) as Record<string, unknown>,
|
|
845
|
+
});
|
|
754
846
|
}
|
|
755
|
-
} else {
|
|
756
|
-
observedNow = await plugin.describeResources({
|
|
757
|
-
environment,
|
|
758
|
-
buildOutput,
|
|
759
|
-
entityNames: Array.from(declared),
|
|
760
|
-
entities,
|
|
761
|
-
owned: args.owned,
|
|
762
|
-
});
|
|
763
847
|
}
|
|
764
|
-
} catch (err) {
|
|
765
|
-
console.error(formatError({
|
|
766
|
-
message: `${lexiconName}: describeResources failed — ${err instanceof Error ? err.message : String(err)}`,
|
|
767
|
-
}));
|
|
768
|
-
continue;
|
|
769
|
-
}
|
|
770
848
|
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
849
|
+
const rawOutput = buildResult.outputs.get(lexiconName);
|
|
850
|
+
const buildOutput =
|
|
851
|
+
rawOutput === undefined
|
|
852
|
+
? ""
|
|
853
|
+
: typeof rawOutput === "string"
|
|
854
|
+
? rawOutput
|
|
855
|
+
: (rawOutput as SerializerResult).primary;
|
|
856
|
+
|
|
857
|
+
const observed = await observeLexicon(plugin, {
|
|
858
|
+
environment,
|
|
859
|
+
buildOutput,
|
|
860
|
+
declared,
|
|
861
|
+
entities,
|
|
862
|
+
componentStacks,
|
|
863
|
+
owned: args.owned,
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
const content = await readSnapshot(environment, lexiconName);
|
|
867
|
+
const observedThen = content ? (JSON.parse(content) as LifecycleSnapshot).resources : undefined;
|
|
868
|
+
|
|
869
|
+
const cs = buildChangeSet(environment, {
|
|
870
|
+
declared,
|
|
871
|
+
observedNow: observed.resources,
|
|
872
|
+
observedThen,
|
|
873
|
+
unobserved: observed.unobserved,
|
|
874
|
+
});
|
|
875
|
+
merged.entries.push(...cs.entries);
|
|
876
|
+
checked++;
|
|
877
|
+
}
|
|
878
|
+
} finally {
|
|
879
|
+
endpointResult.restore();
|
|
777
880
|
}
|
|
778
881
|
|
|
779
882
|
if (checked === 0) {
|
|
@@ -785,6 +888,15 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
|
|
|
785
888
|
|
|
786
889
|
merged.entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
787
890
|
|
|
891
|
+
// Say it on stderr too, so `--json` and `--report gitlab-mr` consumers (whose
|
|
892
|
+
// shapes have no room for it) still learn the plan has a hole (#1089).
|
|
893
|
+
const unobservedCount = summarize(merged).unobserved;
|
|
894
|
+
if (unobservedCount > 0) {
|
|
895
|
+
console.error(formatWarning({
|
|
896
|
+
message: `${unobservedCount} declared entity(ies) could not be observed — no create/update/delete is proposed for them. This plan is incomplete, not clean.`,
|
|
897
|
+
}));
|
|
898
|
+
}
|
|
899
|
+
|
|
788
900
|
// `--report gitlab-mr` emits the GitLab MR plan-widget artifact instead of the
|
|
789
901
|
// human render. Write it to a file (`tfplan.json`) in CI and declare it as
|
|
790
902
|
// `artifacts:reports:terraform` to light up the merge-request widget.
|