@intentius/chant 0.28.0 → 0.30.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/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/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +14 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/config.d.ts +46 -4
- package/dist/config.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +257 -0
- package/dist/deep-observation.d.ts.map +1 -0
- package/dist/discovery/fold-import.d.ts +153 -17
- package/dist/discovery/fold-import.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/env.d.ts +5 -2
- package/dist/env.d.ts.map +1 -1
- package/dist/fold/fold.d.ts +35 -3
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +9 -0
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +73 -4
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/kubectl-context.d.ts +27 -0
- package/dist/kubectl-context.d.ts.map +1 -1
- package/dist/lexicon.d.ts +78 -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/deep-diff.d.ts +103 -0
- package/dist/lifecycle/deep-diff.d.ts.map +1 -0
- package/dist/lifecycle/deep-observe.d.ts +62 -0
- package/dist/lifecycle/deep-observe.d.ts.map +1 -0
- package/dist/lifecycle/index.d.ts +3 -0
- package/dist/lifecycle/index.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/observation-baseline.d.ts +118 -0
- package/dist/lifecycle/observation-baseline.d.ts.map +1 -0
- 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/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.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/components.test.ts +63 -4
- package/src/cli/handlers/components.ts +78 -35
- package/src/cli/handlers/graph.test.ts +155 -6
- package/src/cli/handlers/graph.ts +124 -29
- package/src/cli/handlers/lifecycle.test.ts +410 -6
- package/src/cli/handlers/lifecycle.ts +475 -182
- package/src/cli/main.test.ts +6 -0
- package/src/cli/main.ts +12 -0
- package/src/cli/registry.ts +14 -0
- package/src/config.test.ts +75 -0
- package/src/config.ts +61 -3
- package/src/deep-observation.test.ts +234 -0
- package/src/deep-observation.ts +489 -0
- package/src/discovery/fold-composite.test.ts +594 -0
- package/src/discovery/fold-import.test.ts +372 -1
- package/src/discovery/fold-import.ts +1216 -116
- package/src/discovery/sandbox/config-wire.ts +3 -2
- package/src/env.test.ts +12 -0
- package/src/env.ts +12 -4
- package/src/fold/fold.test.ts +105 -0
- package/src/fold/fold.ts +100 -20
- package/src/fold/subset.test.ts +38 -7
- package/src/fold/subset.ts +9 -0
- package/src/graph-ir-live.test.ts +28 -1
- package/src/graph-ir.ts +115 -12
- package/src/index.ts +2 -0
- package/src/kubectl-context.ts +81 -0
- package/src/lexicon.ts +100 -6
- package/src/lifecycle/change-set.test.ts +93 -1
- package/src/lifecycle/change-set.ts +65 -13
- package/src/lifecycle/deep-diff.test.ts +157 -0
- package/src/lifecycle/deep-diff.ts +213 -0
- package/src/lifecycle/deep-observe.test.ts +174 -0
- package/src/lifecycle/deep-observe.ts +173 -0
- package/src/lifecycle/index.ts +3 -0
- package/src/lifecycle/live-diff.test.ts +39 -0
- package/src/lifecycle/live-diff.ts +51 -5
- package/src/lifecycle/observation-baseline.test.ts +99 -0
- package/src/lifecycle/observation-baseline.ts +217 -0
- 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 +40 -20
- package/src/lifecycle/status.test.ts +89 -8
- package/src/lifecycle/status.ts +53 -3
- package/src/lifecycle/types.ts +8 -0
- 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 +55 -0
- package/src/stack-output.ts +41 -20
|
@@ -1,15 +1,36 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { build } from "../../build";
|
|
3
3
|
import { takeSnapshot } from "../../lifecycle/snapshot";
|
|
4
|
-
import { readSnapshot, readSnapshotAt, readEnvironmentSnapshots, listSnapshots, fetchLifecycle, snapshotStorageKey, StaleLifecycleBranchError } from "../../lifecycle/git";
|
|
4
|
+
import { readSnapshot, readSnapshotAt, readEnvironmentSnapshots, listSnapshots, fetchLifecycle, pushLifecycle, snapshotStorageKey, StaleLifecycleBranchError } from "../../lifecycle/git";
|
|
5
|
+
import { deepDiffForLexicon } from "../../lifecycle/deep-observe";
|
|
6
|
+
import { countPropertyDrift, type DeepDiffResult } from "../../lifecycle/deep-diff";
|
|
7
|
+
import {
|
|
8
|
+
acceptDeviations,
|
|
9
|
+
baselineForLexicon,
|
|
10
|
+
emptyBaseline,
|
|
11
|
+
readObservationBaseline,
|
|
12
|
+
writeObservationBaseline,
|
|
13
|
+
OBSERVATION_BASELINE_FILE,
|
|
14
|
+
type DeviationToAccept,
|
|
15
|
+
type ObservationBaseline,
|
|
16
|
+
} from "../../lifecycle/observation-baseline";
|
|
5
17
|
import { computeBuildDigest, diffDigests } from "../../lifecycle/digest";
|
|
6
18
|
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";
|
|
19
|
+
import { buildChangeSet, renderChangeSet, gitlabMrReport, summarize, type ChangeSet } from "../../lifecycle/change-set";
|
|
20
|
+
import {
|
|
21
|
+
formatUnobserved,
|
|
22
|
+
mergeObservations,
|
|
23
|
+
normalizeObservation,
|
|
24
|
+
unobservedAll,
|
|
25
|
+
type NormalizedObservation,
|
|
26
|
+
type UnobservedEntity,
|
|
27
|
+
} from "../../observation";
|
|
8
28
|
import { discoverComponents } from "../../components/discover";
|
|
9
29
|
import { cfnDeployStacks } from "./components";
|
|
10
30
|
import { affectedStacks } from "../../lifecycle/affected";
|
|
11
31
|
import { rollbackToRevision } from "../../lifecycle/rollback";
|
|
12
|
-
import { loadChantConfig } from "../../config";
|
|
32
|
+
import { loadChantConfig, environmentNames } from "../../config";
|
|
33
|
+
import { applyLiveEndpoint } from "../../live-endpoint";
|
|
13
34
|
import { formatError, formatWarning, formatSuccess, formatBold } from "../format";
|
|
14
35
|
import type { CommandContext } from "../registry";
|
|
15
36
|
import type { LifecycleSnapshot } from "../../lifecycle/types";
|
|
@@ -75,10 +96,11 @@ export async function runLifecycleSnapshot(ctx: CommandContext): Promise<number>
|
|
|
75
96
|
// Validate environment against config
|
|
76
97
|
const projectPath = resolve(".");
|
|
77
98
|
const { config } = await loadChantConfig(projectPath);
|
|
78
|
-
|
|
99
|
+
const declaredEnvNames = environmentNames(config.environments);
|
|
100
|
+
if (declaredEnvNames && !declaredEnvNames.includes(environment)) {
|
|
79
101
|
console.error(formatError({
|
|
80
102
|
message: `Unknown environment "${environment}"`,
|
|
81
|
-
hint: `Defined environments: ${
|
|
103
|
+
hint: `Defined environments: ${declaredEnvNames.join(", ")}`,
|
|
82
104
|
}));
|
|
83
105
|
return 1;
|
|
84
106
|
}
|
|
@@ -105,48 +127,58 @@ export async function runLifecycleSnapshot(ctx: CommandContext): Promise<number>
|
|
|
105
127
|
let anySnapshotSaved = false;
|
|
106
128
|
let anyHardError = false;
|
|
107
129
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
anyHardError = true;
|
|
114
|
-
continue;
|
|
115
|
-
}
|
|
130
|
+
// #1166 — same self-sufficiency as `chant graph --live`: a snapshot is
|
|
131
|
+
// always a live read, so an environment's declared endpoint applies here
|
|
132
|
+
// too, unless the ambient shell already set it.
|
|
133
|
+
const endpointResult = applyLiveEndpoint(config.environments, environment, observingPlugins.map((p) => p.name));
|
|
134
|
+
if (endpointResult.notice) console.error(formatWarning({ message: endpointResult.notice }));
|
|
116
135
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (
|
|
122
|
-
console.error(formatError({
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}));
|
|
126
|
-
return 1;
|
|
136
|
+
try {
|
|
137
|
+
for (const target of targets) {
|
|
138
|
+
const label = target.stack ? `stack "${target.stack}"` : "project";
|
|
139
|
+
const buildResult = await build(target.root, targetSerializers);
|
|
140
|
+
if (buildResult.errors.length > 0) {
|
|
141
|
+
console.error(formatError({ message: `Build failed for ${label} — fix errors before taking a snapshot` }));
|
|
142
|
+
anyHardError = true;
|
|
143
|
+
continue;
|
|
127
144
|
}
|
|
128
|
-
throw err;
|
|
129
|
-
}
|
|
130
145
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
146
|
+
let result;
|
|
147
|
+
try {
|
|
148
|
+
result = await takeSnapshot(environment, observingPlugins, buildResult, { stack: target.stack });
|
|
149
|
+
} catch (err) {
|
|
150
|
+
if (err instanceof StaleLifecycleBranchError) {
|
|
151
|
+
console.error(formatError({
|
|
152
|
+
message: `Another snapshot completed for chant/lifecycle after this run started (env: ${environment}).`,
|
|
153
|
+
hint: `Pull and retry: \`git fetch origin ${"chant/lifecycle"}:${"chant/lifecycle"}\` && \`chant lifecycle snapshot ${environment}\`.`,
|
|
154
|
+
}));
|
|
155
|
+
return 1;
|
|
156
|
+
}
|
|
157
|
+
throw err;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
for (const w of result.warnings) {
|
|
161
|
+
console.error(formatWarning({ message: w }));
|
|
162
|
+
}
|
|
163
|
+
for (const e of result.errors) {
|
|
164
|
+
console.error(formatError({ message: e }));
|
|
165
|
+
}
|
|
137
166
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
167
|
+
if (result.snapshots.length > 0) {
|
|
168
|
+
anySnapshotSaved = true;
|
|
169
|
+
const prefix = target.stack ? `${target.stack}: ` : "";
|
|
170
|
+
const counts = result.snapshots
|
|
171
|
+
.map((s) => `${s.lexicon}(${Object.keys(s.resources).length})`)
|
|
172
|
+
.join(" ");
|
|
173
|
+
console.error(formatSuccess(`${prefix}Snapshot saved to chant/lifecycle (${counts})`));
|
|
174
|
+
}
|
|
175
|
+
if (result.errors.length > 0) anyHardError = true;
|
|
145
176
|
}
|
|
146
|
-
if (result.errors.length > 0) anyHardError = true;
|
|
147
|
-
}
|
|
148
177
|
|
|
149
|
-
|
|
178
|
+
return anyHardError && !anySnapshotSaved ? 1 : 0;
|
|
179
|
+
} finally {
|
|
180
|
+
endpointResult.restore();
|
|
181
|
+
}
|
|
150
182
|
}
|
|
151
183
|
|
|
152
184
|
/**
|
|
@@ -263,71 +295,163 @@ export async function runLifecycleDiff(ctx: CommandContext): Promise<number> {
|
|
|
263
295
|
const perStackJson: Record<string, unknown> = {};
|
|
264
296
|
let combinedLexiconsJson: Record<string, unknown> | undefined;
|
|
265
297
|
let totalDrift = 0;
|
|
298
|
+
let totalUnobserved = 0;
|
|
266
299
|
let totalChecked = 0;
|
|
267
300
|
let anyBuildError = false;
|
|
268
301
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
anyBuildError = true;
|
|
275
|
-
continue;
|
|
276
|
-
}
|
|
302
|
+
// Accepted-deviation baseline (#1014). Read once for the whole run — it is
|
|
303
|
+
// env-keyed, not stack-keyed, and every deep pass subtracts from the same
|
|
304
|
+
// committed set. Absent is the normal state (nothing accepted yet).
|
|
305
|
+
const baseline = args.live ? await readObservationBaseline(environment) : null;
|
|
306
|
+
const accepted: Record<string, DeviationToAccept[]> = {};
|
|
277
307
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
308
|
+
// #1166 — an environment can declare its own endpoint (a local emulator like
|
|
309
|
+
// Floci), so `--live` is self-sufficient even when the ambient shell never
|
|
310
|
+
// exported e.g. AWS_ENDPOINT_URL. Ambient always wins when it's already set.
|
|
311
|
+
// Scoped to just the live reads below — restored in `finally`.
|
|
312
|
+
const liveLexicons = args.live ? plugins.filter((p) => p.describeResources || p.listArtifacts).map((p) => p.name) : [];
|
|
313
|
+
const endpointResult = applyLiveEndpoint(config.environments, environment, liveLexicons);
|
|
314
|
+
if (endpointResult.notice) console.error(formatWarning({ message: endpointResult.notice }));
|
|
281
315
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
316
|
+
try {
|
|
317
|
+
for (const target of targets) {
|
|
318
|
+
const buildResult = await build(target.root, targetSerializers);
|
|
319
|
+
if (buildResult.errors.length > 0) {
|
|
320
|
+
const label = target.stack ? `stack "${target.stack}"` : "project";
|
|
321
|
+
console.error(formatError({ message: `Build failed for ${label} — fix errors before diffing` }));
|
|
322
|
+
anyBuildError = true;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const lexicons = lexiconFilter
|
|
327
|
+
? [lexiconFilter]
|
|
328
|
+
: Array.from(buildResult.manifest.lexicons);
|
|
329
|
+
|
|
330
|
+
if (args.live) {
|
|
331
|
+
// Multi-stack component projects: observe each component's own cfn stack
|
|
332
|
+
// and union (same fix runGraphLive / plan use), so a deployed resource
|
|
333
|
+
// isn't reported "missing". Only when this target has no explicit stack.
|
|
334
|
+
let componentStacks: string[] = [];
|
|
335
|
+
if (!target.stack) {
|
|
336
|
+
try {
|
|
337
|
+
const disc = await discoverComponents(target.root, { sandbox: args.sandbox });
|
|
338
|
+
const set = new Set<string>();
|
|
339
|
+
for (const { component } of disc.components.values()) for (const s of cfnDeployStacks(component.deploy)) set.add(s);
|
|
340
|
+
componentStacks = [...set];
|
|
341
|
+
} catch {
|
|
342
|
+
// no components / discovery failed → single-stack observe path
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
const r = await runLifecycleDiffLive({
|
|
346
|
+
environment,
|
|
347
|
+
lexicons,
|
|
348
|
+
plugins,
|
|
349
|
+
buildResult,
|
|
350
|
+
json,
|
|
351
|
+
stack: target.stack,
|
|
352
|
+
componentStacks,
|
|
353
|
+
baseline,
|
|
354
|
+
updateBaseline: args.updateBaseline,
|
|
355
|
+
});
|
|
356
|
+
totalDrift += r.totalDrift;
|
|
357
|
+
totalUnobserved += r.totalUnobserved;
|
|
358
|
+
totalChecked += r.totalLexiconsChecked;
|
|
359
|
+
for (const [lexicon, deviations] of Object.entries(r.toAccept)) {
|
|
360
|
+
(accepted[lexicon] ??= []).push(...deviations);
|
|
295
361
|
}
|
|
362
|
+
if (json) {
|
|
363
|
+
if (target.stack) perStackJson[target.stack] = r.byLexicon;
|
|
364
|
+
else combinedLexiconsJson = r.byLexicon;
|
|
365
|
+
}
|
|
366
|
+
} else {
|
|
367
|
+
await runLifecycleDiffDigest({ environment, lexicons, buildResult, stack: target.stack });
|
|
296
368
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// `--update-baseline` (#1014): record what the deep pass just reported as
|
|
372
|
+
// accepted, so it stops re-alerting. Runs before the summary lines so the
|
|
373
|
+
// "no drift" verdict below still describes the run that produced it.
|
|
374
|
+
if (args.live && args.updateBaseline) {
|
|
375
|
+
await recordAcceptedBaseline(environment, baseline, accepted, json);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (args.live) {
|
|
300
379
|
if (json) {
|
|
301
|
-
|
|
302
|
-
|
|
380
|
+
// Single-stack keeps the original `{ environment, lexicons }` shape
|
|
381
|
+
// (behold's inspect diff, #852); multi-stack nests under `stacks`.
|
|
382
|
+
console.log(
|
|
383
|
+
JSON.stringify(
|
|
384
|
+
combinedLexiconsJson !== undefined
|
|
385
|
+
? { environment, lexicons: combinedLexiconsJson }
|
|
386
|
+
: { environment, stacks: perStackJson },
|
|
387
|
+
),
|
|
388
|
+
);
|
|
389
|
+
} else if (totalChecked === 0) {
|
|
390
|
+
console.error(formatWarning({
|
|
391
|
+
message: "No lexicons implement describeResources or listArtifacts — nothing to diff in --live mode",
|
|
392
|
+
}));
|
|
393
|
+
return 1;
|
|
394
|
+
} else if (totalDrift === 0) {
|
|
395
|
+
// Qualify the all-clear when part of the estate was never read (#1089):
|
|
396
|
+
// "no drift" over an incomplete observation is not the same claim.
|
|
397
|
+
console.error(
|
|
398
|
+
totalUnobserved > 0
|
|
399
|
+
? formatWarning({
|
|
400
|
+
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`,
|
|
401
|
+
})
|
|
402
|
+
: formatSuccess(`No drift detected across ${totalChecked} lexicon(s)`),
|
|
403
|
+
);
|
|
303
404
|
}
|
|
304
|
-
} else {
|
|
305
|
-
await runLifecycleDiffDigest({ environment, lexicons, buildResult, stack: target.stack });
|
|
306
405
|
}
|
|
406
|
+
|
|
407
|
+
return anyBuildError ? 1 : 0;
|
|
408
|
+
} finally {
|
|
409
|
+
endpointResult.restore();
|
|
307
410
|
}
|
|
411
|
+
}
|
|
308
412
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
413
|
+
/**
|
|
414
|
+
* Write the accepted-deviation baseline (#1014) for everything the deep pass
|
|
415
|
+
* reported this run, and push it on the same orphan branch the snapshots use.
|
|
416
|
+
*
|
|
417
|
+
* Acceptance is a deliberate, committed act — that is the whole difference
|
|
418
|
+
* between this and a suppression flag — so the write is loud: it names the
|
|
419
|
+
* count and the storage path, and a failed push says so rather than leaving
|
|
420
|
+
* the operator believing the team's baseline moved.
|
|
421
|
+
*/
|
|
422
|
+
async function recordAcceptedBaseline(
|
|
423
|
+
environment: string,
|
|
424
|
+
existing: ObservationBaseline | null,
|
|
425
|
+
accepted: Record<string, DeviationToAccept[]>,
|
|
426
|
+
json: boolean,
|
|
427
|
+
): Promise<void> {
|
|
428
|
+
const total = Object.values(accepted).reduce((n, d) => n + d.length, 0);
|
|
429
|
+
if (total === 0) {
|
|
430
|
+
if (!json) {
|
|
321
431
|
console.error(formatWarning({
|
|
322
|
-
message: "
|
|
432
|
+
message: "--update-baseline: nothing to accept — no property-level deviations were reported",
|
|
323
433
|
}));
|
|
324
|
-
return 1;
|
|
325
|
-
} else if (totalDrift === 0) {
|
|
326
|
-
console.error(formatSuccess(`No drift detected across ${totalChecked} lexicon(s)`));
|
|
327
434
|
}
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
let next = existing ?? emptyBaseline(environment);
|
|
438
|
+
for (const [lexicon, deviations] of Object.entries(accepted)) {
|
|
439
|
+
next = acceptDeviations(next, lexicon, deviations);
|
|
440
|
+
}
|
|
441
|
+
try {
|
|
442
|
+
await writeObservationBaseline(next);
|
|
443
|
+
const pushed = await pushLifecycle();
|
|
444
|
+
if (!json) {
|
|
445
|
+
console.error(formatSuccess(
|
|
446
|
+
`--update-baseline: accepted ${total} deviation(s) into ${environment}/${OBSERVATION_BASELINE_FILE} on chant/lifecycle` +
|
|
447
|
+
(pushed ? " (pushed)" : " (local only — no remote configured or push refused)"),
|
|
448
|
+
));
|
|
449
|
+
}
|
|
450
|
+
} catch (err) {
|
|
451
|
+
console.error(formatError({
|
|
452
|
+
message: `--update-baseline: could not write the baseline — ${err instanceof Error ? err.message : String(err)}`,
|
|
453
|
+
}));
|
|
328
454
|
}
|
|
329
|
-
|
|
330
|
-
return anyBuildError ? 1 : 0;
|
|
331
455
|
}
|
|
332
456
|
|
|
333
457
|
interface BetweenDiffArgs {
|
|
@@ -452,15 +576,86 @@ interface LiveDiffArgs {
|
|
|
452
576
|
* union (the same fix graph/plan use), else every deployed resource reads as
|
|
453
577
|
* "missing". Empty → the single-stack observe path. */
|
|
454
578
|
componentStacks?: string[];
|
|
579
|
+
/** Accepted-deviation baseline for this environment (#1014), or null when none is recorded. */
|
|
580
|
+
baseline: ObservationBaseline | null;
|
|
581
|
+
/** `--update-baseline`: accept everything the deep pass reports this run. */
|
|
582
|
+
updateBaseline?: boolean;
|
|
455
583
|
}
|
|
456
584
|
|
|
457
585
|
interface LiveDiffOutcome {
|
|
458
586
|
byLexicon: Record<
|
|
459
587
|
string,
|
|
460
|
-
{
|
|
588
|
+
{
|
|
589
|
+
resources?: LiveDiffResult;
|
|
590
|
+
observed?: Record<string, ResourceMetadata>;
|
|
591
|
+
/** Declared entities the lexicon could not read (#1089), keyed by name. */
|
|
592
|
+
unobserved?: Record<string, UnobservedEntity>;
|
|
593
|
+
/** Property-level drift (#1014), present only for lexicons with a deep reader. */
|
|
594
|
+
deep?: DeepDiffResult;
|
|
595
|
+
artifacts?: LiveArtifactDiffResult;
|
|
596
|
+
}
|
|
461
597
|
>;
|
|
462
598
|
totalDrift: number;
|
|
599
|
+
/** Declared entities nobody could read. Not drift — a hole in the report. */
|
|
600
|
+
totalUnobserved: number;
|
|
463
601
|
totalLexiconsChecked: number;
|
|
602
|
+
/** Deviations `--update-baseline` should record, per lexicon. */
|
|
603
|
+
toAccept: Record<string, DeviationToAccept[]>;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Read one lexicon's live resources, resolving the observation tri-state
|
|
608
|
+
* (#1089) for every declared entity: present, confirmed-absent, or not
|
|
609
|
+
* observed with a reason.
|
|
610
|
+
*
|
|
611
|
+
* Two behaviours the old inline call sites did not have. A throw no longer
|
|
612
|
+
* skips the lexicon — every entity it was asked about comes back
|
|
613
|
+
* NOT-OBSERVED (`read-failed`), so a failed read shows up in the plan instead
|
|
614
|
+
* of vanishing from it. And a multi-stack read merges with
|
|
615
|
+
* present > not-observed > absent, so one unreadable stack cannot un-observe a
|
|
616
|
+
* resource another stack returned.
|
|
617
|
+
*/
|
|
618
|
+
async function observeLexicon(
|
|
619
|
+
plugin: ObservationLexicon,
|
|
620
|
+
opts: {
|
|
621
|
+
environment: string;
|
|
622
|
+
buildOutput: string;
|
|
623
|
+
declared: Set<string>;
|
|
624
|
+
entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
|
|
625
|
+
stack?: string;
|
|
626
|
+
componentStacks?: string[];
|
|
627
|
+
owned?: boolean;
|
|
628
|
+
},
|
|
629
|
+
): Promise<NormalizedObservation> {
|
|
630
|
+
const entityNames = Array.from(opts.declared);
|
|
631
|
+
const base = {
|
|
632
|
+
environment: opts.environment,
|
|
633
|
+
buildOutput: opts.buildOutput,
|
|
634
|
+
entityNames,
|
|
635
|
+
entities: opts.entities,
|
|
636
|
+
...(opts.owned !== undefined ? { owned: opts.owned } : {}),
|
|
637
|
+
};
|
|
638
|
+
try {
|
|
639
|
+
if (opts.componentStacks && opts.componentStacks.length > 0) {
|
|
640
|
+
const parts: NormalizedObservation[] = [];
|
|
641
|
+
for (const stack of opts.componentStacks) {
|
|
642
|
+
parts.push(normalizeObservation(await plugin.describeResources!({ ...base, stack })));
|
|
643
|
+
}
|
|
644
|
+
return mergeObservations(parts);
|
|
645
|
+
}
|
|
646
|
+
return normalizeObservation(
|
|
647
|
+
await plugin.describeResources!({ ...base, ...(opts.stack ? { stack: opts.stack } : {}) }),
|
|
648
|
+
);
|
|
649
|
+
} catch (err) {
|
|
650
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
651
|
+
console.error(formatError({
|
|
652
|
+
message: `${plugin.name}: describeResources failed — ${message} (reporting ${entityNames.length} declared entity(ies) as not observed, not as absent)`,
|
|
653
|
+
}));
|
|
654
|
+
return {
|
|
655
|
+
resources: {},
|
|
656
|
+
unobserved: unobservedAll(entityNames, "read-failed", message, opts.entities),
|
|
657
|
+
};
|
|
658
|
+
}
|
|
464
659
|
}
|
|
465
660
|
|
|
466
661
|
/** Diff current build vs live cloud for one stack. Renders the human report
|
|
@@ -468,8 +663,10 @@ interface LiveDiffOutcome {
|
|
|
468
663
|
* once (single-stack keeps the original shape; multi-stack nests under `stacks`). */
|
|
469
664
|
async function runLifecycleDiffLive(args: LiveDiffArgs): Promise<LiveDiffOutcome> {
|
|
470
665
|
let totalDrift = 0;
|
|
666
|
+
let totalUnobserved = 0;
|
|
471
667
|
let totalLexiconsChecked = 0;
|
|
472
668
|
const byLexicon: LiveDiffOutcome["byLexicon"] = {};
|
|
669
|
+
const toAccept: Record<string, DeviationToAccept[]> = {};
|
|
473
670
|
if (!args.json && args.stack) console.log(`\n${formatBold(`■ stack ${args.stack}`)}`);
|
|
474
671
|
|
|
475
672
|
for (const lexiconName of args.lexicons) {
|
|
@@ -515,40 +712,50 @@ async function runLifecycleDiffLive(args: LiveDiffArgs): Promise<LiveDiffOutcome
|
|
|
515
712
|
|
|
516
713
|
// ── Resources path (entity-keyed) ──────────────────────────────────────
|
|
517
714
|
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
|
-
}
|
|
715
|
+
const observed = await observeLexicon(plugin, {
|
|
716
|
+
environment: args.environment,
|
|
717
|
+
buildOutput,
|
|
718
|
+
declared,
|
|
719
|
+
entities,
|
|
720
|
+
stack: args.stack,
|
|
721
|
+
componentStacks: args.componentStacks,
|
|
722
|
+
});
|
|
723
|
+
const observedNow = observed.resources;
|
|
543
724
|
const observedThen = prevSnapshot?.resources;
|
|
544
|
-
const diff = diffLive({ declared, observedNow, observedThen });
|
|
725
|
+
const diff = diffLive({ declared, observedNow, observedThen, unobserved: observed.unobserved });
|
|
726
|
+
// Unobserved entities are deliberately NOT drift: a hole in the read is
|
|
727
|
+
// not a change in the cloud. They are reported separately (#1089) so a
|
|
728
|
+
// "no drift detected" line can never be built on top of a failed read.
|
|
545
729
|
totalDrift += diff.driftedSinceSnapshot.length + diff.missing.length + diff.orphan.length + diff.disappeared.length;
|
|
730
|
+
totalUnobserved += diff.unobserved.length;
|
|
546
731
|
if (args.json) {
|
|
547
732
|
const entry = (byLexicon[lexiconName] ??= {});
|
|
548
733
|
entry.resources = diff;
|
|
549
734
|
entry.observed = observedNow; // live state (#862) — status/attributes per resource
|
|
735
|
+
if (Object.keys(observed.unobserved).length > 0) entry.unobserved = observed.unobserved;
|
|
550
736
|
} else renderLiveDiff(lexiconName, args.environment, diff);
|
|
551
737
|
lexiconChecked = true;
|
|
738
|
+
|
|
739
|
+
// ── Deep path (property-level, #1014) ───────────────────────────────
|
|
740
|
+
// Gated purely on the capability: a lexicon without a deep reader is
|
|
741
|
+
// completely unaffected, including its output.
|
|
742
|
+
if (plugin.observeResourcesDeep) {
|
|
743
|
+
const deep = await deepDiffForLexicon(plugin, {
|
|
744
|
+
environment: args.environment,
|
|
745
|
+
buildOutput,
|
|
746
|
+
entities,
|
|
747
|
+
stack: args.stack,
|
|
748
|
+
componentStacks: args.componentStacks,
|
|
749
|
+
baseline: baselineForLexicon(args.baseline, lexiconName),
|
|
750
|
+
});
|
|
751
|
+
totalDrift += countPropertyDrift(deep);
|
|
752
|
+
// Only count a deep hole for an entity the thin read *did* resolve —
|
|
753
|
+
// otherwise one unreadable entity is counted twice.
|
|
754
|
+
totalUnobserved += deep.unobserved.filter((u) => !observed.unobserved[u.name]).length;
|
|
755
|
+
if (args.updateBaseline) toAccept[lexiconName] = deviationsToAccept(deep);
|
|
756
|
+
if (args.json) (byLexicon[lexiconName] ??= {}).deep = deep;
|
|
757
|
+
else renderDeepDiff(lexiconName, deep);
|
|
758
|
+
}
|
|
552
759
|
}
|
|
553
760
|
|
|
554
761
|
// ── Artifacts path (context-keyed) ─────────────────────────────────────
|
|
@@ -573,21 +780,97 @@ async function runLifecycleDiffLive(args: LiveDiffArgs): Promise<LiveDiffOutcome
|
|
|
573
780
|
if (lexiconChecked) totalLexiconsChecked++;
|
|
574
781
|
}
|
|
575
782
|
|
|
576
|
-
return { byLexicon, totalDrift, totalLexiconsChecked };
|
|
783
|
+
return { byLexicon, totalDrift, totalUnobserved, totalLexiconsChecked, toAccept };
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Everything a deep diff reported this run, as deviations to record accepted.
|
|
788
|
+
* `--update-baseline` accepts what was *reported*, never what was already
|
|
789
|
+
* suppressed — re-accepting an unchanged suppression would rewrite its
|
|
790
|
+
* `recordedAt` on every run and turn the baseline into a churn file.
|
|
791
|
+
*/
|
|
792
|
+
function deviationsToAccept(deep: DeepDiffResult): DeviationToAccept[] {
|
|
793
|
+
const out: DeviationToAccept[] = [];
|
|
794
|
+
for (const entity of deep.drifted) {
|
|
795
|
+
for (const change of entity.changes) {
|
|
796
|
+
// Only a value that is actually live can be accepted: `absent` means the
|
|
797
|
+
// cloud does not carry the declared property, which is a finding to fix
|
|
798
|
+
// in source or in the cloud, not a value to bless.
|
|
799
|
+
if (!("live" in change)) continue;
|
|
800
|
+
out.push({ entity: entity.name, type: entity.type, path: change.path, value: change.live });
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
return out;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/** Property-level drift report (#1014). Silent when a lexicon's deep read found nothing to say. */
|
|
807
|
+
function renderDeepDiff(lexiconName: string, deep: DeepDiffResult): void {
|
|
808
|
+
const drift = countPropertyDrift(deep);
|
|
809
|
+
if (
|
|
810
|
+
drift === 0 &&
|
|
811
|
+
deep.accepted.length === 0 &&
|
|
812
|
+
deep.unobserved.length === 0 &&
|
|
813
|
+
deep.undeclaredEntities.length === 0
|
|
814
|
+
) {
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const acceptedCount = deep.accepted.reduce((n, e) => n + e.changes.length, 0);
|
|
819
|
+
console.log(`\n${formatBold(`${lexiconName} (properties)`)}`);
|
|
820
|
+
console.log(
|
|
821
|
+
`${drift} property drift across ${deep.drifted.length} resource(s), ` +
|
|
822
|
+
`${acceptedCount} accepted, ${deep.unchanged.length} unchanged` +
|
|
823
|
+
(deep.unobserved.length > 0 ? `, ${deep.unobserved.length} unobserved` : ""),
|
|
824
|
+
);
|
|
825
|
+
console.log("-".repeat(80));
|
|
826
|
+
|
|
827
|
+
if (deep.unobserved.length > 0) {
|
|
828
|
+
console.log(formatBold("\nPROPERTIES UNOBSERVED (declared; the deep read could not look):"));
|
|
829
|
+
for (const u of deep.unobserved) console.log(` ? ${formatUnobserved(u.name, u)}`);
|
|
830
|
+
}
|
|
831
|
+
if (deep.drifted.length > 0) {
|
|
832
|
+
console.log(formatBold("\nPROPERTY DRIFT (declared vs live; baseline shown where one exists):"));
|
|
833
|
+
for (const entity of deep.drifted) {
|
|
834
|
+
console.log(` - ${entity.name} (${entity.type})`);
|
|
835
|
+
for (const change of entity.changes) {
|
|
836
|
+
const declared = "declared" in change ? formatValue(change.declared) : "<undeclared>";
|
|
837
|
+
const live = "live" in change ? formatValue(change.live) : "<absent>";
|
|
838
|
+
const baseline = "baseline" in change ? ` [accepted: ${formatValue(change.baseline)}]` : "";
|
|
839
|
+
console.log(` ${change.path}: ${declared} → ${live}${baseline}`);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
if (deep.undeclaredEntities.length > 0) {
|
|
844
|
+
console.log(formatBold("\nUNDECLARED (read deeply, never declared in source):"));
|
|
845
|
+
for (const name of deep.undeclaredEntities) console.log(` - ${name}`);
|
|
846
|
+
}
|
|
847
|
+
if (acceptedCount > 0) {
|
|
848
|
+
console.log(formatBold("\nACCEPTED (in the baseline; not drift):"));
|
|
849
|
+
for (const entity of deep.accepted) {
|
|
850
|
+
console.log(` - ${entity.name}: ${entity.changes.map((c) => c.path).join(", ")}`);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
577
853
|
}
|
|
578
854
|
|
|
579
855
|
function renderLiveDiff(lexiconName: string, environment: string, diff: LiveDiffResult): void {
|
|
580
856
|
const counts =
|
|
581
857
|
`${diff.missing.length} missing, ${diff.orphan.length} orphan, ` +
|
|
582
858
|
`${diff.disappeared.length} disappeared, ${diff.newlyObserved.length} newly observed, ` +
|
|
583
|
-
`${diff.driftedSinceSnapshot.length} drifted, ${diff.unchanged.length} unchanged
|
|
859
|
+
`${diff.driftedSinceSnapshot.length} drifted, ${diff.unchanged.length} unchanged` +
|
|
860
|
+
(diff.unobserved.length > 0 ? `, ${diff.unobserved.length} unobserved` : "");
|
|
584
861
|
|
|
585
862
|
console.log(`\n${formatBold(lexiconName)} — environment: ${environment}`);
|
|
586
863
|
console.log(counts);
|
|
587
864
|
console.log("-".repeat(80));
|
|
588
865
|
|
|
866
|
+
if (diff.unobserved.length > 0) {
|
|
867
|
+
console.log(formatBold("\nUNOBSERVED (declared; chant could not read live state — status unknown):"));
|
|
868
|
+
for (const u of diff.unobserved) {
|
|
869
|
+
console.log(` ? ${formatUnobserved(u.name, u)}`);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
589
872
|
if (diff.missing.length > 0) {
|
|
590
|
-
console.log(formatBold("\nMISSING (declared, not in cloud):"));
|
|
873
|
+
console.log(formatBold("\nMISSING (declared, provider reports not in cloud):"));
|
|
591
874
|
for (const name of diff.missing) console.log(` - ${name}`);
|
|
592
875
|
}
|
|
593
876
|
if (diff.orphan.length > 0) {
|
|
@@ -662,9 +945,11 @@ function renderLiveArtifactDiff(lexiconName: string, environment: string, diff:
|
|
|
662
945
|
* chant lifecycle plan <environment> [lexicon]
|
|
663
946
|
*
|
|
664
947
|
* 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
|
|
948
|
+
* create / update / delete / adopt / noop / unobserved. Strictly read-only —
|
|
949
|
+
* never mutates, never deploys. Deletes are never proposed without ownership
|
|
950
|
+
* data (added in #121); an undeclared live resource is `adopt`; a declared
|
|
951
|
+
* entity chant could not read is `unobserved` and gets no proposal at all
|
|
952
|
+
* (#1089) — a create is only ever proposed against a confirmed absence.
|
|
668
953
|
*/
|
|
669
954
|
export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
|
|
670
955
|
const { args, plugins, serializers } = ctx;
|
|
@@ -709,71 +994,70 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
|
|
|
709
994
|
const merged: ChangeSet = { env: environment, entries: [] };
|
|
710
995
|
let checked = 0;
|
|
711
996
|
|
|
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
|
-
}
|
|
997
|
+
// #1166 — same self-sufficiency as `chant graph --live`: an environment can
|
|
998
|
+
// declare its own endpoint, applied here unless the ambient shell already
|
|
999
|
+
// set it. `chant lifecycle plan` is always a live read (no `--live` flag of
|
|
1000
|
+
// its own), so this applies unconditionally.
|
|
1001
|
+
const endpointResult = applyLiveEndpoint(config.environments, environment, lexicons);
|
|
1002
|
+
if (endpointResult.notice) console.error(formatWarning({ message: endpointResult.notice }));
|
|
724
1003
|
|
|
725
|
-
|
|
726
|
-
const
|
|
727
|
-
|
|
728
|
-
if (
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
1004
|
+
try {
|
|
1005
|
+
for (const lexiconName of lexicons) {
|
|
1006
|
+
const plugin = plugins.find((p) => p.name === lexiconName);
|
|
1007
|
+
if (!plugin) continue;
|
|
1008
|
+
if (!plugin.describeResources) {
|
|
1009
|
+
// Plan is entity-keyed; artifact-only lexicons have no declared axis.
|
|
1010
|
+
if (!args.json) {
|
|
1011
|
+
console.error(formatWarning({
|
|
1012
|
+
message: `${lexiconName}: lexicon does not implement describeResources — skipping (no declared axis to plan against)`,
|
|
1013
|
+
}));
|
|
1014
|
+
}
|
|
1015
|
+
continue;
|
|
734
1016
|
}
|
|
735
|
-
}
|
|
736
1017
|
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
try {
|
|
747
|
-
if (componentStacks.length > 0) {
|
|
748
|
-
// Observe each component's own stack and union — the multi-stack fix.
|
|
749
|
-
for (const stack of componentStacks) {
|
|
750
|
-
Object.assign(
|
|
751
|
-
observedNow,
|
|
752
|
-
await plugin.describeResources({ environment, buildOutput, entityNames: Array.from(declared), entities, stack, owned: args.owned }),
|
|
753
|
-
);
|
|
1018
|
+
const declared = new Set<string>();
|
|
1019
|
+
const entities = new Map<string, { entityType: string; props: Record<string, unknown> }>();
|
|
1020
|
+
for (const [name, entity] of buildResult.entities) {
|
|
1021
|
+
if (entity.lexicon === lexiconName) {
|
|
1022
|
+
declared.add(name);
|
|
1023
|
+
entities.set(name, {
|
|
1024
|
+
entityType: entity.entityType,
|
|
1025
|
+
props: ("props" in entity && entity.props != null ? entity.props : {}) as Record<string, unknown>,
|
|
1026
|
+
});
|
|
754
1027
|
}
|
|
755
|
-
} else {
|
|
756
|
-
observedNow = await plugin.describeResources({
|
|
757
|
-
environment,
|
|
758
|
-
buildOutput,
|
|
759
|
-
entityNames: Array.from(declared),
|
|
760
|
-
entities,
|
|
761
|
-
owned: args.owned,
|
|
762
|
-
});
|
|
763
1028
|
}
|
|
764
|
-
} catch (err) {
|
|
765
|
-
console.error(formatError({
|
|
766
|
-
message: `${lexiconName}: describeResources failed — ${err instanceof Error ? err.message : String(err)}`,
|
|
767
|
-
}));
|
|
768
|
-
continue;
|
|
769
|
-
}
|
|
770
1029
|
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
1030
|
+
const rawOutput = buildResult.outputs.get(lexiconName);
|
|
1031
|
+
const buildOutput =
|
|
1032
|
+
rawOutput === undefined
|
|
1033
|
+
? ""
|
|
1034
|
+
: typeof rawOutput === "string"
|
|
1035
|
+
? rawOutput
|
|
1036
|
+
: (rawOutput as SerializerResult).primary;
|
|
1037
|
+
|
|
1038
|
+
const observed = await observeLexicon(plugin, {
|
|
1039
|
+
environment,
|
|
1040
|
+
buildOutput,
|
|
1041
|
+
declared,
|
|
1042
|
+
entities,
|
|
1043
|
+
componentStacks,
|
|
1044
|
+
owned: args.owned,
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
const content = await readSnapshot(environment, lexiconName);
|
|
1048
|
+
const observedThen = content ? (JSON.parse(content) as LifecycleSnapshot).resources : undefined;
|
|
1049
|
+
|
|
1050
|
+
const cs = buildChangeSet(environment, {
|
|
1051
|
+
declared,
|
|
1052
|
+
observedNow: observed.resources,
|
|
1053
|
+
observedThen,
|
|
1054
|
+
unobserved: observed.unobserved,
|
|
1055
|
+
});
|
|
1056
|
+
merged.entries.push(...cs.entries);
|
|
1057
|
+
checked++;
|
|
1058
|
+
}
|
|
1059
|
+
} finally {
|
|
1060
|
+
endpointResult.restore();
|
|
777
1061
|
}
|
|
778
1062
|
|
|
779
1063
|
if (checked === 0) {
|
|
@@ -785,6 +1069,15 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
|
|
|
785
1069
|
|
|
786
1070
|
merged.entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
787
1071
|
|
|
1072
|
+
// Say it on stderr too, so `--json` and `--report gitlab-mr` consumers (whose
|
|
1073
|
+
// shapes have no room for it) still learn the plan has a hole (#1089).
|
|
1074
|
+
const unobservedCount = summarize(merged).unobserved;
|
|
1075
|
+
if (unobservedCount > 0) {
|
|
1076
|
+
console.error(formatWarning({
|
|
1077
|
+
message: `${unobservedCount} declared entity(ies) could not be observed — no create/update/delete is proposed for them. This plan is incomplete, not clean.`,
|
|
1078
|
+
}));
|
|
1079
|
+
}
|
|
1080
|
+
|
|
788
1081
|
// `--report gitlab-mr` emits the GitLab MR plan-widget artifact instead of the
|
|
789
1082
|
// human render. Write it to a file (`tfplan.json`) in CI and declare it as
|
|
790
1083
|
// `artifacts:reports:terraform` to light up the merge-request widget.
|