@intentius/chant 0.49.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/audit/catalog.d.ts +13 -3
- package/dist/audit/catalog.d.ts.map +1 -1
- package/dist/audit/core.d.ts +9 -0
- package/dist/audit/core.d.ts.map +1 -1
- package/dist/audit/discover.d.ts +6 -0
- package/dist/audit/discover.d.ts.map +1 -1
- package/dist/audit/fetch.d.ts.map +1 -1
- package/dist/audit/report-html.d.ts.map +1 -1
- package/dist/audit/report-model.d.ts +6 -0
- package/dist/audit/report-model.d.ts.map +1 -1
- package/dist/audit/report.d.ts.map +1 -1
- package/dist/audit/rules-doc.d.ts.map +1 -1
- package/dist/audit/secrets.d.ts +95 -0
- package/dist/audit/secrets.d.ts.map +1 -0
- package/dist/audit/wrangler.d.ts +33 -0
- package/dist/audit/wrangler.d.ts.map +1 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/audit.d.ts +7 -0
- package/dist/cli/commands/audit.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts +23 -0
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/components.d.ts +31 -0
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts +11 -0
- 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/operator.d.ts +32 -0
- package/dist/cli/handlers/operator.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/scenario.d.ts +39 -0
- package/dist/cli/handlers/scenario.d.ts.map +1 -0
- 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/mcp/server.d.ts +35 -2
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/mcp/types.d.ts +29 -1
- package/dist/cli/mcp/types.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +47 -3
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
- package/dist/components/capability.d.ts +17 -2
- package/dist/components/capability.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +7 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/component.d.ts +15 -0
- package/dist/components/component.d.ts.map +1 -1
- package/dist/components/driver.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/components/verbs/index.d.ts +6 -1
- package/dist/components/verbs/index.d.ts.map +1 -1
- package/dist/components/verbs/run-agent.d.ts +499 -0
- package/dist/components/verbs/run-agent.d.ts.map +1 -0
- package/dist/components/verbs/sign.d.ts +30 -0
- package/dist/components/verbs/sign.d.ts.map +1 -1
- package/dist/composite.d.ts +6 -1
- package/dist/composite.d.ts.map +1 -1
- package/dist/discovery/collect.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +15 -1
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/discovery/fold-rank.d.ts +66 -0
- package/dist/discovery/fold-rank.d.ts.map +1 -0
- package/dist/discovery/index.d.ts +15 -0
- package/dist/discovery/index.d.ts.map +1 -1
- package/dist/discovery/param-deps.d.ts +17 -0
- package/dist/discovery/param-deps.d.ts.map +1 -0
- package/dist/fold/fold.d.ts +55 -2
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +21 -14
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/lexicon-schema.d.ts +2 -0
- package/dist/lexicon-schema.d.ts.map +1 -1
- package/dist/lexicon.d.ts +134 -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/converge-ledger.d.ts +90 -0
- package/dist/lifecycle/converge-ledger.d.ts.map +1 -0
- package/dist/lifecycle/deep-diff.d.ts +18 -0
- package/dist/lifecycle/deep-diff.d.ts.map +1 -1
- package/dist/lifecycle/deep-observe.d.ts +9 -1
- package/dist/lifecycle/deep-observe.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/gate-ledger.d.ts +33 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -0
- package/dist/lifecycle/git.d.ts +145 -21
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/index.d.ts +6 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/lease.d.ts +113 -0
- package/dist/lifecycle/lease.d.ts.map +1 -0
- package/dist/lifecycle/replay.d.ts +2 -0
- package/dist/lifecycle/replay.d.ts.map +1 -1
- package/dist/lifecycle/scenario-eval.d.ts +42 -0
- package/dist/lifecycle/scenario-eval.d.ts.map +1 -0
- package/dist/lifecycle/scenario.d.ts +163 -0
- package/dist/lifecycle/scenario.d.ts.map +1 -0
- package/dist/lifecycle/symptoms.d.ts +63 -0
- package/dist/lifecycle/symptoms.d.ts.map +1 -0
- package/dist/lint/output-docs.d.ts +94 -0
- package/dist/lint/output-docs.d.ts.map +1 -0
- package/dist/lint/post-synth.d.ts +29 -0
- package/dist/lint/post-synth.d.ts.map +1 -1
- package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts +11 -0
- package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts.map +1 -0
- package/dist/lsp/lexicon-providers.d.ts +7 -0
- package/dist/lsp/lexicon-providers.d.ts.map +1 -1
- package/dist/op/activity-contract.d.ts +139 -0
- package/dist/op/activity-contract.d.ts.map +1 -0
- package/dist/op/builders.d.ts +42 -2
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/converge-rule.d.ts +161 -0
- package/dist/op/converge-rule.d.ts.map +1 -0
- package/dist/op/generate-pipeline.d.ts +39 -0
- package/dist/op/generate-pipeline.d.ts.map +1 -0
- package/dist/op/index.d.ts +14 -0
- package/dist/op/index.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/op/op-verb-class.d.ts +42 -0
- package/dist/op/op-verb-class.d.ts.map +1 -0
- package/dist/op/operator.d.ts +128 -0
- package/dist/op/operator.d.ts.map +1 -0
- package/dist/op/step-output-ref.d.ts +187 -0
- package/dist/op/step-output-ref.d.ts.map +1 -0
- package/dist/op/types.d.ts +18 -1
- package/dist/op/types.d.ts.map +1 -1
- package/dist/provenance.d.ts +73 -3
- package/dist/provenance.d.ts.map +1 -1
- package/dist/runtime-adapter.d.ts +7 -1
- package/dist/runtime-adapter.d.ts.map +1 -1
- package/dist/serializer.d.ts +18 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/testing.d.ts +23 -2
- package/dist/testing.d.ts.map +1 -1
- package/dist/toml.d.ts +40 -5
- package/dist/toml.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/audit/catalog.test.ts +1 -1
- package/src/audit/catalog.ts +75 -3
- package/src/audit/core.ts +9 -0
- package/src/audit/discover.ts +29 -2
- package/src/audit/fetch.test.ts +216 -3
- package/src/audit/fetch.ts +270 -59
- package/src/audit/report-html.ts +5 -2
- package/src/audit/report-model.ts +9 -0
- package/src/audit/report.test.ts +22 -0
- package/src/audit/report.ts +3 -2
- package/src/audit/rules-doc.ts +2 -0
- package/src/audit/secrets.test.ts +303 -0
- package/src/audit/secrets.ts +406 -0
- package/src/audit/wrangler.test.ts +230 -0
- package/src/audit/wrangler.ts +290 -0
- package/src/build.ts +8 -3
- package/src/cli/command-group.ts +1 -1
- package/src/cli/commands/__fixtures__/schemas/sarif-2.1.0.schema.json +2882 -0
- package/src/cli/commands/audit.test.ts +215 -1
- package/src/cli/commands/audit.ts +86 -17
- package/src/cli/commands/build.test.ts +167 -2
- package/src/cli/commands/build.ts +114 -23
- package/src/cli/handlers/build.ts +2 -0
- package/src/cli/handlers/components.test.ts +199 -1
- package/src/cli/handlers/components.ts +160 -3
- package/src/cli/handlers/graph.test.ts +20 -0
- package/src/cli/handlers/graph.ts +10 -1
- package/src/cli/handlers/lifecycle.test.ts +90 -0
- package/src/cli/handlers/lifecycle.ts +30 -5
- package/src/cli/handlers/op-progress.test.ts +202 -0
- package/src/cli/handlers/op-progress.ts +192 -0
- package/src/cli/handlers/operator.test.ts +255 -0
- package/src/cli/handlers/operator.ts +240 -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/scenario.test.ts +456 -0
- package/src/cli/handlers/scenario.ts +330 -0
- package/src/cli/handlers/search-drift.test.ts +263 -0
- package/src/cli/handlers/search.ts +150 -1
- package/src/cli/main.test.ts +23 -0
- package/src/cli/main.ts +81 -1
- package/src/cli/mcp/op-tools.ts +17 -6
- package/src/cli/mcp/resource-handlers.ts +13 -5
- package/src/cli/mcp/server.test.ts +265 -2
- package/src/cli/mcp/server.ts +84 -7
- package/src/cli/mcp/types.ts +27 -1
- package/src/cli/registry.ts +47 -3
- package/src/codegen/docs-rule-scanning.test.ts +42 -0
- package/src/codegen/docs-rule-scanning.ts +25 -2
- package/src/components/README.md +7 -0
- package/src/components/capability.ts +17 -2
- package/src/components/cli-support.test.ts +17 -0
- package/src/components/cli-support.ts +13 -1
- package/src/components/component-schema.test.ts +32 -0
- package/src/components/component.schema.json +6 -0
- package/src/components/component.test.ts +21 -0
- package/src/components/component.ts +15 -0
- package/src/components/driver.ts +12 -4
- package/src/components/run-progress.ts +9 -5
- package/src/components/verbs/index.ts +6 -1
- package/src/components/verbs/run-agent.test.ts +683 -0
- package/src/components/verbs/run-agent.ts +786 -0
- package/src/components/verbs/sign.test.ts +19 -0
- package/src/components/verbs/sign.ts +34 -2
- package/src/composite.ts +31 -2
- package/src/discovery/collect.ts +11 -2
- package/src/discovery/fold-import.test.ts +54 -0
- package/src/discovery/fold-import.ts +178 -38
- package/src/discovery/fold-rank.test.ts +197 -0
- package/src/discovery/fold-rank.ts +346 -0
- package/src/discovery/index.ts +16 -1
- package/src/discovery/param-deps.test.ts +118 -0
- package/src/discovery/param-deps.ts +170 -0
- package/src/fold/fold.test.ts +6 -2
- package/src/fold/fold.ts +184 -3
- package/src/fold/subset.test.ts +82 -19
- package/src/fold/subset.ts +79 -41
- package/src/lexicon-schema.ts +3 -0
- package/src/lexicon.ts +154 -2
- 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/converge-ledger.test.ts +199 -0
- package/src/lifecycle/converge-ledger.ts +179 -0
- package/src/lifecycle/deep-diff.test.ts +79 -1
- package/src/lifecycle/deep-diff.ts +23 -0
- package/src/lifecycle/deep-observe.ts +13 -2
- package/src/lifecycle/disruption.test.ts +186 -0
- package/src/lifecycle/disruption.ts +224 -0
- package/src/lifecycle/gate-ledger.test.ts +103 -0
- package/src/lifecycle/gate-ledger.ts +140 -0
- package/src/lifecycle/git.test.ts +430 -0
- package/src/lifecycle/git.ts +446 -84
- package/src/lifecycle/index.ts +6 -0
- package/src/lifecycle/lease.test.ts +343 -0
- package/src/lifecycle/lease.ts +270 -0
- package/src/lifecycle/replay.test.ts +25 -0
- package/src/lifecycle/replay.ts +11 -3
- package/src/lifecycle/scenario-eval.test.ts +199 -0
- package/src/lifecycle/scenario-eval.ts +158 -0
- package/src/lifecycle/scenario.test.ts +195 -0
- package/src/lifecycle/scenario.ts +321 -0
- package/src/lifecycle/symptoms.test.ts +116 -0
- package/src/lifecycle/symptoms.ts +126 -0
- package/src/lint/output-docs.test.ts +220 -0
- package/src/lint/output-docs.ts +204 -0
- package/src/lint/post-synth.test.ts +97 -0
- package/src/lint/post-synth.ts +45 -0
- package/src/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.ts +26 -0
- package/src/lint/rules/comp/comp.test.ts +49 -1
- package/src/lint/rules/evl001-non-literal-expression.test.ts +8 -3
- package/src/lint/rules/evl001-non-literal-expression.ts +6 -6
- package/src/lsp/lexicon-providers.test.ts +44 -0
- package/src/lsp/lexicon-providers.ts +11 -1
- package/src/op/activity-contract.test.ts +180 -0
- package/src/op/activity-contract.ts +278 -0
- package/src/op/builders-exports.test.ts +17 -1
- package/src/op/builders.ts +59 -5
- package/src/op/converge-rule.test.ts +179 -0
- package/src/op/converge-rule.ts +311 -0
- package/src/op/generate-pipeline.test.ts +53 -0
- package/src/op/generate-pipeline.ts +99 -0
- package/src/op/index.ts +30 -0
- package/src/op/local-executor.test.ts +92 -0
- package/src/op/local-executor.ts +52 -10
- package/src/op/local-output.ts +1 -1
- package/src/op/op-verb-class.test.ts +126 -0
- package/src/op/op-verb-class.ts +115 -0
- package/src/op/operator.test.ts +346 -0
- package/src/op/operator.ts +213 -0
- package/src/op/step-output-ref.test.ts +334 -0
- package/src/op/step-output-ref.ts +453 -0
- package/src/op/types.ts +18 -1
- package/src/provenance.test.ts +151 -4
- package/src/provenance.ts +118 -4
- package/src/runtime-adapter.ts +31 -10
- package/src/serializer.ts +18 -0
- package/src/testing.test.ts +89 -2
- package/src/testing.ts +63 -3
- package/src/toml.test.ts +157 -384
- package/src/toml.ts +371 -5
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Converge ledger (#1484) — the append-only record of what a `ConvergeOp`
|
|
3
|
+
* tick did, one line per tick, at `<env>/converge.jsonl` on the
|
|
4
|
+
* `chant/lifecycle` orphan branch. Same storage shape as the release ledger
|
|
5
|
+
* (./release-ledger.ts): immutable, append-only JSONL, reusing
|
|
6
|
+
* `writeBlobToPath`/`readBlobFromPath` (./git.ts) directly rather than a new
|
|
7
|
+
* git-plumbing path — the exact reuse `appendReleaseRecordLine` already
|
|
8
|
+
* demonstrates for a second filename under the same env directory.
|
|
9
|
+
*
|
|
10
|
+
* This is also where flap-damping state lives (issue: "consecutive-fire
|
|
11
|
+
* counts go in the ledger ... not process memory, so damping survives worker
|
|
12
|
+
* restarts"): each record's `firedRuleIds` names every rule whose predicate
|
|
13
|
+
* matched this tick, independent of what action was actually taken (run,
|
|
14
|
+
* report, or skipped for budget/flap reasons) — {@link consecutiveRuleFires}
|
|
15
|
+
* walks the ledger backward from the newest record and counts how many in a
|
|
16
|
+
* row include a given rule id, stopping at the first tick where it didn't
|
|
17
|
+
* fire (the symptom cleared).
|
|
18
|
+
*/
|
|
19
|
+
import { sortedJsonReplacer } from "../utils";
|
|
20
|
+
import { readBlobFromPath, readPathSha, readBlobBySha, writeBlobToPath, RefCASConflictError } from "./git";
|
|
21
|
+
|
|
22
|
+
const FILENAME = "converge.jsonl";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Read-modify-append retry budget for {@link appendConvergeRecord} (#1485).
|
|
26
|
+
* `writeBlobToPath`'s ref write is now CAS-guarded (./git.ts) — a conflict
|
|
27
|
+
* (another writer, e.g. a second operator ticking a different env on the
|
|
28
|
+
* same orphan branch, updated the branch tip between our read and our
|
|
29
|
+
* write) throws `RefCASConflictError` instead of silently clobbering. This
|
|
30
|
+
* many attempts, re-reading the ledger fresh each time, absorbs that race
|
|
31
|
+
* rather than surfacing it as a tick failure — the whole point of the CAS
|
|
32
|
+
* guard is correctness, not a new way for a tick to fail.
|
|
33
|
+
*/
|
|
34
|
+
const APPEND_RETRY_ATTEMPTS = 5;
|
|
35
|
+
|
|
36
|
+
/** One rule's outcome within a tick. */
|
|
37
|
+
export interface ConvergeRuleOutcome {
|
|
38
|
+
ruleId: string;
|
|
39
|
+
/**
|
|
40
|
+
* What actually happened for this fired rule this tick. `"gated"` (#1485)
|
|
41
|
+
* is gate-as-fact: the dispatched op's own run hit a gate it can't clear
|
|
42
|
+
* on the local executor, and the tick records that as a terminal, durable
|
|
43
|
+
* fact — `gateName` names the gate — rather than treating it as a
|
|
44
|
+
* dispatch failure. Resolution is out-of-band (`chant approve <op>
|
|
45
|
+
* <gate>`, ./gate-ledger.ts, or a merged PR); the tick itself never
|
|
46
|
+
* blocks waiting for it.
|
|
47
|
+
*/
|
|
48
|
+
action: "ran" | "reported" | "skipped-budget" | "skipped-flap" | "gated";
|
|
49
|
+
/** The dispatched Op name, for `action: "ran"` or `"gated"`. */
|
|
50
|
+
op?: string;
|
|
51
|
+
/** The gate's signal name, for `action: "gated"`. */
|
|
52
|
+
gateName?: string;
|
|
53
|
+
/** The report reason, for `action: "reported"` (including a flap-damped rule's forced report) — and the human-readable explanation for `action: "gated"`. */
|
|
54
|
+
reason?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One immutable converge-tick record. */
|
|
58
|
+
export interface ConvergeTickRecord {
|
|
59
|
+
/** Schema version, so an incompatible future shape is detected before being misread. */
|
|
60
|
+
version: 1;
|
|
61
|
+
/** The ConvergeOp's name (`OpConfig.name`). */
|
|
62
|
+
op: string;
|
|
63
|
+
env: string;
|
|
64
|
+
/** ISO-8601 timestamp, caller-supplied (same convention as `ReleaseRecord.timestamp` — library code never calls `Date.now()` internally). */
|
|
65
|
+
timestamp: string;
|
|
66
|
+
/** Every rule id whose predicate matched this tick, regardless of what action followed — the flap-damping input. */
|
|
67
|
+
firedRuleIds: string[];
|
|
68
|
+
/** Per-rule outcome, for every fired rule. */
|
|
69
|
+
outcomes: ConvergeRuleOutcome[];
|
|
70
|
+
/** Aggregate counts backing the tick's one log line. */
|
|
71
|
+
summary: {
|
|
72
|
+
drifted: number;
|
|
73
|
+
remediated: number;
|
|
74
|
+
reported: number;
|
|
75
|
+
skippedBudget: number;
|
|
76
|
+
skippedFlap: number;
|
|
77
|
+
unobserved: number;
|
|
78
|
+
adopted: number;
|
|
79
|
+
/** Rules whose dispatch hit a gate this tick (#1485) — a terminal, non-blocking fact; see `ConvergeRuleOutcome.action`'s doc. @default 0, so an older record without this field still reads as zero, not undefined. */
|
|
80
|
+
gated?: number;
|
|
81
|
+
};
|
|
82
|
+
/** The one human-readable log line this tick produced (issue: "one log line and one ledger record per tick"). */
|
|
83
|
+
log: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export type ConvergeTickRecordInput = Omit<ConvergeTickRecord, "version">;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Append one immutable tick record. Does not push to the remote — call
|
|
90
|
+
* `pushLifecycle` (./git.ts) afterward, same two-step shape every other
|
|
91
|
+
* ledger write here uses.
|
|
92
|
+
*
|
|
93
|
+
* Retries the whole read-modify-write cycle (#1485) on `RefCASConflictError`
|
|
94
|
+
* — `writeBlobToPath`'s ref write is CAS-guarded, so a concurrent writer to
|
|
95
|
+
* a *different* env's file on the same orphan branch (two operators ticking
|
|
96
|
+
* two environments at once is the ordinary case, not an edge case) can lose
|
|
97
|
+
* the race and needs to re-read the branch tip and retry, not fail the
|
|
98
|
+
* tick. Each retry re-reads `existing` fresh, so it always appends onto
|
|
99
|
+
* whatever the other writer just committed rather than reintroducing a stale
|
|
100
|
+
* read. Exhausting the budget re-throws the conflict — a real, sustained
|
|
101
|
+
* pile-up of writers is a signal worth surfacing, not silently swallowing.
|
|
102
|
+
*
|
|
103
|
+
* The baseline read must be `readPathSha` + `readBlobBySha` rather than
|
|
104
|
+
* `readBlobFromPath`, so the exact sha `existing` came from can be passed as
|
|
105
|
+
* `expectPriorPathSha`. See `writeBlobToPath` (./git.ts) for the race that
|
|
106
|
+
* closes.
|
|
107
|
+
*/
|
|
108
|
+
export async function appendConvergeRecord(
|
|
109
|
+
input: ConvergeTickRecordInput,
|
|
110
|
+
opts?: { cwd?: string },
|
|
111
|
+
): Promise<{ commit: string; record: ConvergeTickRecord }> {
|
|
112
|
+
const record: ConvergeTickRecord = { version: 1, ...input };
|
|
113
|
+
const json = JSON.stringify(record, sortedJsonReplacer);
|
|
114
|
+
|
|
115
|
+
let lastErr: unknown;
|
|
116
|
+
for (let attempt = 1; attempt <= APPEND_RETRY_ATTEMPTS; attempt++) {
|
|
117
|
+
try {
|
|
118
|
+
const priorSha = await readPathSha(record.env, FILENAME, opts);
|
|
119
|
+
const existing = priorSha ? await readBlobBySha(priorSha, opts) : null;
|
|
120
|
+
const content = existing ? `${existing.replace(/\n$/, "")}\n${json}` : json;
|
|
121
|
+
const commit = await writeBlobToPath(record.env, FILENAME, content, "Converge tick record", {
|
|
122
|
+
...opts,
|
|
123
|
+
expectPriorPathSha: priorSha,
|
|
124
|
+
});
|
|
125
|
+
return { commit, record };
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (!(err instanceof RefCASConflictError)) throw err;
|
|
128
|
+
lastErr = err;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
throw lastErr;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Read every tick record for `environment`, oldest first. Malformed lines are skipped, not thrown on — a corrupted or hand-edited ledger degrades gracefully, the same stance `readReleaseLedger` takes. */
|
|
135
|
+
export async function readConvergeLedger(
|
|
136
|
+
environment: string,
|
|
137
|
+
opts?: { cwd?: string },
|
|
138
|
+
): Promise<{ records: ConvergeTickRecord[]; malformed: number }> {
|
|
139
|
+
const content = await readBlobFromPath(environment, FILENAME, opts);
|
|
140
|
+
if (!content) return { records: [], malformed: 0 };
|
|
141
|
+
|
|
142
|
+
const lines = content.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
143
|
+
const records: ConvergeTickRecord[] = [];
|
|
144
|
+
let malformed = 0;
|
|
145
|
+
for (const line of lines) {
|
|
146
|
+
try {
|
|
147
|
+
const parsed = JSON.parse(line) as Partial<ConvergeTickRecord>;
|
|
148
|
+
if (
|
|
149
|
+
parsed.version !== 1 ||
|
|
150
|
+
typeof parsed.op !== "string" ||
|
|
151
|
+
typeof parsed.env !== "string" ||
|
|
152
|
+
typeof parsed.timestamp !== "string" ||
|
|
153
|
+
!Array.isArray(parsed.firedRuleIds)
|
|
154
|
+
) {
|
|
155
|
+
malformed++;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
records.push(parsed as ConvergeTickRecord);
|
|
159
|
+
} catch {
|
|
160
|
+
malformed++;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return { records, malformed };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* How many consecutive most-recent ticks (newest first) fired `ruleId`,
|
|
168
|
+
* stopping at the first tick where it did not — the count a rule's
|
|
169
|
+
* `flapThreshold` is compared against. `0` when the newest tick didn't fire
|
|
170
|
+
* it (including an empty ledger).
|
|
171
|
+
*/
|
|
172
|
+
export function consecutiveRuleFires(records: ConvergeTickRecord[], ruleId: string): number {
|
|
173
|
+
let count = 0;
|
|
174
|
+
for (let i = records.length - 1; i >= 0; i--) {
|
|
175
|
+
if (!records[i].firedRuleIds.includes(ruleId)) break;
|
|
176
|
+
count++;
|
|
177
|
+
}
|
|
178
|
+
return count;
|
|
179
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
|
-
import { countPropertyDrift, diffDeep } from "./deep-diff";
|
|
2
|
+
import { countPropertyDrift, diffDeep, type DeclaredDeepEntity } from "./deep-diff";
|
|
3
3
|
import { UNRESOLVED, type NormalizedDeepObservation } from "../deep-observation";
|
|
4
4
|
import type { BaselineLexicon } from "./observation-baseline";
|
|
5
5
|
|
|
@@ -202,3 +202,81 @@ describe("diffDeep — owning field manager (#1189)", () => {
|
|
|
202
202
|
expect(change).not.toHaveProperty("owner");
|
|
203
203
|
});
|
|
204
204
|
});
|
|
205
|
+
|
|
206
|
+
// #1443 — the declared-side counterpart of `owner`. Both sides of one
|
|
207
|
+
// comparison get precise attribution, which is what makes the report actionable.
|
|
208
|
+
describe("diffDeep — declared-side origin (#1443)", () => {
|
|
209
|
+
// Annotated, not `as const`: the annotation is what keeps `kind` narrowed to
|
|
210
|
+
// its literal, and it types the fixture as the production shape rather than
|
|
211
|
+
// whatever the literal happens to infer to.
|
|
212
|
+
const declared: Record<string, DeclaredDeepEntity> = {
|
|
213
|
+
web: {
|
|
214
|
+
type: "K8s::Apps::Deployment",
|
|
215
|
+
properties: { spec: { replicas: 2, template: { spec: { containers: [{ name: "app", image: "a:1" }] } } } },
|
|
216
|
+
pathOrigins: {
|
|
217
|
+
"": { kind: "composite", composite: "WebService", instance: "web" },
|
|
218
|
+
"spec.replicas": { kind: "build-param", params: ["tier"] },
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
test("reports the origin alongside the live owner", () => {
|
|
224
|
+
const result = diffDeep({
|
|
225
|
+
declared,
|
|
226
|
+
live: live({
|
|
227
|
+
web: {
|
|
228
|
+
type: "K8s::Apps::Deployment",
|
|
229
|
+
properties: { spec: { replicas: 5, template: { spec: { containers: [{ name: "app", image: "a:1" }] } } } },
|
|
230
|
+
fieldOwners: { "spec.replicas": "hpa-controller" },
|
|
231
|
+
},
|
|
232
|
+
}),
|
|
233
|
+
});
|
|
234
|
+
expect(result.drifted[0].changes[0]).toMatchObject({
|
|
235
|
+
path: "spec.replicas",
|
|
236
|
+
owner: "hpa-controller",
|
|
237
|
+
origin: { kind: "build-param", params: ["tier"] },
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("a path with no origin of its own inherits the nearest recorded ancestor", () => {
|
|
242
|
+
const result = diffDeep({
|
|
243
|
+
declared,
|
|
244
|
+
live: live({
|
|
245
|
+
web: {
|
|
246
|
+
type: "K8s::Apps::Deployment",
|
|
247
|
+
properties: { spec: { replicas: 2, template: { spec: { containers: [{ name: "app", image: "a:2" }] } } } },
|
|
248
|
+
},
|
|
249
|
+
}),
|
|
250
|
+
});
|
|
251
|
+
const change = result.drifted[0].changes.find((c) => c.path.includes("image"))!;
|
|
252
|
+
expect(change.origin).toEqual({ kind: "composite", composite: "WebService", instance: "web" });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("is absent on an undeclared path — nothing in source produced it", () => {
|
|
256
|
+
const result = diffDeep({
|
|
257
|
+
declared,
|
|
258
|
+
live: live({
|
|
259
|
+
web: {
|
|
260
|
+
type: "K8s::Apps::Deployment",
|
|
261
|
+
properties: {
|
|
262
|
+
spec: { replicas: 2, template: { spec: { containers: [{ name: "app", image: "a:1" }] } } },
|
|
263
|
+
status: { observed: 1 },
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
}),
|
|
267
|
+
});
|
|
268
|
+
const change = result.drifted[0].changes.find((c) => c.path === "status.observed")!;
|
|
269
|
+
expect(change.kind).toBe("undeclared");
|
|
270
|
+
expect(change).not.toHaveProperty("origin");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("is absent when the build recorded no path origins", () => {
|
|
274
|
+
// The run path, and a sandboxed child: absent means "could not record",
|
|
275
|
+
// not "nothing governs this field", so the key must not appear at all.
|
|
276
|
+
const result = diffDeep({
|
|
277
|
+
declared: { web: { type: "K8s::Apps::Deployment", properties: { spec: { replicas: 2 } } } },
|
|
278
|
+
live: live({ web: { type: "K8s::Apps::Deployment", properties: { spec: { replicas: 5 } } } }),
|
|
279
|
+
});
|
|
280
|
+
expect(result.drifted[0].changes[0]).not.toHaveProperty("origin");
|
|
281
|
+
});
|
|
282
|
+
});
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
type DeepNormalizationHooks,
|
|
30
30
|
type NormalizedDeepObservation,
|
|
31
31
|
} from "../deep-observation";
|
|
32
|
+
import { originOfPath, type PathOrigin } from "../provenance";
|
|
32
33
|
import type { UnobservedResource } from "./live-diff";
|
|
33
34
|
import { acceptedDeviation, type BaselineLexicon } from "./observation-baseline";
|
|
34
35
|
|
|
@@ -70,6 +71,18 @@ export interface PropertyDrift {
|
|
|
70
71
|
* with no per-field ownership, which is every substrate but k8s.
|
|
71
72
|
*/
|
|
72
73
|
owner?: string;
|
|
74
|
+
/**
|
|
75
|
+
* What produced this path on the DECLARED side (#1443) — the counterpart of
|
|
76
|
+
* {@link owner}, and the reason the two are reported together: "owned live by
|
|
77
|
+
* `hpa-controller`, governed in source by the `tier` parameter" says where
|
|
78
|
+
* each half of a disagreement has to be fixed, which neither half says alone.
|
|
79
|
+
*
|
|
80
|
+
* Resolved by longest prefix from the entity's recorded path origins, so a
|
|
81
|
+
* field inside a keyed list element inherits the origin recorded for the
|
|
82
|
+
* list. Absent when the build recorded none — the run path, and a sandboxed
|
|
83
|
+
* child, have no expression to attribute (see `EntityProvenance.paths`).
|
|
84
|
+
*/
|
|
85
|
+
origin?: PathOrigin;
|
|
73
86
|
}
|
|
74
87
|
|
|
75
88
|
/** Property-level drift for one declared entity. */
|
|
@@ -100,6 +113,11 @@ export interface DeepDiffResult {
|
|
|
100
113
|
export interface DeclaredDeepEntity {
|
|
101
114
|
type: string;
|
|
102
115
|
properties: Record<string, unknown>;
|
|
116
|
+
/**
|
|
117
|
+
* The entity's recorded path origins (#1443), as `EntityProvenance.paths`.
|
|
118
|
+
* Omit for a build that recorded none.
|
|
119
|
+
*/
|
|
120
|
+
pathOrigins?: Record<string, PathOrigin>;
|
|
103
121
|
}
|
|
104
122
|
|
|
105
123
|
export interface DiffDeepInput {
|
|
@@ -188,12 +206,17 @@ export function diffDeep(input: DiffDeepInput): DeepDiffResult {
|
|
|
188
206
|
// meaningful for a path that exists live — an `absent` drift has no live
|
|
189
207
|
// field for anyone to own.
|
|
190
208
|
const owner = hasLive ? liveEntity.fieldOwners?.[path] : undefined;
|
|
209
|
+
// The declared-side counterpart (#1443). Only meaningful for a path
|
|
210
|
+
// source actually declares — an `undeclared` drift has no authored
|
|
211
|
+
// expression for anything to have produced.
|
|
212
|
+
const origin = hasDeclared ? originOfPath(declaredEntity.pathOrigins, path) : undefined;
|
|
191
213
|
const drift: PropertyDrift = {
|
|
192
214
|
path,
|
|
193
215
|
kind,
|
|
194
216
|
...(hasDeclared ? { declared: declaredValue } : {}),
|
|
195
217
|
...(hasLive ? { live: liveValue } : {}),
|
|
196
218
|
...(owner ? { owner } : {}),
|
|
219
|
+
...(origin ? { origin } : {}),
|
|
197
220
|
};
|
|
198
221
|
|
|
199
222
|
const acceptedEntry = acceptedDeviation(baseline, name, path);
|
|
@@ -27,11 +27,21 @@ import {
|
|
|
27
27
|
type NormalizedDeepObservation,
|
|
28
28
|
} from "../deep-observation";
|
|
29
29
|
import { unobservedAll, type UnobservedEntity } from "../observation";
|
|
30
|
+
import type { PathOrigin } from "../provenance";
|
|
30
31
|
import { diffDeep, type DeclaredDeepEntity, type DeepDiffResult } from "./deep-diff";
|
|
31
32
|
import type { BaselineLexicon } from "./observation-baseline";
|
|
32
33
|
|
|
33
|
-
/**
|
|
34
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Declared entities for one lexicon, in the shape the observe paths pass around.
|
|
36
|
+
*
|
|
37
|
+
* `pathOrigins` (#1443) rides alongside `props` rather than being read off the
|
|
38
|
+
* entity, because callers rebuild this map as plain objects and the symbol-keyed
|
|
39
|
+
* provenance channel does not survive that copy.
|
|
40
|
+
*/
|
|
41
|
+
export type DeclaredEntities = Map<
|
|
42
|
+
string,
|
|
43
|
+
{ entityType: string; props: Record<string, unknown>; pathOrigins?: Record<string, PathOrigin> }
|
|
44
|
+
>;
|
|
35
45
|
|
|
36
46
|
export interface DeepObserveOptions {
|
|
37
47
|
environment: string;
|
|
@@ -138,6 +148,7 @@ export function diffDeepObservation(
|
|
|
138
148
|
hooks,
|
|
139
149
|
counterpartPaths: livePaths,
|
|
140
150
|
}),
|
|
151
|
+
...(entity.pathOrigins ? { pathOrigins: entity.pathOrigins } : {}),
|
|
141
152
|
};
|
|
142
153
|
if (liveEntity) {
|
|
143
154
|
normalizedLive[name] = {
|
|
@@ -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
|
+
});
|