@intentius/chant-lexicon-pulumi 0.124.54 → 0.124.56

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.
Files changed (163) hide show
  1. package/README.md +42 -3
  2. package/dist/adopt.d.ts +68 -0
  3. package/dist/adopt.d.ts.map +1 -0
  4. package/dist/adopt.js +162 -0
  5. package/dist/capability-plugin.d.ts +3 -0
  6. package/dist/capability-plugin.d.ts.map +1 -1
  7. package/dist/capability-plugin.js +8 -1
  8. package/dist/change-set.d.ts +27 -4
  9. package/dist/change-set.d.ts.map +1 -1
  10. package/dist/change-set.js +15 -5
  11. package/dist/check-step.d.ts +61 -0
  12. package/dist/check-step.d.ts.map +1 -0
  13. package/dist/check-step.js +270 -0
  14. package/dist/ci-setup.d.ts +53 -0
  15. package/dist/ci-setup.d.ts.map +1 -0
  16. package/dist/ci-setup.js +146 -0
  17. package/dist/composites/pulumi-adopt-op.d.ts +106 -0
  18. package/dist/composites/pulumi-adopt-op.d.ts.map +1 -0
  19. package/dist/composites/pulumi-adopt-op.js +132 -0
  20. package/dist/engine-events.d.ts +222 -0
  21. package/dist/engine-events.d.ts.map +1 -0
  22. package/dist/engine-events.js +280 -0
  23. package/dist/exit-codes.d.ts +53 -0
  24. package/dist/exit-codes.d.ts.map +1 -0
  25. package/dist/exit-codes.js +67 -0
  26. package/dist/index.d.ts +13 -2
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +17 -2
  29. package/dist/init-templates.d.ts.map +1 -1
  30. package/dist/init-templates.js +5 -3
  31. package/dist/integrity.json +3 -3
  32. package/dist/lint/post-synth/pul015.d.ts +2 -1
  33. package/dist/lint/post-synth/pul015.d.ts.map +1 -1
  34. package/dist/lock-rows.d.ts +32 -0
  35. package/dist/lock-rows.d.ts.map +1 -0
  36. package/dist/lock-rows.js +36 -0
  37. package/dist/manifest.json +1 -1
  38. package/dist/member-engine.d.ts +27 -2
  39. package/dist/member-engine.d.ts.map +1 -1
  40. package/dist/member-engine.js +58 -7
  41. package/dist/member-outputs.d.ts +10 -3
  42. package/dist/member-outputs.d.ts.map +1 -1
  43. package/dist/member-outputs.js +15 -7
  44. package/dist/op/activities/index.d.ts +9 -1
  45. package/dist/op/activities/index.d.ts.map +1 -1
  46. package/dist/op/activities/index.js +8 -1
  47. package/dist/op/activities/pulumi-adopt.d.ts +104 -0
  48. package/dist/op/activities/pulumi-adopt.d.ts.map +1 -0
  49. package/dist/op/activities/pulumi-adopt.js +216 -0
  50. package/dist/op/activities/pulumi.d.ts.map +1 -1
  51. package/dist/op/activities/pulumi.js +2 -1
  52. package/dist/op/activity-contracts.d.ts +73 -0
  53. package/dist/op/activity-contracts.d.ts.map +1 -1
  54. package/dist/op/activity-contracts.js +49 -0
  55. package/dist/op/builders.d.ts +9 -0
  56. package/dist/op/builders.d.ts.map +1 -1
  57. package/dist/op/builders.js +20 -0
  58. package/dist/plan-shape.d.ts +47 -0
  59. package/dist/plan-shape.d.ts.map +1 -0
  60. package/dist/plan-shape.js +94 -0
  61. package/dist/planned-output-changes.d.ts +60 -0
  62. package/dist/planned-output-changes.d.ts.map +1 -0
  63. package/dist/planned-output-changes.js +94 -0
  64. package/dist/plugin.d.ts.map +1 -1
  65. package/dist/plugin.js +38 -4
  66. package/dist/plunge-timings.d.ts +43 -0
  67. package/dist/plunge-timings.d.ts.map +1 -0
  68. package/dist/plunge-timings.js +106 -0
  69. package/dist/preview-tips.d.ts +71 -0
  70. package/dist/preview-tips.d.ts.map +1 -0
  71. package/dist/preview-tips.js +76 -0
  72. package/dist/pulumi-up.d.ts +384 -0
  73. package/dist/pulumi-up.d.ts.map +1 -0
  74. package/dist/pulumi-up.js +836 -0
  75. package/dist/release-copy.d.ts +47 -0
  76. package/dist/release-copy.d.ts.map +1 -0
  77. package/dist/release-copy.js +131 -0
  78. package/dist/review-stack.d.ts +92 -0
  79. package/dist/review-stack.d.ts.map +1 -0
  80. package/dist/review-stack.js +222 -0
  81. package/dist/rules/pul015.ts +2 -1
  82. package/dist/run.d.ts +269 -7
  83. package/dist/run.d.ts.map +1 -1
  84. package/dist/run.js +473 -26
  85. package/dist/stack-export-snapshot.d.ts +125 -0
  86. package/dist/stack-export-snapshot.d.ts.map +1 -0
  87. package/dist/stack-export-snapshot.js +233 -0
  88. package/dist/stack-ops.d.ts +175 -0
  89. package/dist/stack-ops.d.ts.map +1 -0
  90. package/dist/stack-ops.js +400 -0
  91. package/dist/stack-references.d.ts +46 -0
  92. package/dist/stack-references.d.ts.map +1 -0
  93. package/dist/stack-references.js +88 -0
  94. package/dist/trace-facts.d.ts +34 -0
  95. package/dist/trace-facts.d.ts.map +1 -0
  96. package/dist/trace-facts.js +81 -0
  97. package/dist/workspace-member.d.ts +13 -2
  98. package/dist/workspace-member.d.ts.map +1 -1
  99. package/dist/workspace-member.js +14 -9
  100. package/package.json +2 -2
  101. package/src/adopt.ts +202 -0
  102. package/src/capability-plugin.ts +15 -6
  103. package/src/change-set.ts +29 -5
  104. package/src/check-step.test.ts +191 -0
  105. package/src/check-step.ts +316 -0
  106. package/src/ci-setup.test.ts +123 -0
  107. package/src/ci-setup.ts +176 -0
  108. package/src/composites/pulumi-adopt-op.test.ts +223 -0
  109. package/src/composites/pulumi-adopt-op.ts +191 -0
  110. package/src/composites/pulumi-watch-op.test.ts +2 -2
  111. package/src/engine-events.test.ts +134 -0
  112. package/src/engine-events.ts +390 -0
  113. package/src/engine-graph.test.ts +1 -1
  114. package/src/exit-codes.test.ts +21 -0
  115. package/src/exit-codes.ts +71 -0
  116. package/src/index.ts +77 -1
  117. package/src/init-templates.ts +5 -3
  118. package/src/lint/post-synth/pul015.ts +2 -1
  119. package/src/lock-rows.test.ts +60 -0
  120. package/src/lock-rows.ts +56 -0
  121. package/src/member-engine.test.ts +112 -0
  122. package/src/member-engine.ts +73 -6
  123. package/src/member-outputs.test.ts +30 -8
  124. package/src/member-outputs.ts +31 -12
  125. package/src/op/activities/index.ts +19 -1
  126. package/src/op/activities/pulumi-adopt.ts +334 -0
  127. package/src/op/activities/pulumi.ts +2 -1
  128. package/src/op/activity-contracts.ts +75 -0
  129. package/src/op/builders.ts +37 -0
  130. package/src/plan-shape.test.ts +91 -0
  131. package/src/plan-shape.ts +105 -0
  132. package/src/planned-output-changes.test.ts +85 -0
  133. package/src/planned-output-changes.ts +141 -0
  134. package/src/plugin.test.ts +1 -1
  135. package/src/plugin.ts +42 -4
  136. package/src/plunge-timings.test.ts +241 -0
  137. package/src/plunge-timings.ts +115 -0
  138. package/src/plunge-up-failed.test.ts +54 -0
  139. package/src/pr-note.test.ts +87 -0
  140. package/src/preview-tips.test.ts +135 -0
  141. package/src/preview-tips.ts +128 -0
  142. package/src/pulumi-up.test.ts +470 -0
  143. package/src/pulumi-up.ts +1053 -0
  144. package/src/release-copy.test.ts +74 -0
  145. package/src/release-copy.ts +142 -0
  146. package/src/review-stack.test.ts +254 -0
  147. package/src/review-stack.ts +266 -0
  148. package/src/run-plan-flags.test.ts +53 -0
  149. package/src/run.test.ts +176 -3
  150. package/src/run.ts +544 -30
  151. package/src/stack-export-snapshot.test.ts +190 -0
  152. package/src/stack-export-snapshot.ts +312 -0
  153. package/src/stack-ops.test.ts +286 -0
  154. package/src/stack-ops.ts +552 -0
  155. package/src/stack-references.test.ts +96 -0
  156. package/src/stack-references.ts +110 -0
  157. package/src/testdata/pulumi-stream/README.md +16 -0
  158. package/src/testdata/pulumi-stream/preview-stream.ndjson +42 -0
  159. package/src/testdata/pulumi-stream/up-failed-plunge.ndjson +5 -0
  160. package/src/trace-facts.test.ts +38 -0
  161. package/src/trace-facts.ts +83 -0
  162. package/src/workspace-member.test.ts +2 -1
  163. package/src/workspace-member.ts +30 -12
@@ -0,0 +1,270 @@
1
+ /**
2
+ * The pulumi lexicon's steps in chant's check stage (#4633). `chant ci
3
+ * check` runs them after the `ci.hooks.check` hooks, for each workspace
4
+ * member of kind `pulumi` (`LexiconPlugin.checkSteps`,
5
+ * `@intentius/chant/components/check-steps`), so a product's check
6
+ * (`plywood check`) gets them through chant with nothing of its own:
7
+ *
8
+ * - `pulumi-install`: `pulumi install` (`plunge install` for a member on
9
+ * plunge, `./member-engine.ts`) in the member's directory, which fetches
10
+ * the program's dependencies and plugins the way `Pulumi.yaml` says;
11
+ * - `stack-config`: each stack's `Pulumi.<stack>.yaml` checked against the
12
+ * project's config schema in `Pulumi.yaml`, with no command run (a key
13
+ * with a type and no default must be set, a value must read as its type,
14
+ * and a `secret: true` key must be encrypted). The stacks are the ones the
15
+ * project lists by their files, plus the member's declared stack;
16
+ * - `pulumi-about`: `pulumi about --json`, which reports the engine, its
17
+ * plugins and the backend for the report.
18
+ *
19
+ * The argv go through `runCheckPulumi` (`./run.ts`), which refuses any
20
+ * other. The environment is the check job's ({@link CheckStepsContext.env}):
21
+ * no forge token, no OIDC request variables, no cloud credential, so neither
22
+ * command can write a stack. A failure names the member and stack, and the
23
+ * check exits with chant's `failed` code.
24
+ *
25
+ * The runner is injectable, so the tests run no binary.
26
+ */
27
+ import { execFile } from "node:child_process";
28
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
29
+ import { join } from "node:path";
30
+ import { promisify } from "node:util";
31
+ import { parseYAML } from "@intentius/chant/yaml";
32
+ import { pulumiMemberEngine } from "./member-engine.js";
33
+ import { engineEnv, pulumiRunFailure, runCheckPulumi } from "./run.js";
34
+ const execFileAsync = promisify(execFile);
35
+ /** The lexicon name the report carries. */
36
+ const LEXICON = "pulumi";
37
+ /** A runner for `bin` with exactly `env` (the check job's), never the process's whole environment. */
38
+ export function checkRunner(bin, env) {
39
+ return (args, opts) => execFileAsync(bin, args, { cwd: opts.cwd, maxBuffer: 64 * 1024 * 1024, env: engineEnv({ ...env, ...opts.env }) });
40
+ }
41
+ const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
42
+ /** The key in its full form: a bare name belongs to the project. */
43
+ function fullKey(project, key) {
44
+ return key.includes(":") ? key : `${project}:${key}`;
45
+ }
46
+ /** Read `Pulumi.yaml`'s schema from its parsed document. A short-form entry (`key: value`) is a value with no type. */
47
+ export function pulumiProjectSchema(doc) {
48
+ const d = isRecord(doc) ? doc : {};
49
+ const project = typeof d.name === "string" ? d.name : "";
50
+ const config = isRecord(d.config) ? d.config : {};
51
+ const keys = [];
52
+ for (const [name, entry] of Object.entries(config)) {
53
+ const key = fullKey(project, name);
54
+ const schema = isRecord(entry) && ("type" in entry || "default" in entry || "value" in entry || "secret" in entry || "description" in entry || "items" in entry);
55
+ if (!schema) {
56
+ keys.push({ key, secret: false, hasDefault: true });
57
+ continue;
58
+ }
59
+ const e = entry;
60
+ keys.push({
61
+ key,
62
+ ...(typeof e.type === "string" ? { type: e.type } : {}),
63
+ ...(isRecord(e.items) && typeof e.items.type === "string" ? { items: { type: e.items.type } } : {}),
64
+ secret: e.secret === true,
65
+ hasDefault: "default" in e || "value" in e,
66
+ });
67
+ }
68
+ return { project, keys, stackConfigDir: typeof d.stackConfigDir === "string" && d.stackConfigDir.length > 0 ? d.stackConfigDir : "." };
69
+ }
70
+ const isSecure = (v) => isRecord(v) && typeof v.secure === "string";
71
+ /** Whether a scalar stack value reads as `type`. Values are strings once Pulumi reads them, so `"3"` is an integer. */
72
+ function readsAs(type, value) {
73
+ switch (type) {
74
+ case "string":
75
+ return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
76
+ case "integer":
77
+ return (typeof value === "number" && Number.isInteger(value)) || (typeof value === "string" && /^-?[0-9]+$/.test(value.trim()));
78
+ case "boolean":
79
+ return typeof value === "boolean" || value === "true" || value === "false";
80
+ case "array":
81
+ return Array.isArray(value);
82
+ default:
83
+ return true;
84
+ }
85
+ }
86
+ /**
87
+ * What is wrong with one stack's config (`config:` of its
88
+ * `Pulumi.<stack>.yaml`, parsed) against the project's schema, as lines
89
+ * naming the stack and key. Empty when it passes. Keys of other namespaces
90
+ * (a provider's `aws:region`) are not checked: the project declares no type
91
+ * for them.
92
+ */
93
+ export function pulumiStackConfigProblems(schema, stack, stackDoc) {
94
+ const raw = isRecord(stackDoc) && isRecord(stackDoc.config) ? stackDoc.config : {};
95
+ const values = new Map();
96
+ for (const [k, v] of Object.entries(raw))
97
+ values.set(fullKey(schema.project, k), v);
98
+ const problems = [];
99
+ for (const k of schema.keys) {
100
+ if (!k.type && !k.secret)
101
+ continue;
102
+ if (!values.has(k.key)) {
103
+ if (k.type && !k.hasDefault)
104
+ problems.push(`stack ${stack} is missing the configuration value ${k.key} (type ${k.type}, no default)`);
105
+ continue;
106
+ }
107
+ const value = values.get(k.key);
108
+ if (k.secret && !isSecure(value)) {
109
+ problems.push(`stack ${stack} sets ${k.key} in plain text, and the project declares it secret; set it with pulumi config set --secret`);
110
+ continue;
111
+ }
112
+ if (isSecure(value) || !k.type)
113
+ continue;
114
+ if (!readsAs(k.type, value)) {
115
+ problems.push(`stack ${stack} sets ${k.key} to ${JSON.stringify(value)}, which is not of type ${k.type}`);
116
+ continue;
117
+ }
118
+ if (k.type === "array" && k.items?.type && Array.isArray(value)) {
119
+ const bad = value.findIndex((item) => !isSecure(item) && !readsAs(k.items.type, item));
120
+ if (bad >= 0)
121
+ problems.push(`stack ${stack} sets ${k.key}[${bad}] to ${JSON.stringify(value[bad])}, which is not of type ${k.items.type}`);
122
+ }
123
+ }
124
+ return problems;
125
+ }
126
+ /** The project's file in `dir`, `Pulumi.yaml` or `Pulumi.yml`. */
127
+ function projectFile(dir) {
128
+ return ["Pulumi.yaml", "Pulumi.yml"].map((f) => join(dir, f)).find((f) => existsSync(f));
129
+ }
130
+ /** The stacks the project lists by their `Pulumi.<stack>.yaml` files in `dir`, sorted. */
131
+ export function listedStacks(dir) {
132
+ if (!existsSync(dir))
133
+ return [];
134
+ return readdirSync(dir)
135
+ .map((f) => /^Pulumi\.(.+)\.ya?ml$/.exec(f)?.[1])
136
+ .filter((s) => s !== undefined)
137
+ .sort();
138
+ }
139
+ /** The stack file for `stack` in `dir`, if there is one. */
140
+ function stackFile(dir, stack) {
141
+ return [`Pulumi.${stack}.yaml`, `Pulumi.${stack}.yml`].map((f) => join(dir, f)).find((f) => existsSync(f));
142
+ }
143
+ /** Read `pulumi about --json`'s document. Undefined when it is not JSON. */
144
+ export function pulumiAboutDetail(engine, stdout) {
145
+ if (!stdout)
146
+ return undefined;
147
+ let doc;
148
+ try {
149
+ doc = JSON.parse(stdout);
150
+ }
151
+ catch {
152
+ return undefined;
153
+ }
154
+ if (!isRecord(doc))
155
+ return undefined;
156
+ const cli = isRecord(doc.cli) ? doc.cli : {};
157
+ const plugins = Array.isArray(doc.plugins)
158
+ ? doc.plugins.filter(isRecord).map((p) => ({ name: String(p.name ?? ""), ...(typeof p.version === "string" ? { version: p.version } : {}) })).filter((p) => p.name)
159
+ : [];
160
+ const b = isRecord(doc.backend) ? doc.backend : undefined;
161
+ const backend = b
162
+ ? { ...(typeof b.name === "string" ? { name: b.name } : {}), ...(typeof b.url === "string" ? { url: b.url } : {}), ...(typeof b.user === "string" ? { user: b.user } : {}) }
163
+ : undefined;
164
+ return { engine, ...(typeof cli.version === "string" ? { version: cli.version } : {}), plugins, ...(backend ? { backend } : {}) };
165
+ }
166
+ // ── The steps ───────────────────────────────────────────────────────
167
+ /** The last lines of what a failed run printed, for its reason. */
168
+ function tail(text) {
169
+ const lines = (text ?? "").trim().split("\n").filter(Boolean);
170
+ return lines.slice(-3).join(" | ");
171
+ }
172
+ /** The steps for one member, in order: install, each stack's config, about. */
173
+ export async function pulumiMemberCheck(member, ctx, opts = {}) {
174
+ const fields = member.fields ?? {};
175
+ const declaredEngine = fields.engine === "pulumi" || fields.engine === "plunge" ? fields.engine : undefined;
176
+ const declaredStack = typeof fields.stack === "string" && fields.stack.length > 0 ? fields.stack : undefined;
177
+ const { engine } = pulumiMemberEngine(member.dir, declaredEngine, { env: ctx.env });
178
+ const run = (opts.runner ?? checkRunner)(engine, ctx.env);
179
+ const out = [];
180
+ const base = { lexicon: LEXICON, member: member.name };
181
+ const file = projectFile(member.dir);
182
+ if (!file) {
183
+ out.push({ ...base, step: "pulumi-install", exit: 1, outcome: "failed", reason: `member ${member.name} has no Pulumi.yaml in ${member.dir}` });
184
+ return out;
185
+ }
186
+ // pulumi install
187
+ let installed = true;
188
+ try {
189
+ await runCheckPulumi(run, ["install"], member.dir);
190
+ out.push({ ...base, step: "pulumi-install", exit: 0, outcome: "passed" });
191
+ }
192
+ catch (err) {
193
+ installed = false;
194
+ const f = pulumiRunFailure(err);
195
+ const why = f.missingBinary ? `${engine} is not installed in the check job` : tail(f.stderr) || tail(f.stdout) || f.message;
196
+ out.push({ ...base, step: "pulumi-install", exit: f.exitCode ?? 1, outcome: "failed", reason: `${engine} install failed for member ${member.name}: ${why}` });
197
+ }
198
+ // Stack config, run by nothing but this module.
199
+ let schema;
200
+ try {
201
+ schema = pulumiProjectSchema(parseYAML(readFileSync(file, "utf-8")));
202
+ }
203
+ catch (err) {
204
+ out.push({ ...base, step: "stack-config", exit: 1, outcome: "failed", reason: `member ${member.name}: ${file} does not read as YAML (${err instanceof Error ? err.message : String(err)})` });
205
+ }
206
+ if (schema) {
207
+ const stackDir = join(member.dir, schema.stackConfigDir);
208
+ const stacks = [...new Set([...listedStacks(stackDir), ...(declaredStack ? [declaredStack] : [])])].sort();
209
+ if (stacks.length === 0)
210
+ out.push({ ...base, step: "stack-config", exit: 0, outcome: "skipped", reason: `member ${member.name} lists no stack (no Pulumi.<stack>.yaml in ${stackDir})` });
211
+ for (const stack of stacks) {
212
+ const at = stackFile(stackDir, stack);
213
+ let doc = {};
214
+ if (at) {
215
+ try {
216
+ doc = parseYAML(readFileSync(at, "utf-8"));
217
+ }
218
+ catch (err) {
219
+ out.push({ ...base, step: "stack-config", stack, exit: 1, outcome: "failed", reason: `member ${member.name}, stack ${stack}: ${at} does not read as YAML (${err instanceof Error ? err.message : String(err)})` });
220
+ continue;
221
+ }
222
+ }
223
+ const problems = pulumiStackConfigProblems(schema, stack, doc);
224
+ out.push(problems.length === 0
225
+ ? { ...base, step: "stack-config", stack, exit: 0, outcome: "passed" }
226
+ : { ...base, step: "stack-config", stack, exit: 1, outcome: "failed", reason: `member ${member.name}: ${problems.join("; ")}`, detail: { problems } });
227
+ }
228
+ }
229
+ // pulumi about --json
230
+ if (!installed) {
231
+ out.push({ ...base, step: "pulumi-about", exit: 0, outcome: "skipped", reason: `member ${member.name}: ${engine} install failed, so about did not run` });
232
+ return out;
233
+ }
234
+ const aboutArgs = declaredStack ? ["about", "--json", "--stack", declaredStack] : ["about", "--json"];
235
+ const stackPart = declaredStack ? { stack: declaredStack } : {};
236
+ try {
237
+ const { stdout } = await runCheckPulumi(run, aboutArgs, member.dir);
238
+ const detail = pulumiAboutDetail(engine, stdout);
239
+ out.push(detail
240
+ ? { ...base, step: "pulumi-about", ...stackPart, exit: 0, outcome: "passed", detail }
241
+ : { ...base, step: "pulumi-about", ...stackPart, exit: 1, outcome: "failed", reason: `${engine} about --json for member ${member.name} printed no JSON` });
242
+ }
243
+ catch (err) {
244
+ const f = pulumiRunFailure(err);
245
+ // about exits non-zero when one of its reads fails (no backend login in a check job), and still prints what it read.
246
+ const detail = pulumiAboutDetail(engine, f.stdout);
247
+ if (detail) {
248
+ ctx.log(`pulumi check: ${engine} about for ${member.name} exited ${f.exitCode ?? 1} and reported: ${tail(f.stderr) || "no reason"}`);
249
+ out.push({ ...base, step: "pulumi-about", ...stackPart, exit: f.exitCode ?? 1, outcome: "passed", detail });
250
+ }
251
+ else {
252
+ const why = f.missingBinary ? `${engine} is not installed in the check job` : tail(f.stderr) || f.message;
253
+ out.push({ ...base, step: "pulumi-about", ...stackPart, exit: f.exitCode ?? 1, outcome: "failed", reason: `${engine} about failed for member ${member.name}${declaredStack ? `, stack ${declaredStack}` : ""}: ${why}` });
254
+ }
255
+ }
256
+ return out;
257
+ }
258
+ /** The pulumi lexicon's check steps, for `LexiconPlugin.checkSteps`. */
259
+ export function pulumiCheckSteps(opts = {}) {
260
+ return {
261
+ lexicon: LEXICON,
262
+ kinds: ["pulumi"],
263
+ async run(ctx) {
264
+ const out = [];
265
+ for (const member of ctx.members)
266
+ out.push(...(await pulumiMemberCheck(member, ctx, opts)));
267
+ return out;
268
+ },
269
+ };
270
+ }
@@ -0,0 +1,53 @@
1
+ export type CiEngine = "pulumi" | "plunge";
2
+ /** The Pulumi release the default lines install: the plunge corpus's, which plunge 0.1.0 is built from. */
3
+ export declare const PULUMI_CI_VERSION = "3.272.0";
4
+ /** The plunge release the default lines install (INTENTIUS/plunge, tag plunge-v<ver>), not the Pulumi version it is built from. */
5
+ export declare const PLUNGE_CI_VERSION = "0.1.0";
6
+ /** Where the lines install each release by default. */
7
+ export declare const CI_ENGINE_ROOT = "/opt/chant";
8
+ /** The archive's architecture word, as both projects name their Linux builds. */
9
+ export type CiArch = "x64" | "arm64";
10
+ export declare const CI_ARCHES: readonly CiArch[];
11
+ /**
12
+ * The sha256 of each recorded Linux archive, copied from its release's
13
+ * checksums file (the same sums plywood#12 records). A checksums file that
14
+ * lists another sum for one of these fails the install. Add a release's
15
+ * two lines when moving a default version.
16
+ */
17
+ export declare const PINNED_SHA256: Readonly<Record<string, string>>;
18
+ /** What a caller may set on an install line. */
19
+ export interface CiInstallOptions {
20
+ /** Where the versioned directories live. Default {@link CI_ENGINE_ROOT}. */
21
+ root?: string;
22
+ /** Recorded sums by architecture, in place of {@link PINNED_SHA256}'s for this release. */
23
+ sha256?: Partial<Record<CiArch, string>>;
24
+ }
25
+ /** One release's download: the base URL, the archive per architecture, and its checksums file. */
26
+ export interface CiRelease {
27
+ base: string;
28
+ archive(arch: CiArch): string;
29
+ checksums: string;
30
+ }
31
+ /** The release of `engine` at `version`. A version is a plain semver, so it is safe on a shell line. */
32
+ export declare function ciRelease(engine: CiEngine, version: string): CiRelease;
33
+ /** Where one release of one engine lives after its line runs. */
34
+ export declare function ciEngineDir(engine: CiEngine, version: string, root?: string): string;
35
+ /** The recorded sums for `version`, by architecture: the caller's, else {@link PINNED_SHA256}'s. */
36
+ export declare function recordedSums(engine: CiEngine, version: string, given?: Partial<Record<CiArch, string>>): Partial<Record<CiArch, string>>;
37
+ /**
38
+ * Pulumi at `version` under `<root>/pulumi-<version>/`, checked against the
39
+ * release checksums. Skips when the `pulumi` on PATH is that version.
40
+ */
41
+ export declare function pulumiInstallLine(version?: string, options?: CiInstallOptions): string;
42
+ /**
43
+ * plunge at `version` under `<root>/plunge-<version>/`, its language hosts
44
+ * beside it, checked against the release checksums. plunge reports the
45
+ * Pulumi version it is built from, not its own release, so only the
46
+ * versioned directory tells an installed release apart.
47
+ */
48
+ export declare function plungeInstallLine(version?: string, options?: CiInstallOptions): string;
49
+ /** The lines for the project at `dir`. */
50
+ export declare function pulumiCiSetup(ctx: {
51
+ dir: string;
52
+ }): string[];
53
+ //# sourceMappingURL=ci-setup.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ci-setup.d.ts","sourceRoot":"","sources":["../src/ci-setup.ts"],"names":[],"mappings":"AA6BA,MAAM,MAAM,QAAQ,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAE3C,2GAA2G;AAC3G,eAAO,MAAM,iBAAiB,YAAY,CAAC;AAE3C,mIAAmI;AACnI,eAAO,MAAM,iBAAiB,UAAU,CAAC;AAEzC,uDAAuD;AACvD,eAAO,MAAM,cAAc,eAAe,CAAC;AAE3C,iFAAiF;AACjF,MAAM,MAAM,MAAM,GAAG,KAAK,GAAG,OAAO,CAAC;AACrC,eAAO,MAAM,SAAS,EAAE,SAAS,MAAM,EAAqB,CAAC;AAE7D;;;;;GAKG;AACH,eAAO,MAAM,aAAa,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAK1D,CAAC;AAEF,gDAAgD;AAChD,MAAM,WAAW,gBAAgB;IAC/B,4EAA4E;IAC5E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,2FAA2F;IAC3F,MAAM,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CAC1C;AAED,kGAAkG;AAClG,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;IAC9B,SAAS,EAAE,MAAM,CAAC;CACnB;AAMD,wGAAwG;AACxG,wBAAgB,SAAS,CAAC,MAAM,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,GAAG,SAAS,CActE;AAED,iEAAiE;AACjE,wBAAgB,WAAW,CAAC,MAAM,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,SAAiB,GAAG,MAAM,CAG5F;AAED,oGAAoG;AACpG,wBAAgB,YAAY,CAAC,MAAM,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAUxI;AA0CD;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,SAAoB,EAAE,OAAO,GAAE,gBAAqB,GAAG,MAAM,CAIrG;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,SAAoB,EAAE,OAAO,GAAE,gBAAqB,GAAG,MAAM,CAErG;AAED,0CAA0C;AAC1C,wBAAgB,aAAa,CAAC,GAAG,EAAE;IAAE,GAAG,EAAE,MAAM,CAAA;CAAE,GAAG,MAAM,EAAE,CAE5D"}
@@ -0,0 +1,146 @@
1
+ /**
2
+ * The engine install lines a Pulumi project's pull request pipeline runs in
3
+ * chant's default image (#4550, `LexiconPlugin.ciSetup`): Pulumi, or plunge
4
+ * when the member's engine is plunge (`pulumiMemberEngine`, #4559).
5
+ *
6
+ * Each line installs one release (#4592), modelled on plywood#12's
7
+ * (INTENTIUS/plywood 9b24b50847, packages/plywood/src/images.ts):
8
+ *
9
+ * - it takes a version, by default {@link PULUMI_CI_VERSION} or
10
+ * {@link PLUNGE_CI_VERSION};
11
+ * - it downloads the Linux archive for the job's architecture and the
12
+ * release's own checksums file (Pulumi's `pulumi-<ver>-checksums.txt`,
13
+ * plunge's `plunge-v<ver>-checksums.txt`), and fails unless the archive's
14
+ * line is there and matches the archive (`sha256sum -c`);
15
+ * - for a release with a recorded sum ({@link PINNED_SHA256}, or the
16
+ * caller's `sha256`), the line must also hold that sum, so a tampered
17
+ * checksums file fails as a tampered archive does;
18
+ * - it unpacks under a versioned path, `/opt/chant/<engine>-<version>/`,
19
+ * with the language hosts beside the binary, and links the binary into
20
+ * /usr/local/bin;
21
+ * - it skips the download when that directory already holds the binary,
22
+ * and for Pulumi also when the `pulumi` on PATH is that version, so an
23
+ * image that carries the engine pays nothing.
24
+ *
25
+ * A product with images of its own (plywood keeps its engines under
26
+ * /opt/plywood) passes `root`, so the lines find what its image carries.
27
+ */
28
+ import { pulumiMemberEngine } from "./member-engine.js";
29
+ /** The Pulumi release the default lines install: the plunge corpus's, which plunge 0.1.0 is built from. */
30
+ export const PULUMI_CI_VERSION = "3.272.0";
31
+ /** The plunge release the default lines install (INTENTIUS/plunge, tag plunge-v<ver>), not the Pulumi version it is built from. */
32
+ export const PLUNGE_CI_VERSION = "0.1.0";
33
+ /** Where the lines install each release by default. */
34
+ export const CI_ENGINE_ROOT = "/opt/chant";
35
+ export const CI_ARCHES = ["x64", "arm64"];
36
+ /**
37
+ * The sha256 of each recorded Linux archive, copied from its release's
38
+ * checksums file (the same sums plywood#12 records). A checksums file that
39
+ * lists another sum for one of these fails the install. Add a release's
40
+ * two lines when moving a default version.
41
+ */
42
+ export const PINNED_SHA256 = {
43
+ "pulumi-v3.272.0-linux-x64.tar.gz": "58d545f2b98761051922008ebd381ecef95484d7f7a5ff7faa15d7a780ea84f6",
44
+ "pulumi-v3.272.0-linux-arm64.tar.gz": "4f0741901b8982c4326b702dabf483c63d2cf77c88ac42dd752b72541284a534",
45
+ "plunge-v0.1.0-linux-x64.tar.gz": "22b89da39ab7dc11268c7b625fec3d99c0793c9c47bf8d031fa94e6967ee8187",
46
+ "plunge-v0.1.0-linux-arm64.tar.gz": "6855611b8cf8381d73f5ddf0b5c0a0d83bdc2a5bd8c50d415e52bb05a4574f83",
47
+ };
48
+ const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
49
+ const SHA256 = /^[0-9a-f]{64}$/;
50
+ const ROOT = /^\/[A-Za-z0-9._/-]*$/;
51
+ /** The release of `engine` at `version`. A version is a plain semver, so it is safe on a shell line. */
52
+ export function ciRelease(engine, version) {
53
+ if (!VERSION.test(version))
54
+ throw new Error(`${engine} version ${JSON.stringify(version)} is not a release version such as 3.272.0`);
55
+ if (engine === "pulumi") {
56
+ return {
57
+ base: `https://github.com/pulumi/pulumi/releases/download/v${version}`,
58
+ archive: (arch) => `pulumi-v${version}-linux-${arch}.tar.gz`,
59
+ checksums: `pulumi-${version}-checksums.txt`,
60
+ };
61
+ }
62
+ return {
63
+ base: `https://github.com/INTENTIUS/plunge/releases/download/plunge-v${version}`,
64
+ archive: (arch) => `plunge-v${version}-linux-${arch}.tar.gz`,
65
+ checksums: `plunge-v${version}-checksums.txt`,
66
+ };
67
+ }
68
+ /** Where one release of one engine lives after its line runs. */
69
+ export function ciEngineDir(engine, version, root = CI_ENGINE_ROOT) {
70
+ if (!ROOT.test(root))
71
+ throw new Error(`an engine root is an absolute path of letters, digits and ._/-, not ${JSON.stringify(root)}`);
72
+ return `${root.replace(/\/+$/, "")}/${engine}-${version}`;
73
+ }
74
+ /** The recorded sums for `version`, by architecture: the caller's, else {@link PINNED_SHA256}'s. */
75
+ export function recordedSums(engine, version, given) {
76
+ const r = ciRelease(engine, version);
77
+ const out = {};
78
+ for (const arch of CI_ARCHES) {
79
+ const sum = given ? given[arch] : PINNED_SHA256[r.archive(arch)];
80
+ if (sum === undefined)
81
+ continue;
82
+ if (!SHA256.test(sum))
83
+ throw new Error(`the recorded sha256 for ${r.archive(arch)} is not 64 hex digits`);
84
+ out[arch] = sum;
85
+ }
86
+ return out;
87
+ }
88
+ /** The job's architecture as x64 or arm64. */
89
+ const JOB_ARCH = `"$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/')"`;
90
+ /**
91
+ * Shell that downloads one release into `dir` and checks it against the
92
+ * release's checksums file and, when recorded, the sum per architecture.
93
+ * Both archives unpack a top directory named after the engine, which
94
+ * `--strip-components=1` drops.
95
+ */
96
+ function fetchScript(engine, version, dir, sums) {
97
+ const r = ciRelease(engine, version);
98
+ const recorded = CI_ARCHES.filter((a) => sums[a]);
99
+ const pinCase = recorded.length > 0 ? `case "$a" in ${recorded.map((a) => `${a}) want=${sums[a]} ;;`).join(" ")} *) want= ;; esac` : "want=";
100
+ const file = r.archive("x64").replace("-x64.", "-$a.");
101
+ return [
102
+ `a=${JOB_ARCH}`,
103
+ `f="${file}"`,
104
+ `t="$(mktemp -d)"`,
105
+ `curl -fsSLo "$t/$f" "${r.base}/$f"`,
106
+ `curl -fsSLo "$t/sums" "${r.base}/${r.checksums}"`,
107
+ // The archive's line, with any ./ or * before the name taken off.
108
+ `line="$(sed -n "s#^\\([0-9a-f]\\{64\\}\\) *[*.]*/*$f\\$#\\1#p" "$t/sums")"`,
109
+ `{ [ -n "$line" ] || { echo "${engine} ${version}: $f is not in ${r.checksums}" >&2; exit 1; }; }`,
110
+ pinCase,
111
+ `{ [ -z "$want" ] || [ "$line" = "$want" ] || { echo "${engine} ${version}: ${r.checksums} lists $line for $f; chant records $want" >&2; exit 1; }; }`,
112
+ `echo "$line $t/$f" | sha256sum -c - >/dev/null`,
113
+ `mkdir -p "${dir}"`,
114
+ `tar -xzf "$t/$f" -C "${dir}" --strip-components=1`,
115
+ ].join(" && ");
116
+ }
117
+ /** The line for one engine: skip when present, else fetch and check, then link into /usr/local/bin. */
118
+ function installLine(engine, version, options, present) {
119
+ const dir = ciEngineDir(engine, version, options.root);
120
+ const install = `(set -e; ${fetchScript(engine, version, dir, recordedSums(engine, version, options.sha256))})`;
121
+ const link = `ln -sf "${dir}/${engine}" /usr/local/bin/${engine}`;
122
+ const inDir = `[ -x "${dir}/${engine}" ]`;
123
+ return present ? `${present} || { { ${inDir} || ${install}; } && ${link}; }` : `{ ${inDir} || ${install}; } && ${link}`;
124
+ }
125
+ /**
126
+ * Pulumi at `version` under `<root>/pulumi-<version>/`, checked against the
127
+ * release checksums. Skips when the `pulumi` on PATH is that version.
128
+ */
129
+ export function pulumiInstallLine(version = PULUMI_CI_VERSION, options = {}) {
130
+ ciRelease("pulumi", version);
131
+ const present = `{ command -v pulumi >/dev/null 2>&1 && [ "$(pulumi version 2>/dev/null)" = "v${version}" ]; }`;
132
+ return installLine("pulumi", version, options, present);
133
+ }
134
+ /**
135
+ * plunge at `version` under `<root>/plunge-<version>/`, its language hosts
136
+ * beside it, checked against the release checksums. plunge reports the
137
+ * Pulumi version it is built from, not its own release, so only the
138
+ * versioned directory tells an installed release apart.
139
+ */
140
+ export function plungeInstallLine(version = PLUNGE_CI_VERSION, options = {}) {
141
+ return installLine("plunge", version, options);
142
+ }
143
+ /** The lines for the project at `dir`. */
144
+ export function pulumiCiSetup(ctx) {
145
+ return [pulumiMemberEngine(ctx.dir).engine === "plunge" ? plungeInstallLine() : pulumiInstallLine()];
146
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * PulumiAdoptOp composite (#4562): adopt stock Pulumi stacks onto plunge's
3
+ * per-resource records, behind a gate, the pulumi twin of the terraform
4
+ * lexicon's `TerraformAdoptOp`. It is the step after `plywood init --engine
5
+ * plunge` (INTENTIUS/plywood#3); plywood supplies the words.
6
+ *
7
+ * plunge refuses a stock checkpoint with no records (`AdoptionRequiredError`)
8
+ * rather than recreate everything, so a stack moves to plunge by `plunge
9
+ * stack adopt`. Adoption writes one record per resource with the
10
+ * checkpoint's IDs and calls no cloud API; the next `plunge up` adds the
11
+ * `plunge-stack` and `plunge-urn` tags in place.
12
+ *
13
+ * Phases:
14
+ *
15
+ * - **Check**: the shared bucket check (#4558) on each stack's backend
16
+ * bucket. A setting that fails, not waived, refuses the run before
17
+ * anything is exported, naming the fix. A `file://` backend is skipped.
18
+ * - **Export**: each stack's stock checkpoint, `pulumi stack export
19
+ * --stack <s>`, kept in a scratch file. It is the back-out copy (`pulumi
20
+ * stack import --file` on a stock backend), and on the route from Pulumi
21
+ * Cloud (`from: { backend }`) or from an export taken by hand (`from: {
22
+ * file }`) it is what plunge adopts with `--file`.
23
+ * - Then one wave per stack, in the order given: **Preview** runs `plunge
24
+ * stack adopt --json` (writes nothing), the **gate** binds that preview's
25
+ * `planDigest`, and **Adopt** runs `plunge stack adopt --json --yes`. A
26
+ * stack whose checkpoint changed after approval previews a new digest,
27
+ * and the old approval does not open the gate for it.
28
+ *
29
+ * Every gate is a fact on the workspace ledger: `chant run` records the
30
+ * pending gate citing the preview's digest and exits gated, `chant approve
31
+ * <op> <gate>` writes the resolution, and the next run walks to the Adopt
32
+ * step. The run itself is a run record on the same ledger (#4551), as for
33
+ * `TerraformAdoptOp`. There is no `gate: "never"`: adoption moves the stack's
34
+ * state onto another backend's records, which nobody should skip reviewing.
35
+ *
36
+ * ## Compensation
37
+ *
38
+ * Refused without a command, as `TerraformAdoptOp` refuses it. Backing out
39
+ * is `plunge stack export --file s.json` then `pulumi stack import --file
40
+ * s.json` on a stock backend, a decision about the stack and its backend
41
+ * rather than something chant should run on its own.
42
+ *
43
+ * @example
44
+ * ```typescript
45
+ * import { PulumiAdoptOp } from "@intentius/chant-lexicon-pulumi";
46
+ *
47
+ * export const { op } = PulumiAdoptOp({
48
+ * name: "adopt-infra",
49
+ * stacks: [{ member: "infra", dir: "./infra", stack: "dev" }],
50
+ * });
51
+ * ```
52
+ */
53
+ import { OpResource } from "@intentius/chant/op";
54
+ /** One stack to adopt. */
55
+ export interface PulumiAdoptStack {
56
+ /** The member's name. */
57
+ member: string;
58
+ /** The stack's directory, where `Pulumi.yaml` is, relative to the project root. */
59
+ dir: string;
60
+ /** The stack to adopt. */
61
+ stack: string;
62
+ /** The plunge backend URL (`plunge+s3://...`). Default: `plywood.yml`'s, `PULUMI_BACKEND_URL`, `Pulumi.yaml`'s. */
63
+ backend?: string;
64
+ /**
65
+ * Where the checkpoint comes from, when it is not the stock stack at the
66
+ * same root: `backend`, another backend to export from (Pulumi Cloud), or
67
+ * `file`, an export already taken. Either way plunge adopts it with
68
+ * `--file`.
69
+ */
70
+ from?: {
71
+ backend: string;
72
+ } | {
73
+ file: string;
74
+ };
75
+ /** The bucket to check, as `@intentius/chant/bucket-check` takes it, when the URL does not name it fully (azblob). */
76
+ bucket?: Record<string, unknown>;
77
+ /** Key prefixes a lifecycle rule must cover. Default: the backend URL's path. */
78
+ namespaces?: string[];
79
+ /** Bucket settings waived, as plunge's `allow_insecure`. An unwaivable one stays refused. */
80
+ allowInsecure?: string[];
81
+ }
82
+ export interface PulumiAdoptOpConfig {
83
+ /** Op name (kebab-case). */
84
+ name: string;
85
+ /** The stacks to adopt, one wave each, in this order. */
86
+ stacks: PulumiAdoptStack[];
87
+ /** Directory relative `dir`s resolve against. Default: the running process's cwd. */
88
+ cwd?: string;
89
+ /** The gate's name with one stack; with several, each gate is `<gateName>-<member>-<stack>`. Default: `approve-<name>`. */
90
+ gateName?: string;
91
+ /** How long a recorded pending gate stays valid, as a duration string. Default: core's own (48h). */
92
+ gateTimeout?: string;
93
+ /** Override the gate description shown to the approver. */
94
+ gateDescription?: string;
95
+ /** Rollback on a failed adoption, run as an `onFailure` phase. Refused without a command. */
96
+ compensate?: boolean | {
97
+ command?: string;
98
+ image?: string;
99
+ };
100
+ }
101
+ export interface PulumiAdoptOpResources {
102
+ /** Op resource: Check, Export, then Preview, gate and Adopt per stack. */
103
+ op: InstanceType<typeof OpResource>;
104
+ }
105
+ export declare function PulumiAdoptOp(config: PulumiAdoptOpConfig): PulumiAdoptOpResources;
106
+ //# sourceMappingURL=pulumi-adopt-op.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pulumi-adopt-op.d.ts","sourceRoot":"","sources":["../../src/composites/pulumi-adopt-op.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AACH,OAAO,EAA6B,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAG5E,0BAA0B;AAC1B,MAAM,WAAW,gBAAgB;IAC/B,yBAAyB;IACzB,MAAM,EAAE,MAAM,CAAC;IACf,mFAAmF;IACnF,GAAG,EAAE,MAAM,CAAC;IACZ,0BAA0B;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,mHAAmH;IACnH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;;OAKG;IACH,IAAI,CAAC,EAAE;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,GAAG;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC;IAC9C,sHAAsH;IACtH,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,iFAAiF;IACjF,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,6FAA6F;IAC7F,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;CAC1B;AAED,MAAM,WAAW,mBAAmB;IAClC,4BAA4B;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,yDAAyD;IACzD,MAAM,EAAE,gBAAgB,EAAE,CAAC;IAC3B,qFAAqF;IACrF,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,2HAA2H;IAC3H,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,qGAAqG;IACrG,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,2DAA2D;IAC3D,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,6FAA6F;IAC7F,UAAU,CAAC,EAAE,OAAO,GAAG;QAAE,OAAO,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CAC7D;AAED,MAAM,WAAW,sBAAsB;IACrC,0EAA0E;IAC1E,EAAE,EAAE,YAAY,CAAC,OAAO,UAAU,CAAC,CAAC;CACrC;AAID,wBAAgB,aAAa,CAAC,MAAM,EAAE,mBAAmB,GAAG,sBAAsB,CAsFjF"}