@intentius/chant 0.105.0 → 0.106.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/fan-out.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/pr.d.ts +16 -2
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +7 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/changed-files.d.ts +8 -2
- package/dist/components/changed-files.d.ts.map +1 -1
- package/dist/components/fan-out-record.d.ts +24 -0
- package/dist/components/fan-out-record.d.ts.map +1 -1
- package/dist/components/pr-pipeline.d.ts +60 -4
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-run.d.ts +92 -3
- package/dist/components/pr-run.d.ts.map +1 -1
- package/dist/gated-waves.d.ts +4 -1
- package/dist/gated-waves.d.ts.map +1 -1
- package/dist/lexicon.d.ts +13 -2
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/op/activities/factory.d.ts.map +1 -1
- package/dist/op/gate-name.d.ts +7 -0
- package/dist/op/gate-name.d.ts.map +1 -1
- package/dist/op/gate-summary.d.ts +2 -0
- package/dist/op/gate-summary.d.ts.map +1 -1
- package/dist/op/gate.d.ts +10 -2
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +2 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-output.d.ts +2 -2
- package/dist/op/local-output.d.ts.map +1 -1
- package/dist/op/runtime.d.ts +19 -2
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/pr-loop.d.ts +29 -4
- package/dist/pr-loop.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade-cli.d.ts.map +1 -1
- package/dist/workspace/member-pipeline.d.ts +14 -1
- package/dist/workspace/member-pipeline.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__fixtures__/change-set/warden.change-set.golden.json +2 -2
- package/src/__fixtures__/change-set/warden.plan.json +47 -40
- package/src/__fixtures__/plan-summary/normalization-vectors.json +275 -2
- package/src/change-set.test.ts +7 -8
- package/src/cli/commands/lint.ts +34 -1
- package/src/cli/handlers/build-lexicon-output.test.ts +94 -0
- package/src/cli/handlers/build.ts +65 -0
- package/src/cli/handlers/fan-out.test.ts +12 -1
- package/src/cli/handlers/fan-out.ts +7 -3
- package/src/cli/handlers/operator.ts +1 -2
- package/src/cli/handlers/pr.ts +220 -35
- package/src/cli/handlers/promote.test.ts +15 -1
- package/src/cli/handlers/promote.ts +5 -3
- package/src/cli/handlers/run.test.ts +164 -1
- package/src/cli/handlers/run.ts +22 -11
- package/src/cli/main.ts +13 -1
- package/src/cli/mcp/never-over-mcp.test.ts +74 -0
- package/src/cli/registry.ts +7 -0
- package/src/components/changed-files.test.ts +58 -0
- package/src/components/changed-files.ts +19 -4
- package/src/components/fan-out-record.test.ts +47 -2
- package/src/components/fan-out-record.ts +53 -0
- package/src/components/pr-pipeline.ts +89 -6
- package/src/components/pr-run.test.ts +300 -3
- package/src/components/pr-run.ts +196 -5
- package/src/gated-waves.test.ts +11 -0
- package/src/gated-waves.ts +7 -2
- package/src/lexicon.ts +13 -1
- package/src/lifecycle/gate-ledger.ts +4 -3
- package/src/op/activities/factory.ts +9 -1
- package/src/op/gate-name.ts +17 -0
- package/src/op/gate-summary.test.ts +12 -0
- package/src/op/gate-summary.ts +3 -1
- package/src/op/gate.test.ts +15 -1
- package/src/op/gate.ts +21 -4
- package/src/op/index.ts +2 -2
- package/src/op/local-executor.test.ts +35 -0
- package/src/op/local-executor.ts +2 -2
- package/src/op/local-output.test.ts +20 -0
- package/src/op/local-output.ts +4 -4
- package/src/op/runtime.ts +20 -2
- package/src/op/runtimes/local.test.ts +15 -0
- package/src/op/runtimes/local.ts +3 -1
- package/src/pr-loop.test.ts +41 -1
- package/src/pr-loop.ts +38 -8
- package/src/workspace/lineage-upgrade-cli.ts +4 -3
- package/src/workspace/lineage-upgrade.test.ts +40 -0
- package/src/workspace/member-pipeline.test.ts +28 -0
- package/src/workspace/member-pipeline.ts +20 -3
- package/src/workspace/pr-report.schema.json +14 -1
|
@@ -30,6 +30,29 @@
|
|
|
30
30
|
import { dirname } from "node:path";
|
|
31
31
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
32
32
|
import { readWaveRecords, type WaveRecord } from "../gated-waves";
|
|
33
|
+
import type { ChangeSetDocument } from "../change-set";
|
|
34
|
+
import type { PrMember } from "../pr-loop";
|
|
35
|
+
|
|
36
|
+
/** What a pull request's apply approved, kept so a re-run can finish it (#3464). */
|
|
37
|
+
export interface PrApplyRecord {
|
|
38
|
+
/** The gate's op and name: `pr-<n>` and `pr-apply` unless the pipeline names another. */
|
|
39
|
+
op: string;
|
|
40
|
+
gate: string;
|
|
41
|
+
/** The commit the apply ran on. A record from another commit does not carry. */
|
|
42
|
+
head: string;
|
|
43
|
+
/** The change-set digest the approval stands for. */
|
|
44
|
+
digest: string;
|
|
45
|
+
approvedBy: string[];
|
|
46
|
+
/** Every member of the approved set, as planned. */
|
|
47
|
+
members: PrMember[];
|
|
48
|
+
/** The approved change-set document. Its entries are what a re-planned member is checked against. */
|
|
49
|
+
changeSet: ChangeSetDocument;
|
|
50
|
+
/**
|
|
51
|
+
* The outputs the approved plans read. The members left are planned
|
|
52
|
+
* against them again, so an unchanged member plans to the same digest.
|
|
53
|
+
*/
|
|
54
|
+
planOutputs: Record<string, Record<string, unknown>>;
|
|
55
|
+
}
|
|
33
56
|
|
|
34
57
|
export interface FanOutAttempt {
|
|
35
58
|
digest: string;
|
|
@@ -44,11 +67,39 @@ export interface FanOutAttempt {
|
|
|
44
67
|
* root, choudoufu's wave resume file.
|
|
45
68
|
*/
|
|
46
69
|
carried?: Record<string, unknown>;
|
|
70
|
+
/** A pull request's apply only (#3464). */
|
|
71
|
+
prApply?: PrApplyRecord;
|
|
47
72
|
}
|
|
48
73
|
|
|
49
74
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
50
75
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
51
76
|
|
|
77
|
+
const isStringArray = (value: unknown): value is string[] =>
|
|
78
|
+
Array.isArray(value) && value.every((v) => typeof v === "string");
|
|
79
|
+
|
|
80
|
+
/** The `prApply` entry, when it has every field a resume needs. Anything else reads as no entry. */
|
|
81
|
+
function readPrApplyRecord(value: unknown): PrApplyRecord | undefined {
|
|
82
|
+
if (!isRecord(value)) return undefined;
|
|
83
|
+
const { op, gate, head, digest, approvedBy, members, changeSet, planOutputs } = value;
|
|
84
|
+
if (typeof op !== "string" || typeof gate !== "string" || typeof head !== "string" || typeof digest !== "string") return undefined;
|
|
85
|
+
if (!isStringArray(approvedBy) || !Array.isArray(members) || !members.every(isRecord)) return undefined;
|
|
86
|
+
if (!isRecord(changeSet) || changeSet.digest !== digest || !Array.isArray(changeSet.members) || !Array.isArray(changeSet.entries)) return undefined;
|
|
87
|
+
const outputs: Record<string, Record<string, unknown>> = {};
|
|
88
|
+
if (isRecord(planOutputs)) {
|
|
89
|
+
for (const [name, v] of Object.entries(planOutputs)) if (isRecord(v)) outputs[name] = v;
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
op,
|
|
93
|
+
gate,
|
|
94
|
+
head,
|
|
95
|
+
digest,
|
|
96
|
+
approvedBy,
|
|
97
|
+
members: members as unknown as PrMember[],
|
|
98
|
+
changeSet: changeSet as unknown as ChangeSetDocument,
|
|
99
|
+
planOutputs: outputs,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
52
103
|
/** Read an attempt record, or `undefined` when there is none yet. Throws on a file that is not JSON. */
|
|
53
104
|
export function readFanOutAttempt(path: string): FanOutAttempt | undefined {
|
|
54
105
|
if (!existsSync(path)) return undefined;
|
|
@@ -58,6 +109,7 @@ export function readFanOutAttempt(path: string): FanOutAttempt | undefined {
|
|
|
58
109
|
for (const [name, value] of Object.entries(parsed.outputs)) if (isRecord(value)) outputs[name] = value;
|
|
59
110
|
}
|
|
60
111
|
const waves = readWaveRecords(parsed.waves);
|
|
112
|
+
const prApply = readPrApplyRecord(parsed.prApply);
|
|
61
113
|
return {
|
|
62
114
|
digest: typeof parsed.digest === "string" ? parsed.digest : "",
|
|
63
115
|
completed: Array.isArray(parsed.completed) ? parsed.completed.filter((n): n is string => typeof n === "string") : [],
|
|
@@ -65,6 +117,7 @@ export function readFanOutAttempt(path: string): FanOutAttempt | undefined {
|
|
|
65
117
|
outputs,
|
|
66
118
|
...(waves.length > 0 ? { waves } : {}),
|
|
67
119
|
...(isRecord(parsed.carried) && Object.keys(parsed.carried).length > 0 ? { carried: parsed.carried } : {}),
|
|
120
|
+
...(prApply ? { prApply } : {}),
|
|
68
121
|
};
|
|
69
122
|
}
|
|
70
123
|
|
|
@@ -17,9 +17,23 @@
|
|
|
17
17
|
* to `chant/lifecycle`, to record the pending fact when the gate stops it.
|
|
18
18
|
* One apply runs at a time per environment.
|
|
19
19
|
*
|
|
20
|
+
* The apply job passes `--resume` (#3543) with a record path outside the
|
|
21
|
+
* report directory, and each forge keeps that file in its CI cache under a
|
|
22
|
+
* key that names the environment, the member and the pushed commit
|
|
23
|
+
* (`prApplyRecordKey`). A re-run of a failed apply job restores it and
|
|
24
|
+
* finishes the approved set under the same approval; the record itself is
|
|
25
|
+
* checked against the pull request's gate, the commit, the selection and
|
|
26
|
+
* the ledger before it counts (`pr-apply --resume`, #3464).
|
|
27
|
+
*
|
|
20
28
|
* Measuring the push from the commit it replaced works for merge commits,
|
|
21
29
|
* squash merges and rebase merges alike, and every job measures from the
|
|
22
30
|
* merge base of that commit and its own head (`resolveMergeBase`).
|
|
31
|
+
*
|
|
32
|
+
* In a workspace member (#3465) both commands run in the member's directory
|
|
33
|
+
* and pass `--member <name>`, so the member's gate (`pr-<number>-<member>`),
|
|
34
|
+
* note and statuses stay apart from every other member's on the same pull
|
|
35
|
+
* request. They still measure the whole change: a file outside the member
|
|
36
|
+
* that one of its units reaches selects that unit.
|
|
23
37
|
*/
|
|
24
38
|
|
|
25
39
|
import type { ForgeKind } from "../pr-forge";
|
|
@@ -28,6 +42,31 @@ import { PR_APPLY_GATE } from "../pr-loop";
|
|
|
28
42
|
/** Where both jobs write their reports, kept as a CI artifact. */
|
|
29
43
|
export const PR_LOOP_REPORT_DIR = ".chant/pr";
|
|
30
44
|
|
|
45
|
+
/**
|
|
46
|
+
* The attempt record the apply job resumes from (#3543), relative to the
|
|
47
|
+
* directory the job runs in. It sits outside `PR_LOOP_REPORT_DIR`, so the
|
|
48
|
+
* report artifact never carries it: it holds the outputs the members read.
|
|
49
|
+
*/
|
|
50
|
+
export const PR_APPLY_RECORD = ".chant/pr-resume/pr-apply.json";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The image the pull-request pipeline runs in when the caller sets none. The
|
|
54
|
+
* full `node` image has git, which both jobs measure the change with;
|
|
55
|
+
* `PR_LOOP_SETUP` adds OpenTofu on top of it. A caller who sets `image`
|
|
56
|
+
* brings both and gets no setup lines.
|
|
57
|
+
*/
|
|
58
|
+
export const PR_LOOP_IMAGE = "node:22";
|
|
59
|
+
|
|
60
|
+
/** Installs OpenTofu into `PR_LOOP_IMAGE`. */
|
|
61
|
+
export const PR_LOOP_SETUP = [
|
|
62
|
+
"command -v tofu >/dev/null 2>&1 || (curl -fsSL https://get.opentofu.org/install-opentofu.sh -o /tmp/install-opentofu.sh && sh /tmp/install-opentofu.sh --install-method standalone)",
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
/** The setup lines for a pipeline running in `image`: the install when it is the default image, none otherwise. */
|
|
66
|
+
export function prLoopSetup(image: string): string[] {
|
|
67
|
+
return image === PR_LOOP_IMAGE ? PR_LOOP_SETUP : [];
|
|
68
|
+
}
|
|
69
|
+
|
|
31
70
|
/** The `prLoop` generator option (`ComponentPipelineOptions.prLoop`). */
|
|
32
71
|
export interface PrLoopPipelineOptions {
|
|
33
72
|
/** The gate the apply waits on. Default `pr-apply`. */
|
|
@@ -58,12 +97,21 @@ export interface PrLoopJob {
|
|
|
58
97
|
* variables on GitLab.
|
|
59
98
|
*/
|
|
60
99
|
env: Record<string, string>;
|
|
100
|
+
/** Shell lines to run before the command, to settle values the forge may leave unusable. */
|
|
101
|
+
setup?: string[];
|
|
61
102
|
}
|
|
62
103
|
|
|
63
|
-
/**
|
|
64
|
-
|
|
104
|
+
/**
|
|
105
|
+
* The two jobs for `forge`. `member` is the workspace member the pipeline is
|
|
106
|
+
* generated for; the commands then run in its directory, so `--output` is
|
|
107
|
+
* relative to it.
|
|
108
|
+
*/
|
|
109
|
+
export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelineOptions = {}, member?: string): PrLoopJob[] {
|
|
65
110
|
const gate = options.gate ?? PR_APPLY_GATE;
|
|
66
|
-
const common = [
|
|
111
|
+
const common = [
|
|
112
|
+
"--env", env, "--gate", gate, "--output", PR_LOOP_REPORT_DIR, "--forge", forge,
|
|
113
|
+
...(member ? ["--member", member] : []),
|
|
114
|
+
];
|
|
67
115
|
const github = forge !== "gitlab";
|
|
68
116
|
const token: Record<string, string> = github ? { GITHUB_TOKEN: "${{ github.token }}" } : {};
|
|
69
117
|
return [
|
|
@@ -79,13 +127,48 @@ export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelin
|
|
|
79
127
|
command: [
|
|
80
128
|
"chant", "components", "pr-apply", "--base", '"$BASE_SHA"', ...common,
|
|
81
129
|
...(options.requireReview === false ? [] : ["--require-review"]),
|
|
130
|
+
"--resume", PR_APPLY_RECORD,
|
|
82
131
|
].join(" "),
|
|
83
132
|
env: github ? { BASE_SHA: "${{ github.event.before }}", ...token } : { BASE_SHA: "$CI_COMMIT_BEFORE_SHA" },
|
|
133
|
+
...(github ? {} : { setup: [gitlabBaseFallback(options.branch)] }),
|
|
84
134
|
},
|
|
85
135
|
];
|
|
86
136
|
}
|
|
87
137
|
|
|
88
|
-
/**
|
|
89
|
-
|
|
90
|
-
|
|
138
|
+
/**
|
|
139
|
+
* The concurrency group (GitHub, Forgejo) or resource group (GitLab) that
|
|
140
|
+
* keeps one apply at a time per environment, and per member in a workspace.
|
|
141
|
+
*/
|
|
142
|
+
export function prApplyGroup(env: string, member?: string): string {
|
|
143
|
+
return member ? `chant-apply-${member}-${env}` : `chant-apply-${env}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The cache key the apply job keeps its attempt record under (#3543), with
|
|
148
|
+
* `commit` the forge's expression for the pushed commit. A push of another
|
|
149
|
+
* commit never restores a record, and the environment and member keep each
|
|
150
|
+
* pipeline's record apart. The pull request and the gate digest are not in
|
|
151
|
+
* the key, since neither is known before the job runs; `pr-apply --resume`
|
|
152
|
+
* refuses a record made for another pull request, gate or digest.
|
|
153
|
+
*/
|
|
154
|
+
export function prApplyRecordKey(env: string, commit: string, member?: string): string {
|
|
155
|
+
return `${prApplyGroup(env, member)}-${commit}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* GitLab sets `CI_COMMIT_BEFORE_SHA` to forty zeros on a branch's first push.
|
|
160
|
+
* The line replaces it with the merge request's diff base when the pipeline
|
|
161
|
+
* has one, and otherwise with the merge base of the target branch and the
|
|
162
|
+
* pushed commit, or the pushed commit's parent when that is the commit itself.
|
|
163
|
+
*/
|
|
164
|
+
export function gitlabBaseFallback(branch?: string): string {
|
|
165
|
+
const target = branch ? JSON.stringify(branch) : "$CI_DEFAULT_BRANCH";
|
|
166
|
+
return (
|
|
167
|
+
'if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then ' +
|
|
168
|
+
'BASE_SHA="${CI_MERGE_REQUEST_DIFF_BASE_SHA:-}"; ' +
|
|
169
|
+
'if [ -z "$BASE_SHA" ]; then ' +
|
|
170
|
+
`git fetch origin ${target} && BASE_SHA="$(git merge-base FETCH_HEAD "$CI_COMMIT_SHA")"; ` +
|
|
171
|
+
'if [ "$BASE_SHA" = "$CI_COMMIT_SHA" ]; then BASE_SHA="$(git rev-parse "$CI_COMMIT_SHA^")"; fi; ' +
|
|
172
|
+
"fi; fi; export BASE_SHA"
|
|
173
|
+
);
|
|
91
174
|
}
|
|
@@ -12,8 +12,21 @@ import { describe, expect, test } from "vitest";
|
|
|
12
12
|
import { CapabilityRegistry, type DeployContext } from "./capability";
|
|
13
13
|
import { memoryGateLedgerPort } from "../op/gate";
|
|
14
14
|
import { planFanOut } from "./fan-out";
|
|
15
|
-
import {
|
|
16
|
-
|
|
15
|
+
import {
|
|
16
|
+
applyPrSet,
|
|
17
|
+
approvalOf,
|
|
18
|
+
approversWithoutReview,
|
|
19
|
+
decidePrGate,
|
|
20
|
+
planCoveredBy,
|
|
21
|
+
planPrSet,
|
|
22
|
+
prReport,
|
|
23
|
+
readOnlyLedger,
|
|
24
|
+
resumePrSet,
|
|
25
|
+
type PrPlanSet,
|
|
26
|
+
} from "./pr-run";
|
|
27
|
+
import { changeSetDigest, composeChangeSet, type ChangeSetEntry, type ChangeSetPart } from "../change-set";
|
|
28
|
+
import type { PrApplyRecord } from "./fan-out-record";
|
|
29
|
+
import type { FanOutPlan } from "./fan-out";
|
|
17
30
|
import { computePlanDigest } from "../lifecycle/plan-digest";
|
|
18
31
|
import type { DriverComponent } from "./driver";
|
|
19
32
|
|
|
@@ -66,7 +79,17 @@ function world() {
|
|
|
66
79
|
changeSet: {
|
|
67
80
|
member: { member: input.root, lexicon: "terraform", planner: "tofu", status: "planned", planDigest, holes: [] },
|
|
68
81
|
entries: changes
|
|
69
|
-
? [
|
|
82
|
+
? [
|
|
83
|
+
{
|
|
84
|
+
member: input.root,
|
|
85
|
+
lexicon: "terraform",
|
|
86
|
+
planner: "tofu",
|
|
87
|
+
address: "terraform_data.x",
|
|
88
|
+
type: "terraform_data",
|
|
89
|
+
action: "update",
|
|
90
|
+
attributes: [{ path: "version", before: "v1", after: w.versions[input.root] }],
|
|
91
|
+
},
|
|
92
|
+
]
|
|
70
93
|
: [],
|
|
71
94
|
},
|
|
72
95
|
};
|
|
@@ -203,3 +226,277 @@ describe("applyPrSet", () => {
|
|
|
203
226
|
expect(members.find((m) => m.member === "a")?.error).toBe("a failed");
|
|
204
227
|
});
|
|
205
228
|
});
|
|
229
|
+
|
|
230
|
+
/** The record a first attempt leaves (`pr-apply --resume`), from the set it applied. */
|
|
231
|
+
function recordOf(set: PrPlanSet, approvedBy = "github:alice"): PrApplyRecord {
|
|
232
|
+
return {
|
|
233
|
+
op: "pr-12",
|
|
234
|
+
gate: "pr-apply",
|
|
235
|
+
head: "2".repeat(40),
|
|
236
|
+
digest: set.doc.digest,
|
|
237
|
+
approvedBy: [approvedBy],
|
|
238
|
+
members: set.members,
|
|
239
|
+
changeSet: set.doc,
|
|
240
|
+
planOutputs: set.outputs,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Plan and apply once, with `failing` failing to apply. */
|
|
245
|
+
async function firstAttempt(w: ReturnType<typeof world>, plan: FanOutPlan, failing: string[]) {
|
|
246
|
+
for (const f of failing) w.failing.add(f);
|
|
247
|
+
const set = await planPrSet({ components: ESTATE, plan, registry: w.registry, env: "prod" });
|
|
248
|
+
const first = await applyPrSet({ components: ESTATE, plan, registry: w.registry, env: "prod", set });
|
|
249
|
+
for (const f of failing) w.failing.delete(f);
|
|
250
|
+
return { set, first, record: recordOf(set) };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
describe("resuming an apply that failed partway (#3464)", () => {
|
|
254
|
+
test("plans and applies only what did not apply, and the set keeps the approved digest", async () => {
|
|
255
|
+
const w = world();
|
|
256
|
+
const plan = planFanOut({ components: ESTATE, changed: ["net", "dns"] });
|
|
257
|
+
const { set, first, record } = await firstAttempt(w, plan, ["a"]);
|
|
258
|
+
expect(first.status).toBe("failed");
|
|
259
|
+
expect(first.run.completed).toEqual(["b", "dns", "net"]);
|
|
260
|
+
|
|
261
|
+
w.planned.length = 0;
|
|
262
|
+
w.applied.length = 0;
|
|
263
|
+
const resumed = await resumePrSet({ components: ESTATE, plan, registry: w.registry, env: "prod", record, completed: first.run.completed });
|
|
264
|
+
if (!resumed.ok) throw new Error(resumed.reasons.join("; "));
|
|
265
|
+
|
|
266
|
+
expect(resumed.plan.order).toEqual(["a", "app"]);
|
|
267
|
+
// Planned against the outputs the approved plans read, so each member plans as it did.
|
|
268
|
+
expect(w.planned).toEqual([
|
|
269
|
+
{ root: "a", vars: { cidr: "10.0.0.0/16" } },
|
|
270
|
+
{ root: "app", vars: { subnet: "a-v1" } },
|
|
271
|
+
]);
|
|
272
|
+
expect(resumed.set.doc.digest).toBe(set.doc.digest);
|
|
273
|
+
expect(resumed.fresh.members.map((m) => m.planDigest)).toEqual(
|
|
274
|
+
set.members.filter((m) => m.member === "a" || m.member === "app").map((m) => m.planDigest),
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
// The gate is decided against the digest that was approved, and it still stands.
|
|
278
|
+
const ledger = memoryGateLedgerPort({ resolutions: [approval(set.doc.digest)] });
|
|
279
|
+
const check = await decidePrGate(ledger, { op: "pr-12", gate: "pr-apply", digest: resumed.set.doc.digest, now: NOW });
|
|
280
|
+
expect(check.satisfied).toBe(true);
|
|
281
|
+
expect(ledger.appended).toEqual([]);
|
|
282
|
+
|
|
283
|
+
const second = await applyPrSet({
|
|
284
|
+
components: ESTATE,
|
|
285
|
+
plan: resumed.plan,
|
|
286
|
+
registry: w.registry,
|
|
287
|
+
env: "prod",
|
|
288
|
+
set: resumed.set,
|
|
289
|
+
resumed: { completed: first.run.completed, outputs: {} },
|
|
290
|
+
});
|
|
291
|
+
expect(second.status).toBe("applied");
|
|
292
|
+
expect(w.applied).toEqual(["a", "app"]);
|
|
293
|
+
expect(w.fromPlan.a).toBe(true);
|
|
294
|
+
expect(Object.fromEntries(second.members.map((m) => [m.member, m.status]))).toEqual({
|
|
295
|
+
a: "applied",
|
|
296
|
+
app: "applied",
|
|
297
|
+
b: "applied",
|
|
298
|
+
dns: "applied",
|
|
299
|
+
net: "applied",
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("the resumed report carries the approved digest and names what the earlier attempt applied", async () => {
|
|
304
|
+
const w = world();
|
|
305
|
+
const plan = planFanOut({ components: ESTATE, changed: ["net", "dns"] });
|
|
306
|
+
const { set, first, record } = await firstAttempt(w, plan, ["a"]);
|
|
307
|
+
const resumed = await resumePrSet({ components: ESTATE, plan, registry: w.registry, env: "prod", record, completed: first.run.completed });
|
|
308
|
+
if (!resumed.ok) throw new Error(resumed.reasons.join("; "));
|
|
309
|
+
const second = await applyPrSet({
|
|
310
|
+
components: ESTATE,
|
|
311
|
+
plan: resumed.plan,
|
|
312
|
+
registry: w.registry,
|
|
313
|
+
env: "prod",
|
|
314
|
+
set: resumed.set,
|
|
315
|
+
resumed: { completed: first.run.completed, outputs: {} },
|
|
316
|
+
});
|
|
317
|
+
const report = prReport({
|
|
318
|
+
stage: "apply",
|
|
319
|
+
pr: 12,
|
|
320
|
+
env: "prod",
|
|
321
|
+
base: "1".repeat(40),
|
|
322
|
+
head: "2".repeat(40),
|
|
323
|
+
op: "pr-12",
|
|
324
|
+
gate: "pr-apply",
|
|
325
|
+
plan,
|
|
326
|
+
signal: { changed: ["net", "dns"], unclaimed: [] } as never,
|
|
327
|
+
set: resumed.set,
|
|
328
|
+
approval: { status: "approved", approvedBy: ["github:alice"] },
|
|
329
|
+
status: second.status,
|
|
330
|
+
members: second.members,
|
|
331
|
+
resumed: first.run.completed,
|
|
332
|
+
});
|
|
333
|
+
expect(report.digest).toBe(set.doc.digest);
|
|
334
|
+
expect(report.changeSet).toBe(set.doc);
|
|
335
|
+
expect(report.resumed).toEqual({ applied: ["b", "dns", "net"] });
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("a dependent whose dependency applied in the earlier attempt applies its approved plan and says its inputs moved", async () => {
|
|
339
|
+
const w = world();
|
|
340
|
+
w.versions.a = "v2";
|
|
341
|
+
const plan = planFanOut({ components: ESTATE, changed: ["a"] });
|
|
342
|
+
const { first, record } = await firstAttempt(w, plan, ["app"]);
|
|
343
|
+
expect(first.run.completed).toEqual(["a"]);
|
|
344
|
+
const outputs = { a: first.run.componentOutputs.a };
|
|
345
|
+
expect(outputs.a).toEqual({ id: "a-v2" });
|
|
346
|
+
|
|
347
|
+
w.planned.length = 0;
|
|
348
|
+
const resumed = await resumePrSet({ components: ESTATE, plan, registry: w.registry, env: "prod", record, completed: ["a"] });
|
|
349
|
+
if (!resumed.ok) throw new Error(resumed.reasons.join("; "));
|
|
350
|
+
// app is planned against the value it was approved with, not the one a wrote since.
|
|
351
|
+
expect(w.planned).toEqual([{ root: "app", vars: { subnet: "a-v1" } }]);
|
|
352
|
+
|
|
353
|
+
const second = await applyPrSet({
|
|
354
|
+
components: ESTATE,
|
|
355
|
+
plan: resumed.plan,
|
|
356
|
+
registry: w.registry,
|
|
357
|
+
env: "prod",
|
|
358
|
+
set: resumed.set,
|
|
359
|
+
resumed: { completed: ["a"], outputs },
|
|
360
|
+
});
|
|
361
|
+
expect(second.members.map((m) => [m.member, m.status, m.inputsMoved])).toEqual([
|
|
362
|
+
["a", "applied", undefined],
|
|
363
|
+
["app", "applied", ["a"]],
|
|
364
|
+
]);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("a member left over whose plan moved is not covered, and says why", async () => {
|
|
368
|
+
const w = world();
|
|
369
|
+
w.versions.a = "v2";
|
|
370
|
+
const plan = planFanOut({ components: ESTATE, changed: ["net", "a"] });
|
|
371
|
+
const { first, record } = await firstAttempt(w, plan, ["a"]);
|
|
372
|
+
expect(first.run.completed).toEqual(["b", "net"]);
|
|
373
|
+
|
|
374
|
+
w.versions.a = "v3";
|
|
375
|
+
const resumed = await resumePrSet({ components: ESTATE, plan, registry: w.registry, env: "prod", record, completed: first.run.completed });
|
|
376
|
+
expect(resumed.ok).toBe(false);
|
|
377
|
+
if (resumed.ok) return;
|
|
378
|
+
expect(resumed.reasons).toEqual(["a now writes terraform_data.x.version to a different value than the approved plan"]);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("a member left over that fails to plan is not covered", async () => {
|
|
382
|
+
const w = world();
|
|
383
|
+
const plan = planFanOut({ components: ESTATE, changed: ["net"] });
|
|
384
|
+
const { first, record } = await firstAttempt(w, plan, ["a"]);
|
|
385
|
+
w.planFailing.add("a");
|
|
386
|
+
const resumed = await resumePrSet({ components: ESTATE, plan, registry: w.registry, env: "prod", record, completed: first.run.completed });
|
|
387
|
+
expect(resumed.ok).toBe(false);
|
|
388
|
+
if (resumed.ok) return;
|
|
389
|
+
expect(resumed.reasons[0]).toMatch(/^a failed to plan/);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test("a record whose change set is not the one its digest names does not resume (#3543)", async () => {
|
|
393
|
+
const w = world();
|
|
394
|
+
const plan = planFanOut({ components: ESTATE, changed: ["net"] });
|
|
395
|
+
const { set, first, record } = await firstAttempt(w, plan, ["a"]);
|
|
396
|
+
const other = { ...set.doc, digest: "jcs1-sha256:" + "0".repeat(64) };
|
|
397
|
+
for (const forged of [
|
|
398
|
+
{ ...record, changeSet: other },
|
|
399
|
+
{ ...record, digest: other.digest, changeSet: other },
|
|
400
|
+
{ ...record, changeSet: { ...set.doc, members: set.doc.members.map((m) => (m.member === "a" ? { ...m, planDigest: "jcs1-sha256:" + "1".repeat(64) } : m)) } },
|
|
401
|
+
]) {
|
|
402
|
+
w.planned.length = 0;
|
|
403
|
+
const resumed = await resumePrSet({ components: ESTATE, plan, registry: w.registry, env: "prod", record: forged, completed: first.run.completed });
|
|
404
|
+
expect(resumed.ok).toBe(false);
|
|
405
|
+
if (resumed.ok) return;
|
|
406
|
+
expect(resumed.reasons[0]).toMatch(/^the record's change set is not the one its digest/);
|
|
407
|
+
expect(w.planned).toEqual([]);
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test("with every component applied, nothing is left to plan or apply", async () => {
|
|
412
|
+
const w = world();
|
|
413
|
+
const plan = planFanOut({ components: ESTATE, changed: ["dns"] });
|
|
414
|
+
const { first, record } = await firstAttempt(w, plan, []);
|
|
415
|
+
expect(first.status).toBe("applied");
|
|
416
|
+
w.planned.length = 0;
|
|
417
|
+
const resumed = await resumePrSet({ components: ESTATE, plan, registry: w.registry, env: "prod", record, completed: first.run.completed });
|
|
418
|
+
if (!resumed.ok) throw new Error(resumed.reasons.join("; "));
|
|
419
|
+
expect(resumed.plan.order).toEqual([]);
|
|
420
|
+
expect(w.planned).toEqual([]);
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
describe("planCoveredBy", () => {
|
|
425
|
+
const entry = (address: string, action: ChangeSetEntry["action"], attributes: ChangeSetEntry["attributes"] = [], extra: Partial<ChangeSetEntry> = {}): ChangeSetEntry => ({
|
|
426
|
+
member: "net",
|
|
427
|
+
lexicon: "terraform",
|
|
428
|
+
planner: "tofu",
|
|
429
|
+
address,
|
|
430
|
+
type: address.split(".")[0],
|
|
431
|
+
action,
|
|
432
|
+
attributes,
|
|
433
|
+
...extra,
|
|
434
|
+
});
|
|
435
|
+
const doc = (digest: string, entries: ChangeSetEntry[], extra: Partial<ChangeSetPart["member"]> = {}) =>
|
|
436
|
+
composeChangeSet([
|
|
437
|
+
{
|
|
438
|
+
member: { member: "net", lexicon: "terraform", planner: "tofu", status: "planned", planDigest: `jcs1-sha256:${digest.repeat(64)}`, holes: [], ...extra },
|
|
439
|
+
entries,
|
|
440
|
+
},
|
|
441
|
+
]);
|
|
442
|
+
|
|
443
|
+
const approved = doc("1", [
|
|
444
|
+
entry("aws_vpc.main", "update", [{ path: "tags", before: { a: "1" }, after: { a: "2" } }]),
|
|
445
|
+
entry("aws_subnet.a", "create", [{ path: "vpc_id", unknown: true }, { path: "cidr_block", after: "10.0.1.0/24" }]),
|
|
446
|
+
entry("aws_instance.web", "replace", [{ path: "ami", before: "ami-1", after: "ami-2", forcesReplacement: true }]),
|
|
447
|
+
entry("aws_db_instance.db", "update", [{ path: "password", sensitive: true }]),
|
|
448
|
+
]);
|
|
449
|
+
|
|
450
|
+
test("the same plan digest is covered", () => {
|
|
451
|
+
expect(planCoveredBy(approved, doc("1", []), "net")).toBeUndefined();
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("what is left of the approved changes is covered", () => {
|
|
455
|
+
// The VPC applied, the subnet's vpc_id is known now, and the instance's
|
|
456
|
+
// old object was destroyed before the create failed.
|
|
457
|
+
const left = doc("2", [
|
|
458
|
+
entry("aws_vpc.main", "no-op"),
|
|
459
|
+
entry("aws_subnet.a", "create", [{ path: "vpc_id", after: "vpc-123" }, { path: "cidr_block", after: "10.0.1.0/24" }]),
|
|
460
|
+
entry("aws_instance.web", "create", [{ path: "ami", after: "ami-2" }, { path: "instance_type", after: "t3.micro" }]),
|
|
461
|
+
entry("aws_db_instance.db", "update", [{ path: "password", sensitive: true }]),
|
|
462
|
+
]);
|
|
463
|
+
expect(planCoveredBy(approved, left, "net")).toBeUndefined();
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("a create-before-destroy replace left with its deposed object to delete is covered", () => {
|
|
467
|
+
const left = doc("2", [entry("aws_instance.web", "delete", [], { deposed: "00abc" })]);
|
|
468
|
+
expect(planCoveredBy(approved, left, "net")).toBeUndefined();
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test("a change the approved plan did not make is not covered", () => {
|
|
472
|
+
expect(planCoveredBy(approved, doc("2", [entry("aws_s3_bucket.logs", "create")]), "net")).toBe(
|
|
473
|
+
"net now plans to create aws_s3_bucket.logs, which the approved plan did not",
|
|
474
|
+
);
|
|
475
|
+
expect(planCoveredBy(approved, doc("2", [entry("aws_vpc.main", "delete")]), "net")).toBe(
|
|
476
|
+
"net now plans to delete aws_vpc.main, where the approved plan would update it",
|
|
477
|
+
);
|
|
478
|
+
expect(planCoveredBy(approved, doc("2", [entry("aws_vpc.main", "update", [{ path: "tags", before: { a: "1" }, after: { a: "3" } }])]), "net")).toBe(
|
|
479
|
+
"net now writes aws_vpc.main.tags to a different value than the approved plan",
|
|
480
|
+
);
|
|
481
|
+
expect(planCoveredBy(approved, doc("2", [entry("aws_vpc.main", "update", [{ path: "cidr_block", before: "a", after: "b" }])]), "net")).toBe(
|
|
482
|
+
"net now writes aws_vpc.main.cidr_block, which the approved plan did not",
|
|
483
|
+
);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test("a sensitive value is covered only where the approved plan wrote it as sensitive", () => {
|
|
487
|
+
expect(planCoveredBy(approved, doc("2", [entry("aws_vpc.main", "update", [{ path: "tags", sensitive: true }])]), "net")).toMatch(/as sensitive/);
|
|
488
|
+
expect(planCoveredBy(approved, doc("2", [entry("aws_db_instance.db", "update", [{ path: "password", after: "x" }])]), "net")).toMatch(/wrote as sensitive/);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test("a hole the approved plan did not have is not covered", () => {
|
|
492
|
+
const left = doc("2", [], { holes: [{ address: "data.aws_ami.latest", reason: "unknown" }] });
|
|
493
|
+
expect(planCoveredBy(approved, left, "net")).toMatch(/cannot read data.aws_ami.latest/);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test("a member the approved set did not have is not covered", () => {
|
|
497
|
+
const other = composeChangeSet([
|
|
498
|
+
{ member: { member: "edge", lexicon: "terraform", planner: "tofu", status: "planned", planDigest: `jcs1-sha256:${"3".repeat(64)}`, holes: [] }, entries: [] },
|
|
499
|
+
]);
|
|
500
|
+
expect(planCoveredBy(approved, other, "edge")).toBe("edge was not in the approved plan");
|
|
501
|
+
});
|
|
502
|
+
});
|