@intentius/chant 0.50.0 → 0.51.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/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/op-progress.d.ts +57 -0
- package/dist/cli/handlers/op-progress.d.ts.map +1 -0
- package/dist/cli/handlers/run-client.d.ts +21 -1
- package/dist/cli/handlers/run-client.d.ts.map +1 -1
- package/dist/cli/handlers/run-report.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/search.d.ts +22 -0
- package/dist/cli/handlers/search.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/op-tools.d.ts.map +1 -1
- package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +33 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/run-progress.d.ts +7 -5
- package/dist/components/run-progress.d.ts.map +1 -1
- package/dist/lexicon.d.ts +41 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/assert-live.d.ts +77 -0
- package/dist/lifecycle/assert-live.d.ts.map +1 -0
- package/dist/lifecycle/change-set.d.ts +17 -0
- package/dist/lifecycle/change-set.d.ts.map +1 -1
- package/dist/lifecycle/disruption.d.ts +96 -0
- package/dist/lifecycle/disruption.d.ts.map +1 -0
- package/dist/lifecycle/index.d.ts +2 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/replay.d.ts +2 -0
- package/dist/lifecycle/replay.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +7 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/testing.d.ts +23 -2
- package/dist/testing.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/lifecycle.test.ts +90 -0
- package/src/cli/handlers/lifecycle.ts +18 -1
- package/src/cli/handlers/op-progress.test.ts +202 -0
- package/src/cli/handlers/op-progress.ts +192 -0
- package/src/cli/handlers/run-client.test.ts +82 -0
- package/src/cli/handlers/run-client.ts +85 -2
- package/src/cli/handlers/run-report.test.ts +62 -0
- package/src/cli/handlers/run-report.ts +20 -58
- package/src/cli/handlers/run.test.ts +144 -0
- package/src/cli/handlers/run.ts +40 -18
- package/src/cli/handlers/search-drift.test.ts +263 -0
- package/src/cli/handlers/search.ts +150 -1
- package/src/cli/main.ts +9 -0
- package/src/cli/mcp/op-tools.ts +17 -6
- package/src/cli/mcp/resource-handlers.ts +13 -5
- package/src/cli/registry.ts +33 -1
- package/src/components/run-progress.ts +9 -5
- package/src/lexicon.ts +51 -0
- package/src/lifecycle/assert-live.test.ts +125 -0
- package/src/lifecycle/assert-live.ts +154 -0
- package/src/lifecycle/change-set.ts +35 -3
- package/src/lifecycle/disruption.test.ts +186 -0
- package/src/lifecycle/disruption.ts +224 -0
- package/src/lifecycle/index.ts +2 -0
- package/src/lifecycle/replay.test.ts +25 -0
- package/src/lifecycle/replay.ts +11 -3
- package/src/op/local-executor.ts +7 -1
- package/src/op/local-output.ts +1 -1
- package/src/testing.test.ts +89 -2
- package/src/testing.ts +63 -3
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* assertLive (#1857) — the read half of the test harness (#1224):
|
|
3
|
+
* observation-backed assertions against a live deploy, for exactly one
|
|
4
|
+
* declared entity at a time. Same primitive teardown.ts's fallback path
|
|
5
|
+
* uses — `describeResources` — turned into a pass/throw instead of a
|
|
6
|
+
* would-delete set.
|
|
7
|
+
*
|
|
8
|
+
* The observation contract (#1089) draws a hard line between OBSERVED-ABSENT
|
|
9
|
+
* and NOT-OBSERVED: a declared entity the read could not cover is never the
|
|
10
|
+
* same as one confirmed missing. `assertLiveEntity` preserves that line by
|
|
11
|
+
* construction — NOT-OBSERVED throws {@link UnobservedAssertionError}, a type
|
|
12
|
+
* distinct from the {@link LiveAssertionError} an observed-absent, foreign,
|
|
13
|
+
* or status-mismatched verdict throws, so a caller can tell "could not tell"
|
|
14
|
+
* from "confirmed wrong" without parsing a message.
|
|
15
|
+
*
|
|
16
|
+
* Marker verification is best-effort by the same logic {@link
|
|
17
|
+
* ResourceMetadata.marker}'s own contract states: a lexicon with no marker
|
|
18
|
+
* channel on this read path (aws's thin `describeResources`, `ownership:
|
|
19
|
+
* "unknown"`) reports no marker at all, which is not the same claim as
|
|
20
|
+
* "foreign". Enforcing a match whenever the channel exists — a present
|
|
21
|
+
* mismatch, or `ownership: "foreign"` with no marker to show — catches the
|
|
22
|
+
* case the harness cares about (a same-named leftover from another env);
|
|
23
|
+
* an absent channel is passed through unverified rather than making every
|
|
24
|
+
* lexicon without one unusable.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { normalizeObservation, unobservedAll, unobservedReasonText, type UnobservedReason } from "../observation";
|
|
28
|
+
import type { ObservationLexicon, ResourceMetadata } from "../lexicon";
|
|
29
|
+
import type { OwnershipMarker } from "../ownership";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Thrown by {@link assertLiveEntity} for a confirmed failure: observed
|
|
33
|
+
* absent, a marker that names another stack/env, a resource confirmed
|
|
34
|
+
* foreign, or a status mismatch. Never thrown for NOT-OBSERVED — see {@link
|
|
35
|
+
* UnobservedAssertionError}.
|
|
36
|
+
*/
|
|
37
|
+
export class LiveAssertionError extends Error {
|
|
38
|
+
constructor(message: string) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = "LiveAssertionError";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Thrown when the entity is NOT-OBSERVED (#1089) rather than confirmed
|
|
46
|
+
* present or absent. Kept as its own type, not a flag on {@link
|
|
47
|
+
* LiveAssertionError}: a suite (or a CI policy) that wants to fail loudly on
|
|
48
|
+
* "could not tell" but treat it differently from a confirmed miss can catch
|
|
49
|
+
* this one specifically.
|
|
50
|
+
*/
|
|
51
|
+
export class UnobservedAssertionError extends Error {
|
|
52
|
+
constructor(
|
|
53
|
+
public readonly entity: string,
|
|
54
|
+
public readonly reason: UnobservedReason,
|
|
55
|
+
public readonly detail?: string,
|
|
56
|
+
) {
|
|
57
|
+
super(
|
|
58
|
+
`assertLive("${entity}") is NOT-OBSERVED — ${unobservedReasonText(reason)}` +
|
|
59
|
+
`${detail ? `: ${detail}` : ""}. An entity chant could not read is never the same as one confirmed absent.`,
|
|
60
|
+
);
|
|
61
|
+
this.name = "UnobservedAssertionError";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface AssertLiveOptions {
|
|
66
|
+
/** Expected `ResourceMetadata.status`, where the lexicon reports one. Skipped when omitted. */
|
|
67
|
+
status?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface AssertLiveEntityOptions extends AssertLiveOptions {
|
|
71
|
+
plugin: ObservationLexicon;
|
|
72
|
+
/** chant entity name — the key to assert on. */
|
|
73
|
+
name: string;
|
|
74
|
+
entityType: string;
|
|
75
|
+
props: Record<string, unknown>;
|
|
76
|
+
/** This lexicon's own built output for the deploy, or `""` when none was built. */
|
|
77
|
+
buildOutput: string;
|
|
78
|
+
environment: string;
|
|
79
|
+
/** This deploy's identity — the marker an observed resource is checked against. */
|
|
80
|
+
marker: OwnershipMarker;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** True when `meta` names a different stack/env than `marker`, on whichever signal it carries. */
|
|
84
|
+
function isConfirmedForeign(meta: ResourceMetadata, marker: OwnershipMarker): boolean {
|
|
85
|
+
if (meta.marker) return meta.marker.stack !== marker.stack || meta.marker.env !== marker.env;
|
|
86
|
+
return meta.ownership === "foreign";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Assert one declared entity is live: observed present in `environment`, not
|
|
91
|
+
* a confirmed-foreign resource, and — when `status` is given — reporting
|
|
92
|
+
* that status. Resolves to the entity's {@link ResourceMetadata} on success.
|
|
93
|
+
*
|
|
94
|
+
* Throws {@link UnobservedAssertionError} for NOT-OBSERVED. Throws {@link
|
|
95
|
+
* LiveAssertionError} for observed-absent, a confirmed-foreign identity, or a
|
|
96
|
+
* status mismatch.
|
|
97
|
+
*/
|
|
98
|
+
export async function assertLiveEntity(opts: AssertLiveEntityOptions): Promise<ResourceMetadata> {
|
|
99
|
+
const { plugin, name, entityType, props, buildOutput, environment, marker, status } = opts;
|
|
100
|
+
|
|
101
|
+
if (!plugin.describeResources) {
|
|
102
|
+
throw new UnobservedAssertionError(
|
|
103
|
+
name,
|
|
104
|
+
"unsupported-kind",
|
|
105
|
+
`the "${plugin.name}" lexicon implements no describeResources`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let observed;
|
|
110
|
+
try {
|
|
111
|
+
observed = normalizeObservation(
|
|
112
|
+
await plugin.describeResources({
|
|
113
|
+
environment,
|
|
114
|
+
buildOutput,
|
|
115
|
+
entityNames: [name],
|
|
116
|
+
entities: new Map([[name, { entityType, props }]]),
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
121
|
+
observed = {
|
|
122
|
+
resources: {},
|
|
123
|
+
unobserved: unobservedAll([name], "read-failed", detail, { [name]: entityType }),
|
|
124
|
+
queried: {},
|
|
125
|
+
notes: [],
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const unobserved = observed.unobserved[name];
|
|
130
|
+
if (unobserved) throw new UnobservedAssertionError(name, unobserved.reason, unobserved.detail);
|
|
131
|
+
|
|
132
|
+
const meta = observed.resources[name];
|
|
133
|
+
if (!meta) {
|
|
134
|
+
throw new LiveAssertionError(
|
|
135
|
+
`assertLive("${name}"): observed absent — chant looked and "${environment}" reported no such resource.`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (isConfirmedForeign(meta, marker)) {
|
|
140
|
+
const found = meta.marker ? `{ stack: "${meta.marker.stack}", env: "${meta.marker.env ?? ""}" }` : "no chant marker";
|
|
141
|
+
throw new LiveAssertionError(
|
|
142
|
+
`assertLive("${name}"): observed, but it is not this deploy's — carries ${found}, not ` +
|
|
143
|
+
`{ stack: "${marker.stack}", env: "${marker.env ?? ""}" }. A same-named resource from another stack or env cannot satisfy this assertion.`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (status !== undefined && meta.status !== status) {
|
|
148
|
+
throw new LiveAssertionError(
|
|
149
|
+
`assertLive("${name}", { status: "${status}" }): observed with status "${meta.status}".`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return meta;
|
|
154
|
+
}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { diffLive, type AttributeChange, type DiffLiveInput } from "./live-diff";
|
|
16
16
|
import { unobservedReasonText, type UnobservedReason } from "../observation";
|
|
17
|
+
import { renderDisruption, summarizeDisruption, type Disruption } from "./disruption";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* What the projection proposes for a single resource.
|
|
@@ -129,6 +130,22 @@ export interface ChangeSetEntry {
|
|
|
129
130
|
effectReason?: EffectFireReason;
|
|
130
131
|
/** Human-readable backing for `effectReason` (the digests that differ, the unresolved path). */
|
|
131
132
|
effectDetail?: string;
|
|
133
|
+
/**
|
|
134
|
+
* How much applying this change hurts (#1665) — in-place / rolling / replace
|
|
135
|
+
* / destroy / unknown. Set on `update` entries only: every other action
|
|
136
|
+
* carries its blast radius in the action itself.
|
|
137
|
+
*
|
|
138
|
+
* The verdict comes from the lexicon that owns the spec
|
|
139
|
+
* ({@link LexiconPlugin.classifyDisruption}), never from core, which has no
|
|
140
|
+
* per-provider replacement rules and must not grow any. `unknown` is the
|
|
141
|
+
* default and the only fallback — read it as "nobody could say", never as
|
|
142
|
+
* "probably in place".
|
|
143
|
+
*/
|
|
144
|
+
disruption?: Disruption;
|
|
145
|
+
/** The attribute paths that forced `disruption` (#1665). */
|
|
146
|
+
disruptionBecause?: string[];
|
|
147
|
+
/** Human-readable backing for `disruption` — the spec knowledge behind the call, or why there is none. */
|
|
148
|
+
disruptionDetail?: string;
|
|
132
149
|
}
|
|
133
150
|
|
|
134
151
|
export interface ChangeSet {
|
|
@@ -314,6 +331,16 @@ export function renderChangeSet(cs: ChangeSet): string {
|
|
|
314
331
|
const header = ACTION_ORDER.map((a) => `${counts[a]} ${a}`).join(", ");
|
|
315
332
|
const lines: string[] = [`Plan for ${cs.env}: ${header}`];
|
|
316
333
|
|
|
334
|
+
// Disruption (#1665) rides the header too, so the one number that says how
|
|
335
|
+
// much this plan hurts is visible without reading every row.
|
|
336
|
+
const disruption = summarizeDisruption(cs);
|
|
337
|
+
const disruptionParts = (Object.entries(disruption) as Array<[Disruption, number]>)
|
|
338
|
+
.filter(([, n]) => n > 0)
|
|
339
|
+
.map(([level, n]) => `${n} ${level}`);
|
|
340
|
+
if (disruptionParts.length > 0) {
|
|
341
|
+
lines.push(`Disruption: ${disruptionParts.join(", ")}`);
|
|
342
|
+
}
|
|
343
|
+
|
|
317
344
|
for (const action of ACTION_ORDER) {
|
|
318
345
|
const group = cs.entries.filter((e) => e.action === action);
|
|
319
346
|
if (group.length === 0) continue;
|
|
@@ -324,7 +351,9 @@ export function renderChangeSet(cs: ChangeSet): string {
|
|
|
324
351
|
? "\nRUNTIME (owned by a declared resource; not drift, never a delete/adopt candidate):"
|
|
325
352
|
: action === "effect"
|
|
326
353
|
? "\nEFFECT (receipt absent or stale; the effect step fires — the generic apply never writes a receipt):"
|
|
327
|
-
:
|
|
354
|
+
: action === "update" && disruptionParts.length > 0
|
|
355
|
+
? "\nUPDATE (disruption from the lexicon that owns the spec; unknown means nobody could say, not that it is safe):"
|
|
356
|
+
: `\n${action.toUpperCase()}:`,
|
|
328
357
|
);
|
|
329
358
|
for (const e of group) {
|
|
330
359
|
if (e.action === "effect") {
|
|
@@ -339,9 +368,12 @@ export function renderChangeSet(cs: ChangeSet): string {
|
|
|
339
368
|
? ` — ${unobservedReasonText(e.unobservedReason)}${e.unobservedDetail ? `: ${e.unobservedDetail}` : ""}`
|
|
340
369
|
: "";
|
|
341
370
|
const owner = e.runtimeOwner ? ` — owned by ${e.runtimeOwner}` : "";
|
|
342
|
-
lines.push(` ${e.name}${e.type ? ` (${e.type})` : ""}${own}${why}${owner}`);
|
|
371
|
+
lines.push(` ${e.name}${e.type ? ` (${e.type})` : ""}${own}${why}${owner}${renderDisruption(e)}`);
|
|
372
|
+
const forced = new Set(e.disruptionBecause ?? []);
|
|
343
373
|
for (const d of e.deltas ?? []) {
|
|
344
|
-
|
|
374
|
+
// A path the verdict rests on is marked, so a `replace` row says which
|
|
375
|
+
// of five changed properties caused it.
|
|
376
|
+
lines.push(` ${forced.has(d.path) ? "! " : ""}${d.path}: ${fmt(d.oldValue)} → ${fmt(d.newValue)}`);
|
|
345
377
|
}
|
|
346
378
|
}
|
|
347
379
|
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
annotateDisruption,
|
|
4
|
+
disruptionNotices,
|
|
5
|
+
summarizeDisruption,
|
|
6
|
+
worstDisruption,
|
|
7
|
+
type DisruptionClassifier,
|
|
8
|
+
} from "./disruption";
|
|
9
|
+
import { renderChangeSet, type ChangeSet, type ChangeSetEntry } from "./change-set";
|
|
10
|
+
|
|
11
|
+
function entry(overrides: Partial<ChangeSetEntry> = {}): ChangeSetEntry {
|
|
12
|
+
return {
|
|
13
|
+
name: "db",
|
|
14
|
+
type: "AWS::RDS::DBInstance",
|
|
15
|
+
lexicon: "aws",
|
|
16
|
+
action: "update",
|
|
17
|
+
evidence: { declared: true, inSnapshot: true, live: true, observed: true },
|
|
18
|
+
deltas: [{ path: "attributes.Engine", oldValue: "postgres", newValue: "mysql" }],
|
|
19
|
+
ownership: "unknown",
|
|
20
|
+
...overrides,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function set(entries: ChangeSetEntry[]): ChangeSet {
|
|
25
|
+
return { env: "prod", entries };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("annotateDisruption (#1665)", () => {
|
|
29
|
+
test("no classifier degrades every update to unknown, never in-place", async () => {
|
|
30
|
+
const out = await annotateDisruption(set([entry()]), "prod", undefined);
|
|
31
|
+
expect(out.entries[0].disruption).toBe("unknown");
|
|
32
|
+
expect(out.entries[0].disruptionDetail).toContain("aws lexicon does not classify disruption");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("a lexicon's verdict rides the entry", async () => {
|
|
36
|
+
const classify: DisruptionClassifier = () => ({
|
|
37
|
+
db: { disruption: "replace", because: ["attributes.Engine"], detail: "Engine is create-only" },
|
|
38
|
+
});
|
|
39
|
+
const out = await annotateDisruption(set([entry()]), "prod", classify);
|
|
40
|
+
expect(out.entries[0]).toMatchObject({
|
|
41
|
+
disruption: "replace",
|
|
42
|
+
disruptionBecause: ["attributes.Engine"],
|
|
43
|
+
disruptionDetail: "Engine is create-only",
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("a name the classifier said nothing about is unknown", async () => {
|
|
48
|
+
const classify: DisruptionClassifier = () => ({});
|
|
49
|
+
const out = await annotateDisruption(set([entry()]), "prod", classify);
|
|
50
|
+
expect(out.entries[0].disruption).toBe("unknown");
|
|
51
|
+
expect(out.entries[0].disruptionDetail).toContain("returned no verdict");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// The guard that makes `in-place` trustworthy: a lexicon cannot smuggle a
|
|
55
|
+
// level in that core does not recognise, and a bogus one is never treated as
|
|
56
|
+
// the safe end of the scale.
|
|
57
|
+
test("a level outside the vocabulary is rejected into unknown", async () => {
|
|
58
|
+
const classify = (() => ({
|
|
59
|
+
db: { disruption: "totally-fine", detail: "trust me" },
|
|
60
|
+
})) as unknown as DisruptionClassifier;
|
|
61
|
+
const out = await annotateDisruption(set([entry()]), "prod", classify);
|
|
62
|
+
expect(out.entries[0].disruption).toBe("unknown");
|
|
63
|
+
expect(out.entries[0].disruptionDetail).toContain("totally-fine");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("a classifier that throws leaves unknown, and the plan survives", async () => {
|
|
67
|
+
const classify: DisruptionClassifier = () => {
|
|
68
|
+
throw new Error("registry missing");
|
|
69
|
+
};
|
|
70
|
+
const out = await annotateDisruption(set([entry()]), "prod", classify);
|
|
71
|
+
expect(out.entries[0].disruption).toBe("unknown");
|
|
72
|
+
expect(out.entries[0].disruptionDetail).toContain("registry missing");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("an async classifier is awaited", async () => {
|
|
76
|
+
const classify: DisruptionClassifier = async () => ({
|
|
77
|
+
db: { disruption: "in-place", detail: "no create-only property changed" },
|
|
78
|
+
});
|
|
79
|
+
const out = await annotateDisruption(set([entry()]), "prod", classify);
|
|
80
|
+
expect(out.entries[0].disruption).toBe("in-place");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("only update entries are classified", async () => {
|
|
84
|
+
const classify: DisruptionClassifier = ({ changes }) => {
|
|
85
|
+
expect(changes.map((c) => c.name)).toEqual(["db"]);
|
|
86
|
+
return { db: { disruption: "destroy" } };
|
|
87
|
+
};
|
|
88
|
+
const out = await annotateDisruption(
|
|
89
|
+
set([
|
|
90
|
+
entry(),
|
|
91
|
+
entry({ name: "bucket", action: "create", deltas: undefined }),
|
|
92
|
+
entry({ name: "queue", action: "delete", deltas: undefined }),
|
|
93
|
+
]),
|
|
94
|
+
"prod",
|
|
95
|
+
classify,
|
|
96
|
+
);
|
|
97
|
+
const byName = Object.fromEntries(out.entries.map((e) => [e.name, e]));
|
|
98
|
+
expect(byName.db.disruption).toBe("destroy");
|
|
99
|
+
expect(byName.bucket.disruption).toBeUndefined();
|
|
100
|
+
expect(byName.queue.disruption).toBeUndefined();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("a set with no updates is returned untouched, and no classifier is called", async () => {
|
|
104
|
+
let called = false;
|
|
105
|
+
const cs = set([entry({ action: "noop", deltas: undefined })]);
|
|
106
|
+
const out = await annotateDisruption(cs, "prod", () => {
|
|
107
|
+
called = true;
|
|
108
|
+
return {};
|
|
109
|
+
});
|
|
110
|
+
expect(out).toBe(cs);
|
|
111
|
+
expect(called).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("the input change set is not mutated", async () => {
|
|
115
|
+
const cs = set([entry()]);
|
|
116
|
+
await annotateDisruption(cs, "prod", () => ({ db: { disruption: "replace" } }));
|
|
117
|
+
expect(cs.entries[0].disruption).toBeUndefined();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("summaries and notices", () => {
|
|
122
|
+
const classified = set([
|
|
123
|
+
entry({ name: "db", disruption: "destroy" }),
|
|
124
|
+
entry({ name: "sg", disruption: "replace" }),
|
|
125
|
+
entry({ name: "tags", disruption: "in-place" }),
|
|
126
|
+
entry({ name: "mystery", disruption: "unknown" }),
|
|
127
|
+
entry({ name: "bucket", action: "noop", disruption: undefined, deltas: undefined }),
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
test("summarizeDisruption counts update entries only", () => {
|
|
131
|
+
expect(summarizeDisruption(classified)).toEqual({
|
|
132
|
+
"in-place": 1,
|
|
133
|
+
rolling: 0,
|
|
134
|
+
replace: 1,
|
|
135
|
+
destroy: 1,
|
|
136
|
+
unknown: 1,
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("worstDisruption ranks unknown above every confident verdict", () => {
|
|
141
|
+
expect(worstDisruption(classified)).toBe("unknown");
|
|
142
|
+
expect(worstDisruption(set([entry({ disruption: "in-place" })]))).toBe("in-place");
|
|
143
|
+
expect(worstDisruption(set([entry({ action: "noop", disruption: undefined })]))).toBeUndefined();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("notices name the replacing and the unclassified rows", () => {
|
|
147
|
+
const notices = disruptionNotices(classified);
|
|
148
|
+
expect(notices[0]).toContain("2 update(s) replace the resource");
|
|
149
|
+
expect(notices[0]).toContain("1 of them by deleting it first");
|
|
150
|
+
expect(notices[1]).toContain("1 update(s) could not be classified");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("a clean plan produces no notices", () => {
|
|
154
|
+
expect(disruptionNotices(set([entry({ disruption: "in-place" })]))).toEqual([]);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("renderChangeSet with disruption", () => {
|
|
159
|
+
test("the header, the row, and the forcing delta all say it", () => {
|
|
160
|
+
const out = renderChangeSet(
|
|
161
|
+
set([
|
|
162
|
+
entry({
|
|
163
|
+
disruption: "replace",
|
|
164
|
+
disruptionBecause: ["attributes.Engine"],
|
|
165
|
+
disruptionDetail: "Engine is create-only",
|
|
166
|
+
deltas: [
|
|
167
|
+
{ path: "attributes.Engine", oldValue: "postgres", newValue: "mysql" },
|
|
168
|
+
{ path: "attributes.AllocatedStorage", oldValue: 20, newValue: 40 },
|
|
169
|
+
],
|
|
170
|
+
}),
|
|
171
|
+
]),
|
|
172
|
+
);
|
|
173
|
+
expect(out).toContain("Disruption: 1 replace");
|
|
174
|
+
expect(out).toContain("UPDATE (disruption from the lexicon that owns the spec");
|
|
175
|
+
expect(out).toContain("db (AWS::RDS::DBInstance) — replace: Engine is create-only");
|
|
176
|
+
expect(out).toContain("! attributes.Engine:");
|
|
177
|
+
expect(out).toContain(" attributes.AllocatedStorage:");
|
|
178
|
+
expect(out).not.toContain("! attributes.AllocatedStorage");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("an unclassified plan renders the plain UPDATE header", () => {
|
|
182
|
+
const out = renderChangeSet(set([entry({ disruption: undefined })]));
|
|
183
|
+
expect(out).toContain("\nUPDATE:");
|
|
184
|
+
expect(out).not.toContain("Disruption:");
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-change disruption classification (#1665).
|
|
3
|
+
*
|
|
4
|
+
* The change set says WHAT a pending change is (`create`/`update`/`delete`/…).
|
|
5
|
+
* It says nothing about what applying it costs. An `update` that flips a tag
|
|
6
|
+
* and an `update` that rebuilds a database read identically, and the second one
|
|
7
|
+
* is the one that wakes somebody up.
|
|
8
|
+
*
|
|
9
|
+
* The knowledge that separates them is spec knowledge. CloudFormation's
|
|
10
|
+
* registry schema declares `createOnlyProperties` per type; Kubernetes' SSA
|
|
11
|
+
* schema knows which field changes roll a workload. Core owns neither, and
|
|
12
|
+
* hardcoding either here would put per-provider replacement rules in the tool
|
|
13
|
+
* — the same mistake `postSynthChecks` exists to avoid. So core defines the
|
|
14
|
+
* contract and the reporting, and the lexicon that compiled the spec supplies
|
|
15
|
+
* the answer, via {@link LexiconPlugin.classifyDisruption}.
|
|
16
|
+
*
|
|
17
|
+
* The invariant that makes the field trustworthy is that `unknown` is the
|
|
18
|
+
* default and the only fallback. No classifier, a classifier that says nothing
|
|
19
|
+
* about an entry, a classifier that throws, a classifier that returns a level
|
|
20
|
+
* outside the vocabulary — all of them land on `unknown`, never on `in-place`.
|
|
21
|
+
* A confident "this mutates in place" is only ever a lexicon's own claim.
|
|
22
|
+
*/
|
|
23
|
+
import type { AttributeChange } from "./live-diff";
|
|
24
|
+
import type { ChangeSet, ChangeSetEntry } from "./change-set";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* How much applying one pending change hurts.
|
|
28
|
+
*
|
|
29
|
+
* - `in-place` — the provider mutates the existing resource. No new identity,
|
|
30
|
+
* no window where it is absent.
|
|
31
|
+
* - `rolling` — the resource survives, but its workload is replaced
|
|
32
|
+
* incrementally (a Deployment's pod template changing). Disruptive to what
|
|
33
|
+
* is running, not to the resource.
|
|
34
|
+
* - `replace` — a new resource is created and the old one removed. The
|
|
35
|
+
* physical id changes; anything holding the old one has to be updated.
|
|
36
|
+
* - `destroy` — replacement that removes the old resource FIRST. There is a
|
|
37
|
+
* window with nothing there, and whatever the old one held is gone.
|
|
38
|
+
* - `unknown` — nobody could say. The honest value, and the default: it is
|
|
39
|
+
* what a change gets when no lexicon classifies it, and it must never be
|
|
40
|
+
* read as "probably fine".
|
|
41
|
+
*/
|
|
42
|
+
export type Disruption = "in-place" | "rolling" | "replace" | "destroy" | "unknown";
|
|
43
|
+
|
|
44
|
+
/** Every level, most disruptive last — also the guard core validates a lexicon's answer against. */
|
|
45
|
+
export const DISRUPTION_LEVELS: readonly Disruption[] = [
|
|
46
|
+
"in-place",
|
|
47
|
+
"rolling",
|
|
48
|
+
"replace",
|
|
49
|
+
"destroy",
|
|
50
|
+
"unknown",
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
/** Ordering for "the worst thing in this plan", with `unknown` above every confident verdict. */
|
|
54
|
+
const DISRUPTION_RANK: Record<Disruption, number> = {
|
|
55
|
+
"in-place": 0,
|
|
56
|
+
rolling: 1,
|
|
57
|
+
replace: 2,
|
|
58
|
+
destroy: 3,
|
|
59
|
+
unknown: 4,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** One pending change put to a lexicon for classification. */
|
|
63
|
+
export interface DisruptionQuery {
|
|
64
|
+
/** The change set entry's `name` — the key a verdict comes back under. */
|
|
65
|
+
name: string;
|
|
66
|
+
/** Resource type, when the observation reported one. */
|
|
67
|
+
type?: string;
|
|
68
|
+
/** The attribute-level changes the entry carries. */
|
|
69
|
+
deltas: AttributeChange[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A lexicon's answer for one query. */
|
|
73
|
+
export interface DisruptionVerdict {
|
|
74
|
+
disruption: Disruption;
|
|
75
|
+
/** The attribute paths that forced the verdict — empty or absent when none did. */
|
|
76
|
+
because?: string[];
|
|
77
|
+
/** One line of human-readable backing, naming the spec knowledge behind the call. */
|
|
78
|
+
detail?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The shape of {@link LexiconPlugin.classifyDisruption}. Keyed by query `name`;
|
|
83
|
+
* a name the lexicon says nothing about degrades to `unknown`, so a partial
|
|
84
|
+
* answer is a valid answer.
|
|
85
|
+
*/
|
|
86
|
+
export type DisruptionClassifier = (options: {
|
|
87
|
+
environment: string;
|
|
88
|
+
changes: DisruptionQuery[];
|
|
89
|
+
}) => Record<string, DisruptionVerdict> | Promise<Record<string, DisruptionVerdict>>;
|
|
90
|
+
|
|
91
|
+
/** The verdict every fallback path produces. */
|
|
92
|
+
export function unknownDisruption(detail: string): DisruptionVerdict {
|
|
93
|
+
return { disruption: "unknown", detail };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Annotate one lexicon's change set with a disruption verdict per `update`.
|
|
98
|
+
*
|
|
99
|
+
* Only `update` entries are asked about: every other action already carries its
|
|
100
|
+
* blast radius in the action itself. Called once per lexicon, before the plan
|
|
101
|
+
* merges the change sets, so `classify` is always the lexicon that produced the
|
|
102
|
+
* entries — the only party that can map its own observation's attribute paths
|
|
103
|
+
* back onto spec properties.
|
|
104
|
+
*
|
|
105
|
+
* Returns a new change set; the input is not mutated.
|
|
106
|
+
*/
|
|
107
|
+
export async function annotateDisruption(
|
|
108
|
+
cs: ChangeSet,
|
|
109
|
+
environment: string,
|
|
110
|
+
classify?: DisruptionClassifier,
|
|
111
|
+
): Promise<ChangeSet> {
|
|
112
|
+
const updates = cs.entries.filter((e) => e.action === "update");
|
|
113
|
+
if (updates.length === 0) return cs;
|
|
114
|
+
|
|
115
|
+
const who = updates[0].lexicon ? `the ${updates[0].lexicon} lexicon` : "this lexicon";
|
|
116
|
+
|
|
117
|
+
let verdicts: Record<string, DisruptionVerdict> = {};
|
|
118
|
+
let fallback: string | undefined;
|
|
119
|
+
|
|
120
|
+
if (!classify) {
|
|
121
|
+
fallback = `${who} does not classify disruption — replacement semantics are spec knowledge it has not published`;
|
|
122
|
+
} else {
|
|
123
|
+
try {
|
|
124
|
+
verdicts = (await classify({
|
|
125
|
+
environment,
|
|
126
|
+
changes: updates.map((e) => ({
|
|
127
|
+
name: e.name,
|
|
128
|
+
...(e.type ? { type: e.type } : {}),
|
|
129
|
+
deltas: e.deltas ?? [],
|
|
130
|
+
})),
|
|
131
|
+
})) ?? {};
|
|
132
|
+
} catch (err) {
|
|
133
|
+
// A broken classifier is not evidence of anything. It must not be able to
|
|
134
|
+
// leave a confident verdict behind, and it must not fail the plan either.
|
|
135
|
+
verdicts = {};
|
|
136
|
+
fallback = `${who}'s disruption classifier failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const entries = cs.entries.map((e) => {
|
|
141
|
+
if (e.action !== "update") return e;
|
|
142
|
+
const verdict = resolveVerdict(verdicts[e.name], fallback, who);
|
|
143
|
+
const annotated: ChangeSetEntry = {
|
|
144
|
+
...e,
|
|
145
|
+
disruption: verdict.disruption,
|
|
146
|
+
...(verdict.because && verdict.because.length > 0 ? { disruptionBecause: verdict.because } : {}),
|
|
147
|
+
...(verdict.detail ? { disruptionDetail: verdict.detail } : {}),
|
|
148
|
+
};
|
|
149
|
+
return annotated;
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
return { ...cs, entries };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function resolveVerdict(
|
|
156
|
+
verdict: DisruptionVerdict | undefined,
|
|
157
|
+
fallback: string | undefined,
|
|
158
|
+
who: string,
|
|
159
|
+
): DisruptionVerdict {
|
|
160
|
+
if (fallback) return unknownDisruption(fallback);
|
|
161
|
+
if (!verdict) return unknownDisruption(`${who} returned no verdict for this change`);
|
|
162
|
+
if (!DISRUPTION_LEVELS.includes(verdict.disruption)) {
|
|
163
|
+
return unknownDisruption(
|
|
164
|
+
`${who} returned "${String(verdict.disruption)}", which is not a disruption level`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
return verdict;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Count `update` entries per level. Entries with no verdict at all are not counted. */
|
|
171
|
+
export function summarizeDisruption(cs: ChangeSet): Record<Disruption, number> {
|
|
172
|
+
const counts: Record<Disruption, number> = {
|
|
173
|
+
"in-place": 0,
|
|
174
|
+
rolling: 0,
|
|
175
|
+
replace: 0,
|
|
176
|
+
destroy: 0,
|
|
177
|
+
unknown: 0,
|
|
178
|
+
};
|
|
179
|
+
for (const e of cs.entries) {
|
|
180
|
+
if (e.action === "update" && e.disruption) counts[e.disruption]++;
|
|
181
|
+
}
|
|
182
|
+
return counts;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The most disruptive verdict in the set, or undefined when nothing was classified. */
|
|
186
|
+
export function worstDisruption(cs: ChangeSet): Disruption | undefined {
|
|
187
|
+
let worst: Disruption | undefined;
|
|
188
|
+
for (const e of cs.entries) {
|
|
189
|
+
if (e.action !== "update" || !e.disruption) continue;
|
|
190
|
+
if (!worst || DISRUPTION_RANK[e.disruption] > DISRUPTION_RANK[worst]) worst = e.disruption;
|
|
191
|
+
}
|
|
192
|
+
return worst;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Warnings a plan should print on stderr — so a `--json` or `--report gitlab-mr`
|
|
197
|
+
* consumer, whose shape has no column for disruption, still hears about the
|
|
198
|
+
* expensive rows. Same discipline as the unobserved warning (#1089).
|
|
199
|
+
*/
|
|
200
|
+
export function disruptionNotices(cs: ChangeSet): string[] {
|
|
201
|
+
const counts = summarizeDisruption(cs);
|
|
202
|
+
const notices: string[] = [];
|
|
203
|
+
const replacing = counts.replace + counts.destroy;
|
|
204
|
+
if (replacing > 0) {
|
|
205
|
+
notices.push(
|
|
206
|
+
`${replacing} update(s) replace the resource rather than mutating it in place` +
|
|
207
|
+
(counts.destroy > 0
|
|
208
|
+
? `, ${counts.destroy} of them by deleting it first — that window has nothing in it.`
|
|
209
|
+
: "."),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
if (counts.unknown > 0) {
|
|
213
|
+
notices.push(
|
|
214
|
+
`${counts.unknown} update(s) could not be classified — no lexicon could say whether applying them replaces the resource. Unknown is not "in place".`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
return notices;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Render one entry's verdict for the human plan, or "" when there is none. */
|
|
221
|
+
export function renderDisruption(entry: ChangeSetEntry): string {
|
|
222
|
+
if (!entry.disruption) return "";
|
|
223
|
+
return ` — ${entry.disruption}${entry.disruptionDetail ? `: ${entry.disruptionDetail}` : ""}`;
|
|
224
|
+
}
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ export * from "./deep-diff";
|
|
|
7
7
|
export * from "./deep-observe";
|
|
8
8
|
export * from "./observation-baseline";
|
|
9
9
|
export * from "./change-set";
|
|
10
|
+
export * from "./disruption";
|
|
10
11
|
export * from "./unobserved-gate";
|
|
11
12
|
export * from "./receipt-plan";
|
|
12
13
|
export * from "./affected";
|
|
@@ -16,6 +17,7 @@ export * from "./build-ledger-store";
|
|
|
16
17
|
export * from "./oras-referrer-lookup";
|
|
17
18
|
export * from "./status";
|
|
18
19
|
export * from "./teardown";
|
|
20
|
+
export * from "./assert-live";
|
|
19
21
|
export * from "./symptoms";
|
|
20
22
|
export * from "./converge-ledger";
|
|
21
23
|
export * from "./scenario";
|
|
@@ -249,3 +249,28 @@ describe("a resource recorded twice is one node (#1432 follow-up)", () => {
|
|
|
249
249
|
expect(keys(result.observations)).toEqual(["rtb-default"]);
|
|
250
250
|
});
|
|
251
251
|
});
|
|
252
|
+
|
|
253
|
+
describe("replaySnapshots — recorded depth (#1268)", () => {
|
|
254
|
+
it("reports identity when nothing recorded deeper", async () => {
|
|
255
|
+
stored.set("main__aws", snapshot("main", "us-east-1", { managed: { web: "i-1" } }));
|
|
256
|
+
const result = await replaySnapshots("prod", "latest", new Set());
|
|
257
|
+
if ("error" in result) throw new Error(result.error);
|
|
258
|
+
expect(result.depth).toBe("identity");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("reports deep when any recorded lexicon read that deep", async () => {
|
|
262
|
+
stored.set("main__aws", JSON.stringify({
|
|
263
|
+
lexicon: "aws", environment: "prod", stack: "main",
|
|
264
|
+
commit: "abc", timestamp: "2026-08-03T00:00:00.000Z", depth: "deep",
|
|
265
|
+
resources: { web: { type: "AWS::EC2::Instance", status: "OBSERVED", physicalId: "i-1" } },
|
|
266
|
+
}));
|
|
267
|
+
stored.set("net__aws", JSON.stringify({
|
|
268
|
+
lexicon: "aws", environment: "prod", stack: "net",
|
|
269
|
+
commit: "abc", timestamp: "2026-08-03T00:00:00.000Z",
|
|
270
|
+
resources: { vpc: { type: "AWS::EC2::VPC", status: "OBSERVED", physicalId: "vpc-1" } },
|
|
271
|
+
}));
|
|
272
|
+
const result = await replaySnapshots("prod", "latest", new Set());
|
|
273
|
+
if ("error" in result) throw new Error(result.error);
|
|
274
|
+
expect(result.depth).toBe("deep");
|
|
275
|
+
});
|
|
276
|
+
});
|