@intentius/chant 0.50.0 → 0.52.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/build-options.d.ts +68 -0
- package/dist/cli/build-options.d.ts.map +1 -0
- package/dist/cli/commands/build.d.ts.map +1 -1
- 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 +0 -21
- 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 +40 -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/lint/policy.d.ts +16 -0
- package/dist/lint/policy.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +22 -2
- 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/build-options.test.ts +101 -0
- package/src/cli/build-options.ts +109 -0
- package/src/cli/commands/build.ts +24 -53
- package/src/cli/handlers/lifecycle.test.ts +109 -0
- package/src/cli/handlers/lifecycle.ts +32 -8
- 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 +240 -0
- package/src/cli/handlers/run.ts +76 -18
- package/src/cli/handlers/search-drift.test.ts +263 -0
- package/src/cli/handlers/search.ts +150 -1
- package/src/cli/main.ts +11 -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.test.ts +144 -1
- package/src/lifecycle/change-set.ts +165 -11
- 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/lint/policy-build-parity.test.ts +232 -0
- package/src/lint/policy.ts +51 -6
- package/src/op/local-executor.ts +35 -1
- package/src/op/local-output.ts +1 -1
- package/src/testing.test.ts +89 -2
- package/src/testing.ts +63 -3
|
@@ -107,11 +107,15 @@ export type RunProgressSink = (event: RunProgressEvent) => void;
|
|
|
107
107
|
/**
|
|
108
108
|
* Build a sink that writes `JSON.stringify(event) + "\n"` to `write` (default:
|
|
109
109
|
* `process.stdout.write`), one line per event, as they happen — the
|
|
110
|
-
* `--progress-json` CLI wiring's sink (../cli/handlers/run.ts
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
110
|
+
* `--progress-json` CLI wiring's sink (../cli/handlers/run.ts, for both the
|
|
111
|
+
* `--components` driver's `RunProgressEvent`s and the Temporal Op path's
|
|
112
|
+
* `StepRecord`s, chant #1676). Kept separate from `driver-output.ts`'s
|
|
113
|
+
* end-of-run renderers: this emits *during* the run, one line at a time;
|
|
114
|
+
* `renderDriverJson`/`renderDriverHuman` render the completed
|
|
115
|
+
* `DriverRunResult` once, after the run finishes.
|
|
114
116
|
*/
|
|
115
|
-
export function ndjsonProgressSink
|
|
117
|
+
export function ndjsonProgressSink<T = RunProgressEvent>(
|
|
118
|
+
write: (chunk: string) => void = (s) => void process.stdout.write(s),
|
|
119
|
+
): (event: T) => void {
|
|
116
120
|
return (event) => write(JSON.stringify(event) + "\n");
|
|
117
121
|
}
|
package/src/lexicon.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type { ReferenceCatalog } from "./graph-refs";
|
|
|
18
18
|
import type { IREdge } from "./graph-ir";
|
|
19
19
|
import type { DescribeResourcesResult, UnobservedReason } from "./observation";
|
|
20
20
|
import type { DeepNormalizationHooks, DeepObservationResult } from "./deep-observation";
|
|
21
|
+
import type { DisruptionQuery, DisruptionVerdict } from "./lifecycle/disruption";
|
|
21
22
|
import type { OwnerChainVerdict } from "./owner-chain";
|
|
22
23
|
import type { CommandGroup } from "./cli/command-group";
|
|
23
24
|
|
|
@@ -44,6 +45,16 @@ export type {
|
|
|
44
45
|
UnobservedReason,
|
|
45
46
|
} from "./observation";
|
|
46
47
|
|
|
48
|
+
// Disruption classification (#1665), re-exported from the same entry so a
|
|
49
|
+
// lexicon's `classifyDisruption` types itself without a second import path.
|
|
50
|
+
// Runtime helpers live in `@intentius/chant/lifecycle/disruption`.
|
|
51
|
+
export type {
|
|
52
|
+
Disruption,
|
|
53
|
+
DisruptionQuery,
|
|
54
|
+
DisruptionVerdict,
|
|
55
|
+
DisruptionClassifier,
|
|
56
|
+
} from "./lifecycle/disruption";
|
|
57
|
+
|
|
47
58
|
// The deep observation contract (#1014), re-exported for the same reason: a
|
|
48
59
|
// lexicon authoring `observeResourcesDeep` + its pruning/ordering hooks types
|
|
49
60
|
// them from the same entry. Runtime helpers live in
|
|
@@ -878,6 +889,46 @@ export interface LexiconPlugin {
|
|
|
878
889
|
namespace?: string;
|
|
879
890
|
}): Promise<DescribeResourcesResult>;
|
|
880
891
|
|
|
892
|
+
/**
|
|
893
|
+
* Classify how much each pending `update` in a plan hurts (#1665) —
|
|
894
|
+
* in-place / rolling / replace / destroy, or `unknown`.
|
|
895
|
+
*
|
|
896
|
+
* `lifecycle plan` reports WHAT changes. What it costs to apply is a
|
|
897
|
+
* different question, and the answer is spec knowledge: CloudFormation's
|
|
898
|
+
* registry schema declares `createOnlyProperties` per type, Kubernetes' SSA
|
|
899
|
+
* schema knows which field changes roll a workload. Core owns neither, so it
|
|
900
|
+
* defines the vocabulary and does the reporting, and the lexicon that
|
|
901
|
+
* compiled the spec answers — the same division `postSynthChecks` draws for
|
|
902
|
+
* validation. A replacement rule hardcoded in core would be a rule the tool
|
|
903
|
+
* has to keep in step with a provider it does not generate from.
|
|
904
|
+
*
|
|
905
|
+
* Only the lexicon can answer, for a second reason: the `deltas` on an entry
|
|
906
|
+
* are paths into ITS OWN observation shape (`attributes.<key>` off
|
|
907
|
+
* {@link describeResources}), which core cannot map back onto spec property
|
|
908
|
+
* names without knowing how the reader named things.
|
|
909
|
+
*
|
|
910
|
+
* Called once per lexicon with that lexicon's `update` entries, before the
|
|
911
|
+
* plan merges the change sets. Expected to be a table lookup over compiled
|
|
912
|
+
* spec data — no live API call, no mutation. Returning a `Promise` is
|
|
913
|
+
* allowed, so a lexicon whose answer needs an await is not shut out.
|
|
914
|
+
*
|
|
915
|
+
* **Every degradation lands on `unknown`.** Return a partial map: a name
|
|
916
|
+
* absent from the result is `unknown`, which is the right answer when the
|
|
917
|
+
* spec does not say (a conditionally-immutable property, a type with no
|
|
918
|
+
* schema on record). Core also forces `unknown` when this method is absent,
|
|
919
|
+
* when it throws, and when it returns a level outside the vocabulary. There
|
|
920
|
+
* is no path by which a lexicon accidentally produces a confident
|
|
921
|
+
* `in-place` — that claim has to be made deliberately, and a consumer gating
|
|
922
|
+
* on `replace` can trust it for exactly that reason.
|
|
923
|
+
*
|
|
924
|
+
* Omit for lexicons with no replacement semantics to publish.
|
|
925
|
+
*/
|
|
926
|
+
classifyDisruption?(options: {
|
|
927
|
+
environment: string;
|
|
928
|
+
/** This lexicon's `update` entries — name, type, and the attribute deltas. */
|
|
929
|
+
changes: DisruptionQuery[];
|
|
930
|
+
}): Record<string, DisruptionVerdict> | Promise<Record<string, DisruptionVerdict>>;
|
|
931
|
+
|
|
881
932
|
/**
|
|
882
933
|
* Report the undeclared resources this estate *depends on* (#1273), as
|
|
883
934
|
* opposed to the ones it manages.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { createMockPlugin, staticObservation } from "@intentius/chant-test-utils";
|
|
3
|
+
import { observation } from "../observation";
|
|
4
|
+
import { assertLiveEntity, LiveAssertionError, UnobservedAssertionError } from "./assert-live";
|
|
5
|
+
import type { ResourceMetadata } from "../lexicon";
|
|
6
|
+
|
|
7
|
+
const MARKER = { stack: "shop", env: "test-suite-abc123" };
|
|
8
|
+
|
|
9
|
+
const meta = (overrides: Partial<ResourceMetadata> = {}): ResourceMetadata => ({
|
|
10
|
+
type: "Mock::Queue",
|
|
11
|
+
status: "READY",
|
|
12
|
+
marker: MARKER,
|
|
13
|
+
ownership: "owned",
|
|
14
|
+
...overrides,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const call = (opts: Partial<Parameters<typeof assertLiveEntity>[0]> = {}) =>
|
|
18
|
+
assertLiveEntity({
|
|
19
|
+
plugin: createMockPlugin(),
|
|
20
|
+
name: "taskQueue",
|
|
21
|
+
entityType: "Mock::Queue",
|
|
22
|
+
props: {},
|
|
23
|
+
buildOutput: "",
|
|
24
|
+
environment: MARKER.env,
|
|
25
|
+
marker: MARKER,
|
|
26
|
+
...opts,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("assertLiveEntity", () => {
|
|
30
|
+
test("resolves the metadata for an observed, marker-matched entity", async () => {
|
|
31
|
+
const plugin = createMockPlugin({ describeResources: staticObservation({ taskQueue: meta() }) });
|
|
32
|
+
await expect(call({ plugin })).resolves.toEqual(meta());
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("checks status when given, passes when it matches", async () => {
|
|
36
|
+
const plugin = createMockPlugin({ describeResources: staticObservation({ taskQueue: meta({ status: "READY" }) }) });
|
|
37
|
+
await expect(call({ plugin, status: "READY" })).resolves.toEqual(meta());
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("throws naming the entity when observed absent", async () => {
|
|
41
|
+
const plugin = createMockPlugin({ describeResources: staticObservation({}) });
|
|
42
|
+
await expect(call({ plugin })).rejects.toThrow(LiveAssertionError);
|
|
43
|
+
await expect(call({ plugin })).rejects.toThrow(/taskQueue.*observed absent/s);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("throws UnobservedAssertionError, not a plain failure, when NOT-OBSERVED", async () => {
|
|
47
|
+
const plugin = createMockPlugin({
|
|
48
|
+
describeResources: staticObservation({}, { taskQueue: { reason: "no-credentials", type: "Mock::Queue" } }),
|
|
49
|
+
});
|
|
50
|
+
const err = await call({ plugin }).then(
|
|
51
|
+
() => undefined,
|
|
52
|
+
(e: unknown) => e,
|
|
53
|
+
);
|
|
54
|
+
expect(err).toBeInstanceOf(UnobservedAssertionError);
|
|
55
|
+
expect(err).not.toBeInstanceOf(LiveAssertionError);
|
|
56
|
+
expect((err as UnobservedAssertionError).reason).toBe("no-credentials");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("a thrown describeResources degrades to NOT-OBSERVED read-failed, never a silent absence", async () => {
|
|
60
|
+
const plugin = createMockPlugin({
|
|
61
|
+
describeResources: async () => {
|
|
62
|
+
throw new Error("ECONNREFUSED");
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
const err = await call({ plugin }).then(
|
|
66
|
+
() => undefined,
|
|
67
|
+
(e: unknown) => e,
|
|
68
|
+
);
|
|
69
|
+
expect(err).toBeInstanceOf(UnobservedAssertionError);
|
|
70
|
+
expect((err as UnobservedAssertionError).reason).toBe("read-failed");
|
|
71
|
+
expect((err as UnobservedAssertionError).detail).toContain("ECONNREFUSED");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("a lexicon with no describeResources is NOT-OBSERVED, unsupported-kind", async () => {
|
|
75
|
+
const plugin = createMockPlugin();
|
|
76
|
+
const err = await call({ plugin }).then(
|
|
77
|
+
() => undefined,
|
|
78
|
+
(e: unknown) => e,
|
|
79
|
+
);
|
|
80
|
+
expect(err).toBeInstanceOf(UnobservedAssertionError);
|
|
81
|
+
expect((err as UnobservedAssertionError).reason).toBe("unsupported-kind");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("throws when the observed marker names a foreign stack", async () => {
|
|
85
|
+
const plugin = createMockPlugin({
|
|
86
|
+
describeResources: staticObservation({ taskQueue: meta({ marker: { stack: "other-stack", env: MARKER.env } }) }),
|
|
87
|
+
});
|
|
88
|
+
await expect(call({ plugin })).rejects.toThrow(/not this deploy's/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("throws when the observed marker names a foreign env — a same-named leftover from another run", async () => {
|
|
92
|
+
const plugin = createMockPlugin({
|
|
93
|
+
describeResources: staticObservation({ taskQueue: meta({ marker: { stack: MARKER.stack, env: "test-other-999999" } }) }),
|
|
94
|
+
});
|
|
95
|
+
await expect(call({ plugin })).rejects.toThrow(LiveAssertionError);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("throws when the entity is confirmed foreign (no marker, ownership foreign)", async () => {
|
|
99
|
+
const plugin = createMockPlugin({
|
|
100
|
+
describeResources: staticObservation({
|
|
101
|
+
taskQueue: meta({ marker: undefined, ownership: "foreign" }),
|
|
102
|
+
}),
|
|
103
|
+
});
|
|
104
|
+
await expect(call({ plugin })).rejects.toThrow(/not this deploy's/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("passes through an entity whose lexicon has no marker channel (ownership unknown, no marker) rather than always failing", async () => {
|
|
108
|
+
const plugin = createMockPlugin({
|
|
109
|
+
describeResources: staticObservation({
|
|
110
|
+
taskQueue: meta({ marker: undefined, ownership: "unknown" }),
|
|
111
|
+
}),
|
|
112
|
+
});
|
|
113
|
+
await expect(call({ plugin })).resolves.toEqual(meta({ marker: undefined, ownership: "unknown" }));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("throws a status mismatch after identity is confirmed", async () => {
|
|
117
|
+
const plugin = createMockPlugin({ describeResources: staticObservation({ taskQueue: meta({ status: "PENDING" }) }) });
|
|
118
|
+
await expect(call({ plugin, status: "READY" })).rejects.toThrow(/status "PENDING"/);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("uses the observation() envelope form identically to the bare-map form", async () => {
|
|
122
|
+
const plugin = createMockPlugin({ describeResources: async () => observation({ taskQueue: meta() }) });
|
|
123
|
+
await expect(call({ plugin })).resolves.toEqual(meta());
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { describe, expect, test } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
buildChangeSet,
|
|
4
|
+
renderChangeSet,
|
|
5
|
+
renderChangeSetMarkdown,
|
|
6
|
+
summarize,
|
|
7
|
+
gitlabMrReport,
|
|
8
|
+
unobservedPlanNotice,
|
|
9
|
+
type ChangeSet,
|
|
10
|
+
type ChangeSetEntry,
|
|
11
|
+
} from "./change-set";
|
|
3
12
|
import type { ResourceMetadata } from "../lexicon";
|
|
4
13
|
|
|
5
14
|
const meta = (over: Partial<ResourceMetadata> = {}): ResourceMetadata => ({
|
|
@@ -446,3 +455,137 @@ describe("buildChangeSet: not-observed is not absent (#1089)", () => {
|
|
|
446
455
|
expect(gitlabMrReport(cs)).toEqual({ create: 0, update: 0, delete: 0 });
|
|
447
456
|
});
|
|
448
457
|
});
|
|
458
|
+
|
|
459
|
+
// ── Markdown report (#1983) ──────────────────────────────────────────────
|
|
460
|
+
|
|
461
|
+
function entry(overrides: Partial<ChangeSetEntry> = {}): ChangeSetEntry {
|
|
462
|
+
return {
|
|
463
|
+
name: "db",
|
|
464
|
+
type: "AWS::RDS::DBInstance",
|
|
465
|
+
lexicon: "aws",
|
|
466
|
+
action: "update",
|
|
467
|
+
evidence: { declared: true, inSnapshot: true, live: true, observed: true },
|
|
468
|
+
ownership: "unknown",
|
|
469
|
+
...overrides,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function set(entries: ChangeSetEntry[]): ChangeSet {
|
|
474
|
+
return { env: "prod", entries };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
describe("unobservedPlanNotice (#1983)", () => {
|
|
478
|
+
test("empty when nothing is unobserved", () => {
|
|
479
|
+
expect(unobservedPlanNotice(set([entry({ action: "noop" })]))).toEqual([]);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test("one notice naming the count, matching the CLI's stderr wording", () => {
|
|
483
|
+
const cs = set([
|
|
484
|
+
entry({ name: "a", action: "unobserved", unobservedReason: "no-binding" }),
|
|
485
|
+
entry({ name: "b", action: "unobserved", unobservedReason: "read-failed" }),
|
|
486
|
+
entry({ name: "c", action: "noop" }),
|
|
487
|
+
]);
|
|
488
|
+
expect(unobservedPlanNotice(cs)).toEqual([
|
|
489
|
+
"2 declared entity(ies) could not be observed — no create/update/delete is proposed for them. This plan is incomplete, not clean.",
|
|
490
|
+
]);
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
describe("renderChangeSetMarkdown (#1983)", () => {
|
|
495
|
+
test("empty plan: header only, no ANSI, deterministic", () => {
|
|
496
|
+
const out = renderChangeSetMarkdown(set([]));
|
|
497
|
+
expect(out).toBe(
|
|
498
|
+
"## Plan for `prod`\n\n0 create, 0 update, 0 effect, 0 delete, 0 adopt, 0 runtime, 0 noop, 0 unobserved\n",
|
|
499
|
+
);
|
|
500
|
+
expect(out).not.toMatch(/\x1b\[/);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test("counts header and grouped sections, attributed to lexicon", () => {
|
|
504
|
+
const cs = set([
|
|
505
|
+
entry({ name: "new-bucket", type: "S3::Bucket", lexicon: "aws", action: "create", evidence: { declared: true, inSnapshot: false, live: false, observed: true } }),
|
|
506
|
+
entry({ name: "web", type: "K8s::Apps::Deployment", lexicon: "k8s", action: "noop", evidence: { declared: true, inSnapshot: true, live: true, observed: true } }),
|
|
507
|
+
]);
|
|
508
|
+
const out = renderChangeSetMarkdown(cs);
|
|
509
|
+
expect(out).toContain("## Plan for `prod`");
|
|
510
|
+
expect(out).toContain("1 create, 0 update, 0 effect, 0 delete, 0 adopt, 0 runtime, 1 noop, 0 unobserved");
|
|
511
|
+
expect(out).toContain("### CREATE");
|
|
512
|
+
expect(out).toContain("- `new-bucket` (S3::Bucket) `aws`");
|
|
513
|
+
expect(out).toContain("### NOOP");
|
|
514
|
+
expect(out).toContain("- `web` (K8s::Apps::Deployment) `k8s`");
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test("deltas render in a fenced block, forced path marked", () => {
|
|
518
|
+
const cs = set([
|
|
519
|
+
entry({
|
|
520
|
+
deltas: [
|
|
521
|
+
{ path: "attributes.DBInstanceIdentifier", oldValue: "app-db", newValue: "app-db-2" },
|
|
522
|
+
{ path: "attributes.AllocatedStorage", oldValue: 20, newValue: 40 },
|
|
523
|
+
],
|
|
524
|
+
disruption: "destroy",
|
|
525
|
+
disruptionBecause: ["attributes.DBInstanceIdentifier"],
|
|
526
|
+
disruptionDetail: "DBInstanceIdentifier is create-only",
|
|
527
|
+
}),
|
|
528
|
+
]);
|
|
529
|
+
const out = renderChangeSetMarkdown(cs);
|
|
530
|
+
expect(out).toContain("**destroy**: DBInstanceIdentifier is create-only");
|
|
531
|
+
expect(out).toContain("```");
|
|
532
|
+
expect(out).toContain("! attributes.DBInstanceIdentifier: app-db → app-db-2");
|
|
533
|
+
expect(out).toContain("attributes.AllocatedStorage: 20 → 40");
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test("disruption count rides the header, same as the human render", () => {
|
|
537
|
+
const cs = set([entry({ disruption: "in-place" }), entry({ name: "web", disruption: "destroy" })]);
|
|
538
|
+
const out = renderChangeSetMarkdown(cs);
|
|
539
|
+
expect(out).toContain("**Disruption:** 1 in-place, 1 destroy");
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
test("unobserved renders both the prominent notice and its own section, same wording as the human render", () => {
|
|
543
|
+
const cs = set([
|
|
544
|
+
entry({ name: "crd-widget", action: "unobserved", unobservedReason: "no-binding", unobservedDetail: "no kubectl context for prod" }),
|
|
545
|
+
]);
|
|
546
|
+
const out = renderChangeSetMarkdown(cs);
|
|
547
|
+
expect(out).toContain(
|
|
548
|
+
"> **1 declared entity(ies) could not be observed — no create/update/delete is proposed for them. This plan is incomplete, not clean.**",
|
|
549
|
+
);
|
|
550
|
+
expect(out).toContain("### UNOBSERVED (declared; chant could not read live state — no action proposed)");
|
|
551
|
+
expect(out).toContain("- `crd-widget`");
|
|
552
|
+
expect(out).toContain("no binding for this environment");
|
|
553
|
+
expect(out).toContain("no kubectl context for prod");
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test("a clean plan (no holes) carries no unobserved notice at all", () => {
|
|
557
|
+
const out = renderChangeSetMarkdown(set([entry({ action: "noop" })]));
|
|
558
|
+
expect(out).not.toContain("could not be observed");
|
|
559
|
+
expect(out).not.toContain("UNOBSERVED");
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
test("effect entries render as their own line, no deltas", () => {
|
|
563
|
+
const cs = set([
|
|
564
|
+
entry({
|
|
565
|
+
name: "receipt-x",
|
|
566
|
+
action: "effect",
|
|
567
|
+
effect: "seed-admin",
|
|
568
|
+
effectReason: "receipt-absent",
|
|
569
|
+
effectDetail: "no receipt recorded yet",
|
|
570
|
+
deltas: undefined,
|
|
571
|
+
}),
|
|
572
|
+
]);
|
|
573
|
+
const out = renderChangeSetMarkdown(cs);
|
|
574
|
+
expect(out).toContain("- effect will fire: `seed-admin` — receipt `receipt-x`");
|
|
575
|
+
expect(out).toContain("no receipt recorded yet");
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
test("a group past the fold threshold collapses into <details>, a small one does not", () => {
|
|
579
|
+
const many = Array.from({ length: 25 }, (_, i) =>
|
|
580
|
+
entry({ name: `bucket-${i}`, action: "create", evidence: { declared: true, inSnapshot: false, live: false, observed: true }, deltas: undefined }),
|
|
581
|
+
);
|
|
582
|
+
const out = renderChangeSetMarkdown(set(many));
|
|
583
|
+
expect(out).toContain("<details><summary>25 entries — click to expand</summary>");
|
|
584
|
+
expect(out).toContain("</details>");
|
|
585
|
+
expect(out).toContain("- `bucket-0`");
|
|
586
|
+
expect(out).toContain("- `bucket-24`");
|
|
587
|
+
|
|
588
|
+
const few = renderChangeSetMarkdown(set(many.slice(0, 3)));
|
|
589
|
+
expect(few).not.toContain("<details>");
|
|
590
|
+
});
|
|
591
|
+
});
|