@intentius/chant-lexicon-terraform 0.124.31 → 0.124.33
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/__fixtures__/terragrunt/affected-cases.js +111 -0
- package/dist/__fixtures__/terragrunt/record.js +57 -0
- package/dist/behaviour/index.js +6 -0
- package/dist/behaviour/kinds.js +194 -0
- package/dist/behaviour/predict.js +297 -0
- package/dist/behaviour/reference-catalog.js +178 -0
- package/dist/behaviour/request.js +482 -0
- package/dist/change-set.js +234 -0
- package/dist/changed-roots.js +97 -0
- package/dist/codegen/docs-cli.js +3 -0
- package/dist/codegen/docs.js +44 -0
- package/dist/codegen/generate-cli.js +9 -0
- package/dist/codegen/generate.js +59 -0
- package/dist/codegen/package.js +43 -0
- package/dist/commands.js +106 -0
- package/dist/components/capability-plugin.d.ts +38 -2
- package/dist/components/capability-plugin.d.ts.map +1 -1
- package/dist/components/capability-plugin.js +260 -0
- package/dist/composites/terraform-adopt-op.d.ts +2 -0
- package/dist/composites/terraform-adopt-op.d.ts.map +1 -1
- package/dist/composites/terraform-adopt-op.js +153 -0
- package/dist/composites/terraform-apply-op.d.ts +2 -0
- package/dist/composites/terraform-apply-op.d.ts.map +1 -1
- package/dist/composites/terraform-apply-op.js +184 -0
- package/dist/composites/terraform-pin-rollout-op.js +45 -0
- package/dist/composites/terraform-watch-op.js +219 -0
- package/dist/config.js +118 -0
- package/dist/describe-resources.js +984 -0
- package/dist/generated/index.js +1 -0
- package/dist/hcl/descend.js +208 -0
- package/dist/hcl/edges.js +287 -0
- package/dist/hcl/jsonencode.js +442 -0
- package/dist/hcl/marker.js +212 -0
- package/dist/hcl/parse.js +372 -0
- package/dist/hcl/references.js +156 -0
- package/dist/hcl/roots.js +98 -0
- package/dist/hcl/suppressions.js +189 -0
- package/dist/hcl/value.js +72 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +46 -0
- package/dist/integrity.json +2 -2
- package/dist/lint/audit-catalog.js +501 -0
- package/dist/lint/audit-lineage.js +442 -0
- package/dist/lint/post-synth/blocks.js +87 -0
- package/dist/lint/post-synth/fixtures/load.js +94 -0
- package/dist/lint/post-synth/flat-attributes.js +135 -0
- package/dist/lint/post-synth/index.js +90 -0
- package/dist/lint/post-synth/module-source.js +153 -0
- package/dist/lint/post-synth/scope.js +104 -0
- package/dist/lint/post-synth/terragrunt.js +63 -0
- package/dist/lint/post-synth/tf001.js +138 -0
- package/dist/lint/post-synth/tf002.js +143 -0
- package/dist/lint/post-synth/tf003.js +60 -0
- package/dist/lint/post-synth/tf004.js +54 -0
- package/dist/lint/post-synth/tf005.js +68 -0
- package/dist/lint/post-synth/tf006.js +43 -0
- package/dist/lint/post-synth/tf007.js +66 -0
- package/dist/lint/post-synth/tf008.js +64 -0
- package/dist/lint/post-synth/tf009.js +49 -0
- package/dist/lint/post-synth/tf010.js +40 -0
- package/dist/lint/post-synth/tf011.js +38 -0
- package/dist/lint/post-synth/tf012.js +36 -0
- package/dist/lint/post-synth/tf013.js +53 -0
- package/dist/lint/post-synth/tf014.js +61 -0
- package/dist/lint/post-synth/tf015.js +62 -0
- package/dist/lint/post-synth/tf016.js +66 -0
- package/dist/lint/post-synth/tf017.js +39 -0
- package/dist/lint/post-synth/tf018.js +63 -0
- package/dist/lint/post-synth/tf019.js +60 -0
- package/dist/lint/post-synth/tf020.js +103 -0
- package/dist/lint/post-synth/tf021.js +117 -0
- package/dist/lint/post-synth/tf022.js +51 -0
- package/dist/lint/post-synth/tf024.js +62 -0
- package/dist/lint/post-synth/tf025.js +83 -0
- package/dist/lint/post-synth/tf026.js +74 -0
- package/dist/lint/post-synth/tf027.js +66 -0
- package/dist/lint/post-synth/tf028.js +83 -0
- package/dist/lint/post-synth/tf029.js +73 -0
- package/dist/lint/post-synth/tf030.js +253 -0
- package/dist/lint/post-synth/tf031.js +332 -0
- package/dist/lint/post-synth/tf032.js +201 -0
- package/dist/lint/post-synth/tf033.js +126 -0
- package/dist/lint/post-synth/tf034.js +45 -0
- package/dist/lint/post-synth/tf035.js +63 -0
- package/dist/lint/post-synth/tf036.js +97 -0
- package/dist/lint/post-synth/tf037.js +80 -0
- package/dist/lint/post-synth/tf038.js +63 -0
- package/dist/lint/post-synth/tf039.js +55 -0
- package/dist/lint/post-synth/tf040.js +67 -0
- package/dist/lint/post-synth/tf041.js +53 -0
- package/dist/lint/post-synth/tf042.js +38 -0
- package/dist/lint/post-synth/tf043.js +45 -0
- package/dist/lint/post-synth/tf044.js +45 -0
- package/dist/lint/post-synth/tf045.js +44 -0
- package/dist/lint/rules/fixtures/load.js +50 -0
- package/dist/lint/rules/index.js +4 -0
- package/dist/lint/rules/plan-before-apply.js +136 -0
- package/dist/lint/secret-shape.js +149 -0
- package/dist/live-ownership.js +57 -0
- package/dist/lsp/completions.js +95 -0
- package/dist/lsp/context.js +358 -0
- package/dist/lsp/hover.js +45 -0
- package/dist/lsp/option-keys.js +93 -0
- package/dist/manifest.json +1 -1
- package/dist/op/activities/index.js +30 -0
- package/dist/op/activities/live-detect.js +143 -0
- package/dist/op/activities/pin-rollout.js +45 -0
- package/dist/op/activities/terraform.js +1270 -0
- package/dist/op/activity-contracts.js +162 -0
- package/dist/op/adoption.js +272 -0
- package/dist/op/builders.js +125 -0
- package/dist/op/resolve-root-mode.js +77 -0
- package/dist/package-cli.js +16 -0
- package/dist/pin/edit-ts.js +76 -0
- package/dist/pin/edit.js +118 -0
- package/dist/pin/forge.js +65 -0
- package/dist/pin/index.js +16 -0
- package/dist/pin/rollout.d.ts.map +1 -1
- package/dist/pin/rollout.js +382 -0
- package/dist/pin/scan.js +287 -0
- package/dist/pin/source.js +171 -0
- package/dist/pin/waves.js +109 -0
- package/dist/plan-digest.js +67 -0
- package/dist/plugin.js +323 -0
- package/dist/serializer.js +23 -0
- package/dist/state-ownership.js +29 -0
- package/dist/terragrunt/affected.d.ts.map +1 -1
- package/dist/terragrunt/affected.js +290 -0
- package/dist/terragrunt/index.js +10 -0
- package/dist/terragrunt/mocks.js +270 -0
- package/dist/terragrunt/run.js +264 -0
- package/dist/terragrunt/units.js +195 -0
- package/dist/terragrunt/wave.js +125 -0
- package/dist/validate-cli.js +4 -0
- package/dist/validate.js +31 -0
- package/package.json +10 -10
- package/src/codegen/package.ts +2 -2
- package/src/components/capability-plugin.test.ts +97 -2
- package/src/components/capability-plugin.ts +117 -12
- package/src/components/pr-loop.tofu.test.ts +51 -11
- package/src/composites/terraform-adopt-op.test.ts +6 -0
- package/src/composites/terraform-adopt-op.ts +7 -2
- package/src/composites/terraform-apply-op.test.ts +6 -0
- package/src/composites/terraform-apply-op.ts +7 -2
- package/src/index.ts +1 -0
- package/src/pin/rollout.ts +2 -1
- package/src/terragrunt/affected.test.ts +11 -0
- package/src/terragrunt/affected.ts +2 -1
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Roll a module version out as pin-bump pull requests, one wave at a time
|
|
3
|
+
* (#3189, choudoufu#1749).
|
|
4
|
+
*
|
|
5
|
+
* Each run reads where the rollout stands and does at most one thing:
|
|
6
|
+
*
|
|
7
|
+
* 1. It checks out the base branch in a temporary worktree and reads every
|
|
8
|
+
* root's pin there: at the old version, at the new one, refused (a
|
|
9
|
+
* floating constraint, no pin, an expression), or pinned at something
|
|
10
|
+
* else. Only roots at the old or the new version are in the rollout, so
|
|
11
|
+
* the wave plan is the same on every run.
|
|
12
|
+
* 2. It splits those roots into waves (`./waves.ts`).
|
|
13
|
+
* 3. It walks the waves in order, reading each wave's pull request by its
|
|
14
|
+
* branch. A wave whose PR merged, and whose every moved root reports its
|
|
15
|
+
* apply check as passed on the merge commit, is done. The first wave that
|
|
16
|
+
* is not done decides the run:
|
|
17
|
+
* - no PR yet: open it (one branch, one PR, the pin moved for that wave's
|
|
18
|
+
* roots only), or in `report` mode say it would;
|
|
19
|
+
* - PR open, or merged with an apply still pending: report where it
|
|
20
|
+
* stands and open nothing;
|
|
21
|
+
* - PR closed without merging, or a root whose apply check failed: stop,
|
|
22
|
+
* naming it. The waves after it do not open.
|
|
23
|
+
*
|
|
24
|
+
* That is #2119's gate as a fact: nothing waits. The next run reads the
|
|
25
|
+
* forge again and either moves on or reports the same place.
|
|
26
|
+
*
|
|
27
|
+
* The PR's branch is built from the base branch in a worktree and pushed.
|
|
28
|
+
* The default branch, and whatever the caller has checked out, are never
|
|
29
|
+
* written, as with core's `proposeWorkspaceUpgrade` (#2550). Each PR changes
|
|
30
|
+
* only the files of its wave's roots, so a path-diff selection (GitLab's
|
|
31
|
+
* `rules: changes:`, #3183's affected members) plans exactly those roots.
|
|
32
|
+
*
|
|
33
|
+
* An "applied" root is one whose apply check, named by `appliedCheck`
|
|
34
|
+
* (default `apply/{root}`), passed on the merge commit. That is how a
|
|
35
|
+
* per-root apply job on the merged commit reports today; when the per-PR
|
|
36
|
+
* loop (#3183) lands, its per-member check name is the one to pass.
|
|
37
|
+
*/
|
|
38
|
+
import { execFile } from "node:child_process";
|
|
39
|
+
import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, existsSync } from "node:fs";
|
|
40
|
+
import { tmpdir } from "node:os";
|
|
41
|
+
import { join, posix } from "node:path";
|
|
42
|
+
import { promisify } from "node:util";
|
|
43
|
+
import { ghHostEnv } from "@intentius/chant/op/activities/gh-host";
|
|
44
|
+
import { editPins } from "./edit.js";
|
|
45
|
+
import { checkPinRequest, moduleOf } from "./source.js";
|
|
46
|
+
import { planPinWaves, restrictWaves, terragruntDependencies } from "./waves.js";
|
|
47
|
+
import { ghPinForge } from "./forge.js";
|
|
48
|
+
const execFileAsync = /* @__PURE__ */ promisify(execFile);
|
|
49
|
+
// `gh pr list/create` and `gh api repos/{owner}/{repo}/...` name no host, so
|
|
50
|
+
// a `gh` call is told the job's forge or `origin`'s host (core's `ghHostEnv`,
|
|
51
|
+
// #3780, #3798): a GitHub Enterprise Server checkout is not sent to github.com.
|
|
52
|
+
const defaultRun = async (bin, args, cwd) => (await execFileAsync(bin, args, { cwd, maxBuffer: 64 * 1024 * 1024, ...(bin === "gh" ? { env: await ghHostEnv(process.env, cwd) } : {}) })).stdout;
|
|
53
|
+
// `.chant` holds work worktrees in `.chant/work`, each a copy of the checkout (chant#2893).
|
|
54
|
+
const SKIP_DIRS = new Set([".git", ".terraform", ".terragrunt-cache", "node_modules", ".chant"]);
|
|
55
|
+
const NO_FORGE = {
|
|
56
|
+
findPullRequest: async () => null,
|
|
57
|
+
createPullRequest: async () => {
|
|
58
|
+
throw new Error("pin rollout: no forge to open a pull request on");
|
|
59
|
+
},
|
|
60
|
+
commitChecks: async () => [],
|
|
61
|
+
};
|
|
62
|
+
/** The marker that ties a PR to its wave, and carries the roots it moved. */
|
|
63
|
+
const MARKER = "chant-pin-rollout";
|
|
64
|
+
/** A root directory as the rollout keys it: relative, `/`-separated, no `./` and no trailing slash. */
|
|
65
|
+
function dirOf(path) {
|
|
66
|
+
return posix.normalize(path.replaceAll("\\", "/")).replace(/\/+$/, "") || ".";
|
|
67
|
+
}
|
|
68
|
+
function slug(value) {
|
|
69
|
+
const s = value.replace(/^[a-z]+:\/\//, "").replace(/^sha256:([0-9a-f]{12})[0-9a-f]*$/i, "sha256-$1");
|
|
70
|
+
return s.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80);
|
|
71
|
+
}
|
|
72
|
+
/** The branch a wave's PR is opened from. */
|
|
73
|
+
export function pinWaveBranch(request, wave) {
|
|
74
|
+
return `chant/pin/${slug(request.module)}/${slug(request.to)}/wave-${wave}`;
|
|
75
|
+
}
|
|
76
|
+
/** The files the pin edit reads in a root directory: its `.tf` files and its `terragrunt.hcl`. */
|
|
77
|
+
function rootFiles(worktree, root) {
|
|
78
|
+
const dir = join(worktree, root);
|
|
79
|
+
if (!existsSync(dir))
|
|
80
|
+
return [];
|
|
81
|
+
return readdirSync(dir)
|
|
82
|
+
.filter((f) => f.endsWith(".tf") || f === "terragrunt.hcl")
|
|
83
|
+
.sort()
|
|
84
|
+
.map((f) => posix.join(root, f));
|
|
85
|
+
}
|
|
86
|
+
/** Every directory under the worktree with a `.tf` file or a `terragrunt.hcl`. */
|
|
87
|
+
function candidateRoots(worktree) {
|
|
88
|
+
const out = [];
|
|
89
|
+
const walk = (rel) => {
|
|
90
|
+
const entries = readdirSync(join(worktree, rel), { withFileTypes: true });
|
|
91
|
+
if (entries.some((e) => e.isFile() && (e.name.endsWith(".tf") || e.name === "terragrunt.hcl")))
|
|
92
|
+
out.push(rel || ".");
|
|
93
|
+
for (const e of entries)
|
|
94
|
+
if (e.isDirectory() && !SKIP_DIRS.has(e.name))
|
|
95
|
+
walk(rel ? posix.join(rel, e.name) : e.name);
|
|
96
|
+
};
|
|
97
|
+
walk("");
|
|
98
|
+
return out.sort();
|
|
99
|
+
}
|
|
100
|
+
async function editRoot(worktree, root, request, options) {
|
|
101
|
+
const edits = new Map();
|
|
102
|
+
const calls = [];
|
|
103
|
+
let files;
|
|
104
|
+
if (root.tsSource) {
|
|
105
|
+
files = [root.tsSource];
|
|
106
|
+
if (!options.editTs) {
|
|
107
|
+
return { state: { root: root.root, state: "refused", files, calls, reason: `generated from ${root.tsSource}, and this caller has no TypeScript editor` }, edits };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
files = rootFiles(worktree, root.root);
|
|
112
|
+
}
|
|
113
|
+
for (const file of files) {
|
|
114
|
+
const path = join(worktree, file);
|
|
115
|
+
if (!existsSync(path))
|
|
116
|
+
return { state: { root: root.root, state: "refused", files, calls, reason: `${file} does not exist on the base branch` }, edits };
|
|
117
|
+
const text = readFileSync(path, "utf-8");
|
|
118
|
+
const result = root.tsSource ? options.editTs(text, file, request) : await editPins(text, file, request, options.parser);
|
|
119
|
+
calls.push(...result.calls);
|
|
120
|
+
if (result.content !== text)
|
|
121
|
+
edits.set(file, result.content);
|
|
122
|
+
}
|
|
123
|
+
const refused = calls.filter((c) => c.outcome === "refused");
|
|
124
|
+
let state;
|
|
125
|
+
let reason;
|
|
126
|
+
if (calls.length === 0) {
|
|
127
|
+
state = "absent";
|
|
128
|
+
reason = "no call of the module";
|
|
129
|
+
}
|
|
130
|
+
else if (refused.length > 0) {
|
|
131
|
+
state = "refused";
|
|
132
|
+
reason = refused.map((c) => `${c.file} ${c.call}: ${"reason" in c ? c.reason : ""}`).join("; ");
|
|
133
|
+
}
|
|
134
|
+
else if (calls.some((c) => c.outcome === "moved")) {
|
|
135
|
+
state = calls.some((c) => c.outcome === "elsewhere") ? "refused" : "from";
|
|
136
|
+
if (state === "refused")
|
|
137
|
+
reason = "some calls are pinned at another version than the old one";
|
|
138
|
+
}
|
|
139
|
+
else if (calls.every((c) => c.outcome === "already")) {
|
|
140
|
+
state = "to";
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
state = "elsewhere";
|
|
144
|
+
reason = calls.map((c) => ("pin" in c ? `${c.file} ${c.call}: pinned at ${c.pin}` : "")).filter(Boolean).join("; ");
|
|
145
|
+
}
|
|
146
|
+
return { state: { root: root.root, state, files, calls, ...(reason ? { reason } : {}) }, edits: state === "from" ? edits : new Map() };
|
|
147
|
+
}
|
|
148
|
+
function parseMarker(body) {
|
|
149
|
+
const m = new RegExp(`<!-- ${MARKER} (\\{.*?\\}) -->`).exec(body);
|
|
150
|
+
if (!m)
|
|
151
|
+
return null;
|
|
152
|
+
try {
|
|
153
|
+
return JSON.parse(m[1]);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function prBody(request, wave, total, moved, refused, checkName, summary) {
|
|
160
|
+
const marker = JSON.stringify({ module: request.module, from: request.from, to: request.to, wave: wave.wave, roots: moved.map((r) => r.root) });
|
|
161
|
+
return [
|
|
162
|
+
`<!-- ${MARKER} ${marker} -->`,
|
|
163
|
+
`## Pin bump, wave ${wave.wave} of ${total}${wave.canary ? " (canaries)" : ""}`,
|
|
164
|
+
"",
|
|
165
|
+
`\`${request.module}\` pin ${request.from} -> ${request.to}`,
|
|
166
|
+
"",
|
|
167
|
+
`Roots in this wave (${moved.length}):`,
|
|
168
|
+
"",
|
|
169
|
+
...moved.map((r) => `- \`${r.root}\`: ${r.calls.filter((c) => c.outcome === "moved").map((c) => `${c.file} ${c.call}`).join(", ")}`),
|
|
170
|
+
"",
|
|
171
|
+
"This PR changes only these roots' files, so a path-diff selection plans exactly these roots.",
|
|
172
|
+
...(refused.length > 0 ? ["", "Not moved by this rollout:", "", ...refused.map((r) => `- \`${r.root}\`: ${r.reason}`)] : []),
|
|
173
|
+
...(summary ? ["", summary] : []),
|
|
174
|
+
"",
|
|
175
|
+
wave.wave < total
|
|
176
|
+
? `Wave ${wave.wave + 1} opens on a later run, once this PR has merged and each root above reports its apply check (${moved.length > 0 ? `\`${checkName(moved[0].root)}\`` : "none"}${moved.length > 1 ? " and the rest" : ""}) as passed on the merge commit.`
|
|
177
|
+
: "This is the last wave.",
|
|
178
|
+
"",
|
|
179
|
+
"Opened by the terraform lexicon's pin rollout. It never writes the default branch.",
|
|
180
|
+
].join("\n");
|
|
181
|
+
}
|
|
182
|
+
async function defaultBranch(run, repo, remote) {
|
|
183
|
+
try {
|
|
184
|
+
const ref = (await run("git", ["symbolic-ref", "--short", `refs/remotes/${remote}/HEAD`], repo)).trim();
|
|
185
|
+
return ref.startsWith(`${remote}/`) ? ref.slice(remote.length + 1) : ref;
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/** Read where a rollout stands, and open the next wave's PR when it is due and `mode` is `pull-request`. */
|
|
192
|
+
export async function runPinRollout(options) {
|
|
193
|
+
const request = { module: moduleOf(options.module), from: options.from, to: options.to };
|
|
194
|
+
checkPinRequest(request);
|
|
195
|
+
const mode = options.mode ?? "report";
|
|
196
|
+
const run = options.run ?? defaultRun;
|
|
197
|
+
const remote = options.remote ?? "origin";
|
|
198
|
+
const repo = (await run("git", ["rev-parse", "--show-toplevel"], options.cwd ?? process.cwd())).trim();
|
|
199
|
+
const checkName = (root) => (options.appliedCheck ?? "apply/{root}").replaceAll("{root}", root);
|
|
200
|
+
let hasRemote = true;
|
|
201
|
+
try {
|
|
202
|
+
await run("git", ["remote", "get-url", remote], repo);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
hasRemote = false;
|
|
206
|
+
}
|
|
207
|
+
if (mode === "pull-request" && !hasRemote)
|
|
208
|
+
throw new Error(`pin rollout: no remote "${remote}" to open a pull request on`);
|
|
209
|
+
// With no remote there is no forge to have opened a PR on, so a report reads every wave as unopened.
|
|
210
|
+
const forge = options.forge ?? (hasRemote ? ghPinForge(run, repo) : NO_FORGE);
|
|
211
|
+
if (hasRemote)
|
|
212
|
+
await run("git", ["fetch", "--quiet", remote], repo);
|
|
213
|
+
const base = options.base ?? (hasRemote ? await defaultBranch(run, repo, remote) : null) ?? (await run("git", ["symbolic-ref", "--short", "HEAD"], repo)).trim();
|
|
214
|
+
const baseRef = hasRemote ? `refs/remotes/${remote}/${base}` : `refs/heads/${base}`;
|
|
215
|
+
const worktree = mkdtempSync(join(tmpdir(), "chant-pin-"));
|
|
216
|
+
await run("git", ["worktree", "add", "--quiet", "--detach", worktree, baseRef], repo);
|
|
217
|
+
try {
|
|
218
|
+
// 1. Every root's pin on the base branch.
|
|
219
|
+
const declared = (options.roots ?? candidateRoots(worktree).map((root) => ({ root }))).map((r) => ({
|
|
220
|
+
...r,
|
|
221
|
+
root: dirOf(r.root),
|
|
222
|
+
...(r.dependsOn ? { dependsOn: r.dependsOn.map(dirOf) } : {}),
|
|
223
|
+
}));
|
|
224
|
+
const roots = [];
|
|
225
|
+
const edits = new Map();
|
|
226
|
+
const byRoot = new Map();
|
|
227
|
+
for (const root of declared) {
|
|
228
|
+
const { state, edits: e } = await editRoot(worktree, root, request, options);
|
|
229
|
+
if (options.roots || state.state !== "absent")
|
|
230
|
+
roots.push(state);
|
|
231
|
+
edits.set(root.root, e);
|
|
232
|
+
byRoot.set(root.root, root);
|
|
233
|
+
}
|
|
234
|
+
const inRollout = roots.filter((r) => r.state === "from" || r.state === "to");
|
|
235
|
+
const outside = roots.filter((r) => !(r.state === "from" || r.state === "to"));
|
|
236
|
+
// 2. The waves, over the roots at the old or the new pin.
|
|
237
|
+
const keep = new Set(inRollout.map((r) => r.root));
|
|
238
|
+
let waves;
|
|
239
|
+
if (options.waves) {
|
|
240
|
+
waves = restrictWaves(options.waves, keep);
|
|
241
|
+
const planned = new Set(waves.flatMap((w) => w.roots));
|
|
242
|
+
const missing = [...keep].filter((r) => !planned.has(r));
|
|
243
|
+
if (missing.length > 0)
|
|
244
|
+
throw new Error(`pin rollout: the wave plan does not place ${missing.join(", ")}`);
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
const withDeps = [];
|
|
248
|
+
for (const r of inRollout) {
|
|
249
|
+
const declaredRoot = byRoot.get(r.root);
|
|
250
|
+
const tg = join(worktree, r.root, "terragrunt.hcl");
|
|
251
|
+
const fromTg = !declaredRoot.tsSource && existsSync(tg) && statSync(tg).isFile() ? await terragruntDependencies(r.root, readFileSync(tg, "utf-8"), options.parser) : [];
|
|
252
|
+
withDeps.push({ ...declaredRoot, dependsOn: [...new Set([...(declaredRoot.dependsOn ?? []), ...fromTg])] });
|
|
253
|
+
}
|
|
254
|
+
// A canary that is a root but out of the rollout (refused, say) is dropped; one that is no root at all is refused by the planner.
|
|
255
|
+
const canaries = (options.canaries ?? []).map(dirOf);
|
|
256
|
+
waves = planPinWaves(withDeps, canaries.filter((c) => keep.has(c) || !roots.some((r) => r.root === c)));
|
|
257
|
+
}
|
|
258
|
+
// 3. Walk the waves.
|
|
259
|
+
const statuses = waves.map((w) => ({ ...w, branch: pinWaveBranch(request, w.wave), state: "not-reached" }));
|
|
260
|
+
let status = "complete";
|
|
261
|
+
let stop;
|
|
262
|
+
for (const wave of statuses) {
|
|
263
|
+
if (wave.branch === base)
|
|
264
|
+
throw new Error(`pin rollout will not write the base branch "${base}"`);
|
|
265
|
+
const pr = await forge.findPullRequest(wave.branch);
|
|
266
|
+
if (pr)
|
|
267
|
+
wave.pr = pr.url;
|
|
268
|
+
if (pr?.state === "open") {
|
|
269
|
+
wave.state = "open";
|
|
270
|
+
status = "waiting";
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
if (pr?.state === "closed") {
|
|
274
|
+
wave.state = "closed";
|
|
275
|
+
status = "stopped";
|
|
276
|
+
stop = `wave ${wave.wave}'s PR ${pr.url} was closed without merging`;
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
if (pr?.state === "merged") {
|
|
280
|
+
const moved = parseMarker(pr.body)?.roots ?? wave.roots;
|
|
281
|
+
const checks = pr.mergeCommit ? await forge.commitChecks(pr.mergeCommit) : [];
|
|
282
|
+
const stateOf = (root) => checks.find((c) => c.name === checkName(root))?.state ?? "pending";
|
|
283
|
+
wave.failed = moved.filter((r) => stateOf(r) === "failure");
|
|
284
|
+
wave.pending = moved.filter((r) => stateOf(r) === "pending");
|
|
285
|
+
if (wave.failed.length > 0) {
|
|
286
|
+
wave.state = "failed";
|
|
287
|
+
status = "stopped";
|
|
288
|
+
stop = `wave ${wave.wave}: ${wave.failed.map((r) => `${r} failed its apply check ${checkName(r)}`).join(", ")}`;
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
if (wave.pending.length > 0) {
|
|
292
|
+
wave.state = "waiting-apply";
|
|
293
|
+
status = "waiting";
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
wave.state = "applied";
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
// No PR yet: this wave is next.
|
|
300
|
+
const moving = roots.filter((r) => wave.roots.includes(r.root) && r.state === "from");
|
|
301
|
+
if (moving.length === 0) {
|
|
302
|
+
wave.state = "nothing-to-move";
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
const files = moving.flatMap((r) => [...edits.get(r.root).keys()]).sort();
|
|
306
|
+
wave.files = files;
|
|
307
|
+
if (mode === "report") {
|
|
308
|
+
wave.state = "would-open";
|
|
309
|
+
status = "would-open";
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
for (const r of moving)
|
|
313
|
+
for (const [file, content] of edits.get(r.root))
|
|
314
|
+
writeFileSync(join(worktree, file), content);
|
|
315
|
+
const title = `chore(pin): ${request.module} ${request.from} -> ${request.to}, wave ${wave.wave} of ${statuses.length}`;
|
|
316
|
+
const summary = options.planSummary ? await options.planSummary(wave) : undefined;
|
|
317
|
+
const body = prBody(request, wave, statuses.length, moving, outside, checkName, summary);
|
|
318
|
+
await run("git", ["add", "--", ...files], worktree);
|
|
319
|
+
const identity = [];
|
|
320
|
+
const has = async (key) => {
|
|
321
|
+
try {
|
|
322
|
+
return (await run("git", ["config", key], worktree)).trim().length > 0;
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
if (!(await has("user.name")))
|
|
329
|
+
identity.push("-c", "user.name=chant");
|
|
330
|
+
if (!(await has("user.email")))
|
|
331
|
+
identity.push("-c", "user.email=chant@localhost");
|
|
332
|
+
await run("git", [...identity, "commit", "--quiet", "--no-verify", "-m", `${title}\n\nRoots: ${moving.map((r) => r.root).join(", ")}\n`], worktree);
|
|
333
|
+
// The branch is rebuilt from the base branch on every run that opens it, so it is replaced.
|
|
334
|
+
await run("git", ["push", "--quiet", "--force", remote, `HEAD:refs/heads/${wave.branch}`], worktree);
|
|
335
|
+
wave.pr = await forge.createPullRequest({ base, head: wave.branch, title, body });
|
|
336
|
+
wave.state = "opened";
|
|
337
|
+
status = "opened";
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
if (statuses.length === 0)
|
|
341
|
+
status = "complete";
|
|
342
|
+
const result = { ...request, mode, base, status, roots, waves: statuses, ...(stop ? { stop } : {}), summary: "" };
|
|
343
|
+
result.summary = renderPinRollout(result);
|
|
344
|
+
return result;
|
|
345
|
+
}
|
|
346
|
+
finally {
|
|
347
|
+
try {
|
|
348
|
+
await run("git", ["worktree", "remove", "--force", worktree], repo);
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
rmSync(worktree, { recursive: true, force: true });
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const WAVE_TEXT = {
|
|
356
|
+
applied: "merged and applied",
|
|
357
|
+
"nothing-to-move": "nothing to move",
|
|
358
|
+
opened: "PR opened",
|
|
359
|
+
"would-open": "next; would open its PR",
|
|
360
|
+
open: "PR open, waiting for merge",
|
|
361
|
+
"waiting-apply": "merged, waiting for apply",
|
|
362
|
+
failed: "merged, apply failed",
|
|
363
|
+
closed: "PR closed without merging",
|
|
364
|
+
"not-reached": "not opened",
|
|
365
|
+
};
|
|
366
|
+
/** The rollout for a terminal or a run log. */
|
|
367
|
+
export function renderPinRollout(result) {
|
|
368
|
+
const lines = [`${result.module} pin ${result.from} -> ${result.to} on ${result.base}: ${result.status}${result.stop ? ` (${result.stop})` : ""}`];
|
|
369
|
+
for (const w of result.waves) {
|
|
370
|
+
lines.push(` wave ${w.wave}${w.canary ? " (canaries)" : ""}: ${WAVE_TEXT[w.state]}${w.pr ? ` ${w.pr}` : ""}`);
|
|
371
|
+
lines.push(` roots: ${w.roots.join(", ")}`);
|
|
372
|
+
if (w.pending?.length)
|
|
373
|
+
lines.push(` apply pending: ${w.pending.join(", ")}`);
|
|
374
|
+
if (w.failed?.length)
|
|
375
|
+
lines.push(` apply failed: ${w.failed.join(", ")}`);
|
|
376
|
+
if (w.files?.length)
|
|
377
|
+
lines.push(` files: ${w.files.join(", ")}`);
|
|
378
|
+
}
|
|
379
|
+
for (const r of result.roots.filter((r) => r.state !== "from" && r.state !== "to"))
|
|
380
|
+
lines.push(` not in the rollout: ${r.root}: ${r.reason ?? r.state}`);
|
|
381
|
+
return lines.join("\n");
|
|
382
|
+
}
|
package/dist/pin/scan.js
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Byte offsets for the attribute values the pin edit rewrites (#3189).
|
|
3
|
+
*
|
|
4
|
+
* `@cdktn/hcl2json` is the reader that says what a file means, and the pin
|
|
5
|
+
* edit asks it first (`./edit.ts`). It gives back values and no positions
|
|
6
|
+
* (`../hcl/value.ts` says why), so it cannot say WHERE in the file a
|
|
7
|
+
* `source` or `version` literal sits. This lexer answers only that: it walks
|
|
8
|
+
* the raw text token by token, skipping comments, quoted strings, template
|
|
9
|
+
* interpolations and heredocs, counts braces, and records each top-level
|
|
10
|
+
* block and the plain string literal of each of its own attributes, with the
|
|
11
|
+
* literal's offsets.
|
|
12
|
+
*
|
|
13
|
+
* It is an HCL tokenizer, not a pattern match over lines: a `source =` inside
|
|
14
|
+
* a comment, a heredoc or another string is never a match, and a block opened
|
|
15
|
+
* on the same line as its attributes reads the same as one split over lines.
|
|
16
|
+
* What it finds is checked against the reader before anything is written:
|
|
17
|
+
* the literal's unescaped value has to equal what hcl2json read for that
|
|
18
|
+
* attribute, and the edited file has to parse to the same tree with only the
|
|
19
|
+
* pinned values changed.
|
|
20
|
+
*/
|
|
21
|
+
const IDENT_START = /[A-Za-z_]/;
|
|
22
|
+
const IDENT_PART = /[A-Za-z0-9_-]/;
|
|
23
|
+
/** Thrown when the text is not well-formed enough to place an edit in. */
|
|
24
|
+
export class HclScanError extends Error {
|
|
25
|
+
constructor(file, message) {
|
|
26
|
+
super(`${file}: ${message}`);
|
|
27
|
+
this.name = "HclScanError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Skip a quoted string starting at `i` (the opening quote). Returns the index
|
|
32
|
+
* just past the closing quote, and whether a `${` or `%{` template appears.
|
|
33
|
+
*/
|
|
34
|
+
function skipString(text, i, file) {
|
|
35
|
+
let j = i + 1;
|
|
36
|
+
let template = false;
|
|
37
|
+
while (j < text.length) {
|
|
38
|
+
const c = text[j];
|
|
39
|
+
if (c === "\\") {
|
|
40
|
+
j += 2;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (c === '"')
|
|
44
|
+
return { end: j + 1, template };
|
|
45
|
+
if (c === "\n")
|
|
46
|
+
throw new HclScanError(file, `unterminated string at offset ${i}`);
|
|
47
|
+
if ((c === "$" || c === "%") && text[j + 1] === "{") {
|
|
48
|
+
// `$${` and `%%{` are escapes for a literal `${` and `%{`.
|
|
49
|
+
if (text[j - 1] === c) {
|
|
50
|
+
j += 2;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
template = true;
|
|
54
|
+
j = skipTemplate(text, j + 2, file);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
j++;
|
|
58
|
+
}
|
|
59
|
+
throw new HclScanError(file, `unterminated string at offset ${i}`);
|
|
60
|
+
}
|
|
61
|
+
/** Skip a template interpolation body starting just inside `${`. Returns the index past its `}`. */
|
|
62
|
+
function skipTemplate(text, i, file) {
|
|
63
|
+
let depth = 1;
|
|
64
|
+
let j = i;
|
|
65
|
+
while (j < text.length) {
|
|
66
|
+
const c = text[j];
|
|
67
|
+
if (c === '"') {
|
|
68
|
+
j = skipString(text, j, file).end;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (c === "{")
|
|
72
|
+
depth++;
|
|
73
|
+
else if (c === "}") {
|
|
74
|
+
depth--;
|
|
75
|
+
if (depth === 0)
|
|
76
|
+
return j + 1;
|
|
77
|
+
}
|
|
78
|
+
j++;
|
|
79
|
+
}
|
|
80
|
+
throw new HclScanError(file, `unterminated template at offset ${i}`);
|
|
81
|
+
}
|
|
82
|
+
function tokenize(text, file) {
|
|
83
|
+
const tokens = [];
|
|
84
|
+
let i = 0;
|
|
85
|
+
while (i < text.length) {
|
|
86
|
+
const c = text[i];
|
|
87
|
+
if (c === "\n") {
|
|
88
|
+
tokens.push({ kind: "newline", start: i, end: i + 1 });
|
|
89
|
+
i++;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (c === " " || c === "\t" || c === "\r") {
|
|
93
|
+
i++;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (c === "#" || (c === "/" && text[i + 1] === "/")) {
|
|
97
|
+
while (i < text.length && text[i] !== "\n")
|
|
98
|
+
i++;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (c === "/" && text[i + 1] === "*") {
|
|
102
|
+
const close = text.indexOf("*/", i + 2);
|
|
103
|
+
if (close < 0)
|
|
104
|
+
throw new HclScanError(file, `unterminated comment at offset ${i}`);
|
|
105
|
+
// A block comment spanning lines still ends a statement where a newline would.
|
|
106
|
+
if (text.slice(i, close).includes("\n"))
|
|
107
|
+
tokens.push({ kind: "newline", start: i, end: close + 2 });
|
|
108
|
+
i = close + 2;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (c === '"') {
|
|
112
|
+
const { end, template } = skipString(text, i, file);
|
|
113
|
+
tokens.push({ kind: "string", start: i, end, raw: text.slice(i + 1, end - 1), template });
|
|
114
|
+
i = end;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (c === "<" && text[i + 1] === "<" && /[-A-Za-z_]/.test(text[i + 2] ?? "")) {
|
|
118
|
+
const m = /^<<-?([A-Za-z_][A-Za-z0-9_-]*)[ \t]*\r?\n/.exec(text.slice(i));
|
|
119
|
+
if (m) {
|
|
120
|
+
const marker = m[1];
|
|
121
|
+
let j = i + m[0].length;
|
|
122
|
+
for (;;) {
|
|
123
|
+
const lineEnd = text.indexOf("\n", j);
|
|
124
|
+
const line = text.slice(j, lineEnd < 0 ? text.length : lineEnd);
|
|
125
|
+
if (line.trim() === marker) {
|
|
126
|
+
j = lineEnd < 0 ? text.length : lineEnd;
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
if (lineEnd < 0)
|
|
130
|
+
throw new HclScanError(file, `unterminated heredoc ${marker} at offset ${i}`);
|
|
131
|
+
j = lineEnd + 1;
|
|
132
|
+
}
|
|
133
|
+
tokens.push({ kind: "other", start: i, end: j });
|
|
134
|
+
i = j;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (IDENT_START.test(c)) {
|
|
139
|
+
let j = i + 1;
|
|
140
|
+
while (j < text.length && IDENT_PART.test(text[j]))
|
|
141
|
+
j++;
|
|
142
|
+
tokens.push({ kind: "ident", value: text.slice(i, j), start: i, end: j });
|
|
143
|
+
i = j;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const two = text.slice(i, i + 2);
|
|
147
|
+
if (two === "==" || two === "!=" || two === "<=" || two === ">=" || two === "=>" || two === "&&" || two === "||") {
|
|
148
|
+
tokens.push({ kind: "punct", value: two, start: i, end: i + 2 });
|
|
149
|
+
i += 2;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if ("{}[]()=,.:?".includes(c)) {
|
|
153
|
+
tokens.push({ kind: "punct", value: c, start: i, end: i + 1 });
|
|
154
|
+
i++;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
tokens.push({ kind: "other", start: i, end: i + 1 });
|
|
158
|
+
i++;
|
|
159
|
+
}
|
|
160
|
+
return tokens;
|
|
161
|
+
}
|
|
162
|
+
const OPEN = new Set(["{", "[", "("]);
|
|
163
|
+
const CLOSE = new Set(["}", "]", ")"]);
|
|
164
|
+
/** Every top-level block in `text`, with its own attributes' literals. */
|
|
165
|
+
export function scanBlocks(text, file) {
|
|
166
|
+
const tokens = tokenize(text, file);
|
|
167
|
+
const blocks = [];
|
|
168
|
+
let i = 0;
|
|
169
|
+
const atStatementStart = (k) => k === 0 || tokens[k - 1].kind === "newline" || (tokens[k - 1].kind === "punct" && tokens[k - 1].value === "{");
|
|
170
|
+
while (i < tokens.length) {
|
|
171
|
+
const t = tokens[i];
|
|
172
|
+
if (t.kind !== "ident" || !atStatementStart(i)) {
|
|
173
|
+
i = skipStatement(tokens, i);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
// A block header: a type, then string or identifier labels, then `{`.
|
|
177
|
+
const labels = [];
|
|
178
|
+
let j = i + 1;
|
|
179
|
+
while (j < tokens.length) {
|
|
180
|
+
const l = tokens[j];
|
|
181
|
+
if (l.kind === "string" && !l.template)
|
|
182
|
+
labels.push(l.raw);
|
|
183
|
+
else if (l.kind === "ident")
|
|
184
|
+
labels.push(l.value);
|
|
185
|
+
else
|
|
186
|
+
break;
|
|
187
|
+
j++;
|
|
188
|
+
}
|
|
189
|
+
const brace = tokens[j];
|
|
190
|
+
if (!brace || brace.kind !== "punct" || brace.value !== "{") {
|
|
191
|
+
i = skipStatement(tokens, i);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const { end, attributes } = readBody(tokens, j, file);
|
|
195
|
+
blocks.push({ type: t.value, labels, start: t.start, end: tokens[end].end, attributes });
|
|
196
|
+
i = end + 1;
|
|
197
|
+
}
|
|
198
|
+
return blocks;
|
|
199
|
+
}
|
|
200
|
+
/** Skip to the token after the end of the statement starting at `i`: the next newline at bracket depth 0. */
|
|
201
|
+
function skipStatement(tokens, i) {
|
|
202
|
+
let depth = 0;
|
|
203
|
+
let j = i;
|
|
204
|
+
while (j < tokens.length) {
|
|
205
|
+
const t = tokens[j];
|
|
206
|
+
if (t.kind === "punct" && OPEN.has(t.value))
|
|
207
|
+
depth++;
|
|
208
|
+
else if (t.kind === "punct" && CLOSE.has(t.value))
|
|
209
|
+
depth = Math.max(0, depth - 1);
|
|
210
|
+
else if (t.kind === "newline" && depth === 0)
|
|
211
|
+
return j + 1;
|
|
212
|
+
j++;
|
|
213
|
+
}
|
|
214
|
+
return j;
|
|
215
|
+
}
|
|
216
|
+
/** Read a block body whose `{` is at `open`. Returns the index of the matching `}` and the body's own attributes. */
|
|
217
|
+
function readBody(tokens, open, file) {
|
|
218
|
+
const attributes = [];
|
|
219
|
+
let depth = 1;
|
|
220
|
+
let j = open + 1;
|
|
221
|
+
let statementStart = true;
|
|
222
|
+
while (j < tokens.length) {
|
|
223
|
+
const t = tokens[j];
|
|
224
|
+
if (t.kind === "newline") {
|
|
225
|
+
if (depth === 1)
|
|
226
|
+
statementStart = true;
|
|
227
|
+
j++;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (depth === 1 && statementStart && t.kind === "ident") {
|
|
231
|
+
const eq = tokens[j + 1];
|
|
232
|
+
if (eq && eq.kind === "punct" && eq.value === "=") {
|
|
233
|
+
const value = tokens[j + 2];
|
|
234
|
+
const after = tokens[j + 3];
|
|
235
|
+
const ends = !after || after.kind === "newline" || (after.kind === "punct" && after.value === "}");
|
|
236
|
+
const literal = value && value.kind === "string" && !value.template && ends ? { start: value.start + 1, end: value.end - 1, raw: value.raw } : undefined;
|
|
237
|
+
attributes.push({ name: t.value, ...(literal ? { literal } : {}) });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
statementStart = false;
|
|
241
|
+
if (t.kind === "punct" && OPEN.has(t.value))
|
|
242
|
+
depth++;
|
|
243
|
+
else if (t.kind === "punct" && CLOSE.has(t.value)) {
|
|
244
|
+
depth--;
|
|
245
|
+
if (depth === 0)
|
|
246
|
+
return { end: j, attributes };
|
|
247
|
+
}
|
|
248
|
+
j++;
|
|
249
|
+
}
|
|
250
|
+
throw new HclScanError(file, `unbalanced braces: a block opened at offset ${tokens[open].start} never closes`);
|
|
251
|
+
}
|
|
252
|
+
/** Undo HCL's string escapes, so a literal's raw text compares to the value the reader returned. */
|
|
253
|
+
export function unescapeHcl(raw) {
|
|
254
|
+
let out = "";
|
|
255
|
+
for (let i = 0; i < raw.length; i++) {
|
|
256
|
+
const c = raw[i];
|
|
257
|
+
if (c === "\\") {
|
|
258
|
+
const n = raw[i + 1];
|
|
259
|
+
i++;
|
|
260
|
+
if (n === "n")
|
|
261
|
+
out += "\n";
|
|
262
|
+
else if (n === "t")
|
|
263
|
+
out += "\t";
|
|
264
|
+
else if (n === "r")
|
|
265
|
+
out += "\r";
|
|
266
|
+
else if (n === "u" || n === "U") {
|
|
267
|
+
const len = n === "u" ? 4 : 8;
|
|
268
|
+
out += String.fromCodePoint(parseInt(raw.slice(i + 1, i + 1 + len), 16));
|
|
269
|
+
i += len;
|
|
270
|
+
}
|
|
271
|
+
else
|
|
272
|
+
out += n ?? "";
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if ((c === "$" || c === "%") && raw[i + 1] === c && raw[i + 2] === "{") {
|
|
276
|
+
out += `${c}{`;
|
|
277
|
+
i += 2;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
out += c;
|
|
281
|
+
}
|
|
282
|
+
return out;
|
|
283
|
+
}
|
|
284
|
+
/** Write a value as the content of an HCL string literal. */
|
|
285
|
+
export function escapeHcl(value) {
|
|
286
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\$\{/g, "$${").replace(/%\{/g, "%%{");
|
|
287
|
+
}
|