@intentius/chant 0.124.44 → 0.124.45

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 (72) hide show
  1. package/dist/ci-identity.d.ts +183 -12
  2. package/dist/ci-identity.d.ts.map +1 -1
  3. package/dist/ci-identity.js +362 -53
  4. package/dist/cli/handlers/build.d.ts.map +1 -1
  5. package/dist/cli/handlers/build.js +17 -1
  6. package/dist/cli/handlers/pr-env.d.ts +8 -2
  7. package/dist/cli/handlers/pr-env.d.ts.map +1 -1
  8. package/dist/cli/handlers/pr-env.js +126 -225
  9. package/dist/cli/handlers/pr.d.ts +3 -0
  10. package/dist/cli/handlers/pr.d.ts.map +1 -1
  11. package/dist/cli/handlers/pr.js +1 -0
  12. package/dist/components/cli-support.d.ts +2 -0
  13. package/dist/components/cli-support.d.ts.map +1 -1
  14. package/dist/components/cli-support.js +4 -2
  15. package/dist/components/fan-out-support.d.ts +3 -0
  16. package/dist/components/fan-out-support.d.ts.map +1 -1
  17. package/dist/components/fan-out-support.js +1 -1
  18. package/dist/components/pr-env-run.d.ts +270 -0
  19. package/dist/components/pr-env-run.d.ts.map +1 -0
  20. package/dist/components/pr-env-run.js +440 -0
  21. package/dist/components/pr-environment.d.ts +89 -14
  22. package/dist/components/pr-environment.d.ts.map +1 -1
  23. package/dist/components/pr-environment.js +94 -8
  24. package/dist/components/pr-pipeline.d.ts +10 -1
  25. package/dist/components/pr-pipeline.d.ts.map +1 -1
  26. package/dist/op/op-waves.d.ts +17 -0
  27. package/dist/op/op-waves.d.ts.map +1 -1
  28. package/dist/op/op-waves.js +23 -1
  29. package/dist/pr-forge.d.ts +15 -1
  30. package/dist/pr-forge.d.ts.map +1 -1
  31. package/dist/pr-forge.js +28 -16
  32. package/dist/pr-requires.d.ts +5 -1
  33. package/dist/pr-requires.d.ts.map +1 -1
  34. package/dist/wave-runtime.mjs +179 -179
  35. package/dist/workspace/ci-cloud-identity.d.ts +3 -1
  36. package/dist/workspace/ci-cloud-identity.d.ts.map +1 -1
  37. package/dist/workspace/ci-cloud-identity.js +16 -1
  38. package/dist/workspace/ci-run.d.ts +35 -8
  39. package/dist/workspace/ci-run.d.ts.map +1 -1
  40. package/dist/workspace/ci-run.js +95 -21
  41. package/dist/workspace/ci-secrets.d.ts.map +1 -1
  42. package/dist/workspace/ci-secrets.js +4 -3
  43. package/dist/workspace/declaration.d.ts +10 -0
  44. package/dist/workspace/declaration.d.ts.map +1 -1
  45. package/dist/workspace/declaration.js +17 -1
  46. package/dist/workspace/declaration.schema.json +36 -1
  47. package/package.json +1 -1
  48. package/src/ci-identity-targets.test.ts +275 -0
  49. package/src/ci-identity.ts +394 -61
  50. package/src/cli/handlers/build.ts +15 -1
  51. package/src/cli/handlers/pr-env.ts +136 -227
  52. package/src/cli/handlers/pr.ts +4 -0
  53. package/src/components/cli-support.ts +6 -1
  54. package/src/components/fan-out-support.ts +4 -1
  55. package/src/components/pr-env-run.test.ts +290 -0
  56. package/src/components/pr-env-run.ts +634 -0
  57. package/src/components/pr-environment.ts +159 -12
  58. package/src/components/pr-pipeline.ts +10 -1
  59. package/src/op/op-waves.ts +38 -1
  60. package/src/pr-forge-bundle.test.ts +1 -1
  61. package/src/pr-forge.ts +44 -17
  62. package/src/pr-requires.test.ts +43 -6
  63. package/src/pr-requires.ts +5 -1
  64. package/src/workspace/ci-cloud-identity.test.ts +20 -0
  65. package/src/workspace/ci-cloud-identity.ts +14 -1
  66. package/src/workspace/ci-run.test.ts +74 -1
  67. package/src/workspace/ci-run.ts +122 -29
  68. package/src/workspace/ci-secrets.test.ts +1 -1
  69. package/src/workspace/ci-secrets.ts +4 -3
  70. package/src/workspace/declaration.schema.json +36 -1
  71. package/src/workspace/declaration.test.ts +7 -0
  72. package/src/workspace/declaration.ts +24 -2
@@ -21,6 +21,17 @@
21
21
  * identity. The record job plans the merged commit with the plan identity,
22
22
  * and the apply jobs trade for the apply identity.
23
23
  *
24
+ * Per target (#4332): a rule with `targets` covers only the targets those
25
+ * globs match, the Terraform roots or Op wave runs inside one member, so
26
+ * one member's targets can plan and apply as different identities. A
27
+ * target takes the first rule that covers its environment, member and
28
+ * target ({@link resolveTargetCloudIdentity}). The plan job then trades for
29
+ * every plan identity the member's targets take, keeps the credentials of
30
+ * each in a file named by {@link CLOUD_TARGETS_ENV}, and a product hands
31
+ * each target's tool its own with {@link targetCloudEnv}. An apply splits
32
+ * into one job per apply identity ({@link cloudIdentityGroups},
33
+ * {@link cloudIdentityWave}), so no job holds two targets' apply tokens.
34
+ *
24
35
  * The trade is terragucci's `oidc-trade.ts` (INTENTIUS/terragucci e6cb431,
25
36
  * packages/terragucci/src/oidc-trade.ts) with these clients:
26
37
  *
@@ -38,65 +49,289 @@
38
49
  * another client could be traded for; a tool that needs the OIDC token
39
50
  * itself cannot plan under this block.
40
51
  */
41
- // @ts-ignore: picomatch has no types declaration
42
- import picomatch from "picomatch";
52
+ import { readFileSync } from "node:fs";
43
53
  /** The clouds `ci.identity.cloud` names. */
44
54
  export const CLOUDS = ["aws", "gcp", "azure"];
45
- const matches = (globs, name) => globs === undefined || (name !== undefined && globs.some((g) => picomatch(g)(name)));
46
- /** The first rule that covers `env` and `member`, or undefined. */
47
- export function ruleFor(rules, env, member) {
48
- return rules.find((r) => matches(r.environments, env) && matches(r.members, member));
55
+ /** The key a cloud's trade names its identity under. */
56
+ export const IDENTITY_KEY = { aws: "role", gcp: "serviceAccount", azure: "clientId" };
57
+ /**
58
+ * A glob over a name (#4296): `*` and `?` within the name, `[...]` classes,
59
+ * `{a,b}` alternatives, a leading `!` negates. The grammar `ciMemberGlobs`
60
+ * documents, with no dependency, so this module bundles small (#4332).
61
+ */
62
+ export function nameGlobMatch(glob, name) {
63
+ if (glob.startsWith("!"))
64
+ return !nameGlobMatch(glob.slice(1), name);
65
+ return expandBraces(glob).some((g) => new RegExp(`^${globBody(g, false)}$`).test(name));
66
+ }
67
+ /**
68
+ * A glob over a target (#4332): `*` within one `/` segment, `**` across
69
+ * segments (and `a/**\/b` across none), `?` one character. The reading of
70
+ * `targetGlobMatch` in ./op/gate-quorum.ts and the terraform lexicon's unit
71
+ * globs, so a root's glob means one thing in each.
72
+ */
73
+ export function targetGlobMatch(glob, target) {
74
+ const trim = (v) => v.replace(/\\/g, "/").replace(/\/+$/, "");
75
+ return new RegExp(`^${globBody(trim(glob), true)}$`).test(trim(target));
76
+ }
77
+ /** `{a,b}` alternatives, nested too, expanded into plain globs. */
78
+ function expandBraces(glob) {
79
+ const open = glob.indexOf("{");
80
+ if (open < 0)
81
+ return [glob];
82
+ let depth = 0;
83
+ const cuts = [];
84
+ for (let i = open; i < glob.length; i++) {
85
+ const c = glob[i];
86
+ if (c === "{")
87
+ depth++;
88
+ else if (c === "}" && --depth === 0) {
89
+ const parts = cuts.length === 0 ? [glob.slice(open + 1, i)] : [open, ...cuts].map((at, k, all) => glob.slice(at + 1, k + 1 < all.length ? all[k + 1] : i));
90
+ const rest = expandBraces(glob.slice(i + 1));
91
+ return parts.flatMap((p) => expandBraces(glob.slice(0, open) + p)).flatMap((head) => rest.map((tail) => head + tail));
92
+ }
93
+ else if (c === "," && depth === 1)
94
+ cuts.push(i);
95
+ }
96
+ return [glob];
97
+ }
98
+ /** A glob's regular expression body. `paths`: `**` crosses `/`, and classes and braces are literal. */
99
+ function globBody(g, paths) {
100
+ let re = "";
101
+ for (let i = 0; i < g.length; i++) {
102
+ const c = g[i];
103
+ if (c === "*" && g[i + 1] === "*" && paths) {
104
+ if (g[i + 2] === "/") {
105
+ re += "(?:.*/)?";
106
+ i += 2;
107
+ }
108
+ else {
109
+ re += ".*";
110
+ i += 1;
111
+ }
112
+ }
113
+ else if (c === "*") {
114
+ re += "[^/]*";
115
+ while (g[i + 1] === "*")
116
+ i++;
117
+ }
118
+ else if (c === "?")
119
+ re += "[^/]";
120
+ else if (c === "[" && !paths && g.indexOf("]", i + 2) > i) {
121
+ const end = g.indexOf("]", i + 2);
122
+ const body = g.slice(i + 1, end);
123
+ re += `[${body.startsWith("!") ? `^${body.slice(1)}` : body}]`.replace(/\\/g, "\\\\");
124
+ i = end;
125
+ }
126
+ else
127
+ re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
128
+ }
129
+ return re;
130
+ }
131
+ const matches = (globs, name) => globs === undefined || (name !== undefined && globs.some((g) => nameGlobMatch(g, name)));
132
+ const coversTarget = (r, target) => r.targets === undefined || (target !== undefined && r.targets.some((g) => targetGlobMatch(g, target)));
133
+ /**
134
+ * The first rule that covers `env`, `member` and `target`, or undefined.
135
+ * With no `target`, the first rule that names no targets: the pipeline's own.
136
+ */
137
+ export function ruleFor(rules, env, member, target) {
138
+ return rules.find((r) => matches(r.environments, env) && matches(r.members, member) && coversTarget(r, target));
49
139
  }
50
140
  /** The provider's resource name without the `//iam.googleapis.com/` prefix. */
51
141
  const providerPath = (p) => p.replace(/^(https:)?\/\/iam\.googleapis\.com\//, "");
142
+ /** Where each cloud trades, the same for every identity of the cloud. */
143
+ function cloudPlace(decl, cloud) {
144
+ if (cloud === "aws") {
145
+ const a = decl.aws;
146
+ return { region: a.region ?? "us-east-1", audience: a.audience ?? "sts.amazonaws.com", ...(a.endpoint ? { endpoint: a.endpoint } : {}) };
147
+ }
148
+ if (cloud === "gcp") {
149
+ const g = decl.gcp;
150
+ const provider = providerPath(g.provider);
151
+ return {
152
+ provider,
153
+ audience: g.audience ?? `https://iam.googleapis.com/${provider}`,
154
+ ...(g.stsEndpoint ? { stsEndpoint: g.stsEndpoint } : {}),
155
+ ...(g.iamEndpoint ? { iamEndpoint: g.iamEndpoint } : {}),
156
+ };
157
+ }
158
+ const z = decl.azure;
159
+ return {
160
+ tenantId: z.tenantId,
161
+ audience: z.audience ?? "api://AzureADTokenExchange",
162
+ ...(z.subscriptionId ? { subscriptionId: z.subscriptionId } : {}),
163
+ ...(z.authority ? { authority: z.authority } : {}),
164
+ };
165
+ }
166
+ /** One cloud's trade: its own identity, the targets that take another, and where it trades. */
167
+ function cloudTrade(decl, cloud, own, targets) {
168
+ const key = IDENTITY_KEY[cloud];
169
+ return {
170
+ ...(own !== undefined ? { [key]: own } : {}),
171
+ ...(targets.length > 0 ? { targets: targets.map((t) => ({ targets: [...t.targets], [key]: t.id })) } : {}),
172
+ ...cloudPlace(decl, cloud),
173
+ };
174
+ }
52
175
  /**
53
176
  * The plan and apply trades of the pipeline for `env` and `member`, or
54
177
  * undefined when no rule of any cloud covers them. A cloud no rule covers
55
- * is left out of both.
178
+ * is left out of both. Each cloud's own identity is the first rule with no
179
+ * `targets` that covers the pipeline; the rules with `targets` before it
180
+ * are the cloud's `targets` (#4332), so the plan trade holds every plan
181
+ * identity a target of the member takes, and the apply trade every apply
182
+ * identity.
56
183
  */
57
184
  export function resolveCloudIdentity(decl, env, member) {
58
185
  if (!decl)
59
186
  return undefined;
60
187
  const plan = {};
61
188
  const apply = {};
62
- const aws = decl.aws && ruleFor(decl.aws.rules, env, member);
63
- if (decl.aws && aws) {
64
- const at = { region: decl.aws.region ?? "us-east-1", audience: decl.aws.audience ?? "sts.amazonaws.com", ...(decl.aws.endpoint ? { endpoint: decl.aws.endpoint } : {}) };
65
- plan.aws = { role: aws.plan, ...at };
66
- apply.aws = { role: aws.apply, ...at };
189
+ for (const cloud of CLOUDS) {
190
+ const rules = decl[cloud]?.rules;
191
+ if (!rules)
192
+ continue;
193
+ const covering = rules.filter((r) => matches(r.environments, env) && matches(r.members, member));
194
+ const cut = covering.findIndex((r) => r.targets === undefined);
195
+ const own = cut < 0 ? undefined : covering[cut];
196
+ const byTarget = (cut < 0 ? covering : covering.slice(0, cut)).filter((r) => r.targets !== undefined);
197
+ if (!own && byTarget.length === 0)
198
+ continue;
199
+ plan[cloud] = cloudTrade(decl, cloud, own?.plan, byTarget.map((r) => ({ targets: r.targets, id: r.plan })));
200
+ apply[cloud] = cloudTrade(decl, cloud, own?.apply, byTarget.map((r) => ({ targets: r.targets, id: r.apply })));
67
201
  }
68
- const gcp = decl.gcp && ruleFor(decl.gcp.rules, env, member);
69
- if (decl.gcp && gcp) {
70
- const provider = providerPath(decl.gcp.provider);
71
- const at = {
72
- provider,
73
- audience: decl.gcp.audience ?? `https://iam.googleapis.com/${provider}`,
74
- ...(decl.gcp.stsEndpoint ? { stsEndpoint: decl.gcp.stsEndpoint } : {}),
75
- ...(decl.gcp.iamEndpoint ? { iamEndpoint: decl.gcp.iamEndpoint } : {}),
76
- };
77
- plan.gcp = { serviceAccount: gcp.plan, ...at };
78
- apply.gcp = { serviceAccount: gcp.apply, ...at };
202
+ return Object.keys(plan).length > 0 ? { plan: plan, apply: apply } : undefined;
203
+ }
204
+ /**
205
+ * The plan and apply trades of one target (#4332): per cloud, the first
206
+ * rule that covers `env`, `member` and `target`. Undefined when no rule of
207
+ * any cloud covers it. Each trade names one identity per cloud and no
208
+ * `targets`.
209
+ */
210
+ export function resolveTargetCloudIdentity(decl, env, member, target) {
211
+ if (!decl)
212
+ return undefined;
213
+ const plan = {};
214
+ const apply = {};
215
+ for (const cloud of CLOUDS) {
216
+ const rule = decl[cloud] && ruleFor(decl[cloud].rules, env, member, target);
217
+ if (!rule)
218
+ continue;
219
+ plan[cloud] = cloudTrade(decl, cloud, rule.plan, []);
220
+ apply[cloud] = cloudTrade(decl, cloud, rule.apply, []);
79
221
  }
80
- const azure = decl.azure && ruleFor(decl.azure.rules, env, member);
81
- if (decl.azure && azure) {
82
- const at = {
83
- tenantId: decl.azure.tenantId,
84
- audience: decl.azure.audience ?? "api://AzureADTokenExchange",
85
- ...(decl.azure.subscriptionId ? { subscriptionId: decl.azure.subscriptionId } : {}),
86
- ...(decl.azure.authority ? { authority: decl.azure.authority } : {}),
87
- };
88
- plan.azure = { clientId: azure.plan, ...at };
89
- apply.azure = { clientId: azure.apply, ...at };
222
+ return Object.keys(plan).length > 0 ? { plan: plan, apply: apply } : undefined;
223
+ }
224
+ /** The name a pull request environment's rule is matched against (#4330): any pull request's, as `pr-*` covers it. */
225
+ export const PR_ENV_IDENTITY_PROBE = "pr-1";
226
+ /**
227
+ * The trade a pull request environment's jobs take (#4330): per cloud, the
228
+ * apply identity of the rules {@link resolveCloudIdentity} gives `pr-1` and
229
+ * `member`, from the rules that name `environments` only. env-up runs the
230
+ * change's code with that identity, so a rule with no `environments`, which
231
+ * covers every environment, is never one it gets: give pull request
232
+ * environments a rule of their own, such as `environments: ["pr-*"]`, whose
233
+ * identity reaches only what they may touch. Undefined when no such rule
234
+ * covers them.
235
+ */
236
+ export function resolvePrEnvCloudIdentity(decl, member) {
237
+ if (!decl)
238
+ return undefined;
239
+ const named = {};
240
+ for (const cloud of CLOUDS) {
241
+ const c = decl[cloud];
242
+ if (c)
243
+ named[cloud] = { ...c, rules: c.rules.filter((r) => r.environments !== undefined) };
244
+ }
245
+ return resolveCloudIdentity(named, PR_ENV_IDENTITY_PROBE, member)?.apply;
246
+ }
247
+ /** The group name a glob gives: `envs/prod/**` is `envs-prod`. terragucci's `nameFrom` (groups.ts at INTENTIUS/terragucci ca05bd9). */
248
+ const nameFrom = (glob) => glob.toLowerCase().replace(/\*+/g, "").replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
249
+ /** The name of the apply job group for {@link CloudIdentityGroup}s with no rule of their own. */
250
+ export const DEFAULT_IDENTITY_GROUP = "default";
251
+ /**
252
+ * `targets` grouped by the identities they take (#4332), so each group's
253
+ * apply runs in a job of its own with its group's identities alone.
254
+ * Targets that take the same plan and apply identity in every cloud are one
255
+ * group. The groups come in the order their first target comes, with
256
+ * `default` (the targets no rule with `targets` took) last. Names are
257
+ * unique: a second group of one name gets `-2`.
258
+ */
259
+ export function cloudIdentityGroups(decl, env, member, targets) {
260
+ const byKey = new Map();
261
+ for (const target of targets) {
262
+ const identity = resolveTargetCloudIdentity(decl, env, member, target);
263
+ const key = JSON.stringify(identity ? CLOUDS.map((c) => [identity.plan[c] ? tradeIdentities({ [c]: identity.plan[c] }) : [], identity.apply[c] ? tradeIdentities({ [c]: identity.apply[c] }) : []]) : null);
264
+ const at = byKey.get(key);
265
+ if (at) {
266
+ at.group.targets.push(target);
267
+ continue;
268
+ }
269
+ const rule = CLOUDS.map((c) => decl?.[c] && ruleFor(decl[c].rules, env, member, target)).find((r) => r?.targets !== undefined);
270
+ const named = rule ? (rule.name ?? (nameFrom(rule.targets[0]) || "group")) : DEFAULT_IDENTITY_GROUP;
271
+ // An Op wave's own jobs are `decide` and `share-<i>` (./op/op-waves.ts), so a group takes neither name.
272
+ const base = named === "decide" || /^share-\d+$/.test(named) ? `${named}-targets` : named;
273
+ byKey.set(key, { base, group: { name: base, targets: [target], ...(identity ? { identity } : {}) } });
90
274
  }
91
- return Object.keys(plan).length > 0 ? { plan, apply } : undefined;
275
+ const all = [...byKey.values()];
276
+ const ordered = [...all.filter((g) => g.base !== DEFAULT_IDENTITY_GROUP), ...all.filter((g) => g.base === DEFAULT_IDENTITY_GROUP)];
277
+ const taken = new Set();
278
+ return ordered.map(({ base, group }) => {
279
+ let name = base;
280
+ for (let n = 2; taken.has(name); n++)
281
+ name = `${base}-${n}`;
282
+ taken.add(name);
283
+ return { ...group, name };
284
+ });
285
+ }
286
+ /**
287
+ * An Op wave's cloud identities over its run targets (#4332), as
288
+ * `OpWave.cloud` and `OpWave.jobs` (./op/op-waves.ts) take them. With two
289
+ * or more {@link cloudIdentityGroups}, the deciding job trades for every
290
+ * plan identity of the targets and each group's job for its group's apply
291
+ * identities alone. With one group, the wave's one job trades for that
292
+ * group's apply identities, and `jobs` is absent. Neither, when no rule
293
+ * covers a target.
294
+ */
295
+ export function cloudIdentityWave(decl, env, member, targets) {
296
+ const groups = cloudIdentityGroups(decl, env, member, targets);
297
+ if (groups.length === 0)
298
+ return {};
299
+ if (groups.length === 1)
300
+ return groups[0].identity ? { cloud: groups[0].identity.apply } : {};
301
+ const plan = resolveCloudIdentity(decl, env, member)?.plan;
302
+ return {
303
+ ...(plan ? { cloud: plan } : {}),
304
+ jobs: groups.map((g) => ({ name: g.name, runs: [...g.targets], ...(g.identity ? { cloud: g.identity.apply } : {}) })),
305
+ };
306
+ }
307
+ /**
308
+ * Why one job may not apply under `spec` (#4332), or undefined: a cloud
309
+ * whose trade names more than one identity would hand one job the apply
310
+ * tokens of targets that apply as different identities.
311
+ */
312
+ export function oneApplyIdentityProblem(spec) {
313
+ for (const cloud of tradedClouds(spec)) {
314
+ const ids = tradeIdentities({ [cloud]: spec[cloud] });
315
+ if (ids.length > 1)
316
+ return `ci.identity.cloud gives the targets of one apply job ${ids.length} ${cloud} apply identities (${ids.join(", ")}); a job may hold one, so apply them in a job per identity (cloudIdentityGroups)`;
317
+ }
318
+ return undefined;
92
319
  }
93
320
  /** The clouds a trade covers, in {@link CLOUDS} order. */
94
321
  export function tradedClouds(spec) {
95
322
  return CLOUDS.filter((c) => spec[c] !== undefined);
96
323
  }
97
- /** Every identity a trade names: the role, the service account, the client id. */
324
+ /** Every identity a trade names, once each: the roles, the service accounts, the client ids, its targets' too (#4332). */
98
325
  export function tradeIdentities(spec) {
99
- return [spec.aws?.role, spec.gcp?.serviceAccount, spec.azure?.clientId].filter((v) => v !== undefined);
326
+ const out = [];
327
+ for (const cloud of tradedClouds(spec)) {
328
+ const c = spec[cloud];
329
+ const key = IDENTITY_KEY[cloud];
330
+ for (const id of [c[key], ...(c.targets ?? []).map((t) => t[key])])
331
+ if (id !== undefined && !out.includes(id))
332
+ out.push(id);
333
+ }
334
+ return out;
100
335
  }
101
336
  /**
102
337
  * The problems of a declared `ci.identity.cloud`, each with its JSON
@@ -137,15 +372,25 @@ export const TRADED_VARS = {
137
372
  gcp: ["GOOGLE_OAUTH_ACCESS_TOKEN", "CLOUDSDK_AUTH_ACCESS_TOKEN"],
138
373
  azure: ["AZURE_ACCESS_TOKEN", "AZURE_CLIENT_ID", "AZURE_TENANT_ID", "ARM_CLIENT_ID", "ARM_TENANT_ID", "AZURE_SUBSCRIPTION_ID", "ARM_SUBSCRIPTION_ID"],
139
374
  };
375
+ /**
376
+ * The variable the trade sets when a cloud names `targets` (#4332): the
377
+ * path of a JSON file, mode 0600 and outside the checkout, with each
378
+ * identity's credentials and which targets take it. {@link targetCloudEnv}
379
+ * reads it.
380
+ */
381
+ export const CLOUD_TARGETS_ENV = "CHANT_CLOUD_TARGETS";
140
382
  /**
141
383
  * The trade, a script for `node -e` with no dependency, so it runs before
142
384
  * the checkout and reads none of the change's files. argv 1 is the file it
143
385
  * writes and argv 2 its {@link TradeFormat}; {@link TRADE_SPEC_ENV} is the
144
386
  * spec. The token comes from a cloud's {@link idTokenVar} when the job has
145
387
  * it (GitLab's `id_tokens`), else from the forge's request URL with the
146
- * cloud's audience. A value that holds anything but printable ASCII with no
147
- * space is refused, so no answer can add a line of its own to the file. The
148
- * script never prints a token.
388
+ * cloud's audience, once per cloud. Each identity of a cloud (its own, and
389
+ * its targets', #4332) is traded with that one token. The job's own
390
+ * identity's variables go in the file; with targets, every identity's go in
391
+ * the {@link CLOUD_TARGETS_ENV} file too. A value that holds anything but
392
+ * printable ASCII with no space is refused, so no answer can add a line of
393
+ * its own to the file. The script never prints a token.
149
394
  */
150
395
  export const TRADE_JS = [
151
396
  'const fs=require("fs");',
@@ -164,27 +409,40 @@ export const TRADE_JS = [
164
409
  'const r=await fetch(u+(u.includes("?")?"&":"?")+"audience="+encodeURIComponent(aud),{headers:{authorization:"bearer "+k,accept:"application/json"}});',
165
410
  'if(!r.ok)throw new Error("the OIDC token request answered "+r.status);',
166
411
  'const v=json(await r.text(),"the OIDC token request").value;if(!clean(v))throw new Error("the OIDC token request gave no token");return v};',
167
- "const vars={},secrets=[],said=[];",
412
+ `const KEY=${JSON.stringify(IDENTITY_KEY)};`,
413
+ // #4332: a cloud's identities, its own first, then its targets', once each.
414
+ 'const ids=(c)=>{const s=spec[c],l=[];if(s[KEY[c]])l.push(s[KEY[c]]);for(const t of s.targets||[])if(!l.includes(t[KEY[c]]))l.push(t[KEY[c]]);if(!l.length)throw new Error(c+" names no identity");return l};',
415
+ "const vars={},secrets=[],said=[],by={};",
168
416
  "const run=async()=>{",
169
- "if(spec.aws){const a=spec.aws;const tok=await idToken(\"aws\",a.audience);",
417
+ 'if(spec.aws){const a=spec.aws;const tok=await idToken("aws",a.audience);by.aws={};',
170
418
  'const ep=trim(a.endpoint||process.env.AWS_ENDPOINT_URL_STS||("https://sts."+a.region+".amazonaws.com"+(a.region.startsWith("cn-")?".cn":"")));',
171
- 'const t=await post(ep+"/",{"content-type":"application/x-www-form-urlencoded",accept:"application/xml"},new URLSearchParams({Action:"AssumeRoleWithWebIdentity",Version:"2011-06-15",RoleArn:a.role,RoleSessionName:"chant-"+(process.env.GITHUB_RUN_ID||process.env.CI_JOB_ID||"job"),WebIdentityToken:tok}).toString());',
419
+ 'for(const role of ids("aws")){',
420
+ 'const t=await post(ep+"/",{"content-type":"application/x-www-form-urlencoded",accept:"application/xml"},new URLSearchParams({Action:"AssumeRoleWithWebIdentity",Version:"2011-06-15",RoleArn:role,RoleSessionName:"chant-"+(process.env.GITHUB_RUN_ID||process.env.CI_JOB_ID||"job"),WebIdentityToken:tok}).toString());',
172
421
  'const g=(k)=>{const m=new RegExp("<"+k+">([^<]+)</"+k+">").exec(t);return m?m[1].trim():undefined};',
173
- 'const id=g("AccessKeyId"),key=g("SecretAccessKey"),st=g("SessionToken");if(!clean(id)||!clean(key)||!clean(st))throw new Error("STS gave no credentials for "+a.role);',
174
- 'Object.assign(vars,{AWS_ACCESS_KEY_ID:id,AWS_SECRET_ACCESS_KEY:key,AWS_SESSION_TOKEN:st,AWS_REGION:a.region,AWS_DEFAULT_REGION:a.region});secrets.push(key,st);said.push(a.role)}',
175
- "if(spec.gcp){const c=spec.gcp;const tok=await idToken(\"gcp\",c.audience);",
422
+ 'const id=g("AccessKeyId"),key=g("SecretAccessKey"),st=g("SessionToken");if(!clean(id)||!clean(key)||!clean(st))throw new Error("STS gave no credentials for "+role);',
423
+ "by.aws[role]={AWS_ACCESS_KEY_ID:id,AWS_SECRET_ACCESS_KEY:key,AWS_SESSION_TOKEN:st,AWS_REGION:a.region,AWS_DEFAULT_REGION:a.region};secrets.push(key,st);said.push(role)}",
424
+ "if(a.role)Object.assign(vars,by.aws[a.role])}",
425
+ 'if(spec.gcp){const c=spec.gcp;const tok=await idToken("gcp",c.audience);by.gcp={};',
176
426
  'const sts=trim(c.stsEndpoint||"https://sts.googleapis.com"),iam=trim(c.iamEndpoint||"https://iamcredentials.googleapis.com");',
177
427
  'const fed=json(await post(sts+"/v1/token",{"content-type":"application/json"},JSON.stringify({grantType:"urn:ietf:params:oauth:grant-type:token-exchange",audience:"//iam.googleapis.com/"+c.provider,scope:"https://www.googleapis.com/auth/cloud-platform",requestedTokenType:"urn:ietf:params:oauth:token-type:access_token",subjectToken:tok,subjectTokenType:"urn:ietf:params:oauth:token-type:jwt"})),"Google STS").access_token;',
178
428
  'if(!clean(fed))throw new Error("Google STS gave no token");',
179
- 'const at=json(await post(iam+"/v1/projects/-/serviceAccounts/"+encodeURIComponent(c.serviceAccount)+":generateAccessToken",{"content-type":"application/json",authorization:"Bearer "+fed},JSON.stringify({scope:["https://www.googleapis.com/auth/cloud-platform"]})),"IAM credentials").accessToken;',
180
- 'if(!clean(at))throw new Error("IAM credentials gave no token for "+c.serviceAccount);',
181
- "Object.assign(vars,{GOOGLE_OAUTH_ACCESS_TOKEN:at,CLOUDSDK_AUTH_ACCESS_TOKEN:at});secrets.push(at);said.push(c.serviceAccount)}",
182
- "if(spec.azure){const z=spec.azure;const tok=await idToken(\"azure\",z.audience);",
429
+ 'for(const sa of ids("gcp")){',
430
+ 'const at=json(await post(iam+"/v1/projects/-/serviceAccounts/"+encodeURIComponent(sa)+":generateAccessToken",{"content-type":"application/json",authorization:"Bearer "+fed},JSON.stringify({scope:["https://www.googleapis.com/auth/cloud-platform"]})),"IAM credentials").accessToken;',
431
+ 'if(!clean(at))throw new Error("IAM credentials gave no token for "+sa);',
432
+ "by.gcp[sa]={GOOGLE_OAUTH_ACCESS_TOKEN:at,CLOUDSDK_AUTH_ACCESS_TOKEN:at};secrets.push(at);said.push(sa)}",
433
+ "if(c.serviceAccount)Object.assign(vars,by.gcp[c.serviceAccount])}",
434
+ 'if(spec.azure){const z=spec.azure;const tok=await idToken("azure",z.audience);by.azure={};',
183
435
  'const host=trim(z.authority||"https://login.microsoftonline.com");',
184
- 'const at=json(await post(host+"/"+encodeURIComponent(z.tenantId)+"/oauth2/v2.0/token",{"content-type":"application/x-www-form-urlencoded"},new URLSearchParams({client_id:z.clientId,scope:"https://management.azure.com/.default",grant_type:"client_credentials",client_assertion_type:"urn:ietf:params:oauth:client-assertion-type:jwt-bearer",client_assertion:tok}).toString()),"Entra").access_token;',
185
- 'if(!clean(at))throw new Error("Entra gave no token for "+z.clientId);',
186
- "Object.assign(vars,{AZURE_ACCESS_TOKEN:at,AZURE_CLIENT_ID:z.clientId,AZURE_TENANT_ID:z.tenantId,ARM_CLIENT_ID:z.clientId,ARM_TENANT_ID:z.tenantId});",
187
- "if(z.subscriptionId)Object.assign(vars,{AZURE_SUBSCRIPTION_ID:z.subscriptionId,ARM_SUBSCRIPTION_ID:z.subscriptionId});secrets.push(at);said.push(z.clientId)}",
436
+ 'for(const cid of ids("azure")){',
437
+ 'const at=json(await post(host+"/"+encodeURIComponent(z.tenantId)+"/oauth2/v2.0/token",{"content-type":"application/x-www-form-urlencoded"},new URLSearchParams({client_id:cid,scope:"https://management.azure.com/.default",grant_type:"client_credentials",client_assertion_type:"urn:ietf:params:oauth:client-assertion-type:jwt-bearer",client_assertion:tok}).toString()),"Entra").access_token;',
438
+ 'if(!clean(at))throw new Error("Entra gave no token for "+cid);',
439
+ "by.azure[cid]={AZURE_ACCESS_TOKEN:at,AZURE_CLIENT_ID:cid,AZURE_TENANT_ID:z.tenantId,ARM_CLIENT_ID:cid,ARM_TENANT_ID:z.tenantId,...(z.subscriptionId?{AZURE_SUBSCRIPTION_ID:z.subscriptionId,ARM_SUBSCRIPTION_ID:z.subscriptionId}:{})};secrets.push(at);said.push(cid)}",
440
+ "if(z.clientId)Object.assign(vars,by.azure[z.clientId])}",
441
+ // #4332: with targets, every identity's variables go in a file of their own, outside the checkout.
442
+ 'const clouds=Object.keys(by);if(clouds.some((c)=>(spec[c].targets||[]).length>0)){',
443
+ 'const doc={version:1,clouds:{}};for(const c of clouds){const s=spec[c];doc.clouds[c]={...(s[KEY[c]]?{default:s[KEY[c]]}:{}),targets:(s.targets||[]).map((t)=>({targets:t.targets,identity:t[KEY[c]]})),vars:by[c]}}',
444
+ 'const tf=trim(process.env.RUNNER_TEMP||process.env.TMPDIR||"/tmp")+"/chant-cloud-targets-"+require("crypto").randomBytes(8).toString("hex")+".json";',
445
+ `fs.writeFileSync(tf,JSON.stringify(doc),{mode:0o600,flag:"wx"});vars.${CLOUD_TARGETS_ENV}=tf}`,
188
446
  'for(const [k,v] of Object.entries(vars))if(!clean(v))throw new Error(k+" is not a value the job can hold");',
189
447
  'if(format==="github-env"){for(const s of secrets)console.log("::add-mask::"+s);fs.appendFileSync(out,Object.entries(vars).map(([k,v])=>k+"="+v+"\\n").join(""))}',
190
448
  'else fs.writeFileSync(out,Object.entries(vars).map(([k,v])=>"export "+k+"="+Q+v.split(Q).join(Q+"\\\\"+Q+Q)+Q+"\\n").join(""),{mode:0o600});',
@@ -217,3 +475,54 @@ export function gitlabTradeLines(extraUnset = []) {
217
475
  export function gitlabTradeIdTokens(spec) {
218
476
  return Object.fromEntries(tradedClouds(spec).map((c) => [idTokenVar(c), { aud: spec[c].audience }]));
219
477
  }
478
+ /**
479
+ * `env` for one target's tool (#4332): the job's environment with each
480
+ * cloud's credentials replaced by those of the identity `target` takes, as
481
+ * the trade left them in the {@link CLOUD_TARGETS_ENV} file. A target that
482
+ * no rule with `targets` covers keeps the job's own identity. Without the
483
+ * file (no cloud names targets), `env` as it is. terragucci's `rootRoleEnv`
484
+ * (roles.ts at INTENTIUS/terragucci ca05bd9).
485
+ */
486
+ export function targetCloudEnv(env, target, read = (p) => readFileSync(p, "utf-8")) {
487
+ const out = { ...env };
488
+ const path = env[CLOUD_TARGETS_ENV];
489
+ if (!path)
490
+ return out;
491
+ const file = readTargetsFile(path, read);
492
+ for (const cloud of CLOUDS) {
493
+ const c = file.clouds[cloud];
494
+ if (!c)
495
+ continue;
496
+ const id = targetIdentityIn(c, target);
497
+ if (id === undefined || id === c.default)
498
+ continue;
499
+ const vars = c.vars[id];
500
+ if (!vars)
501
+ throw new Error(`${CLOUD_TARGETS_ENV} (${path}) holds no ${cloud} credentials for ${id}`);
502
+ for (const k of TRADED_VARS[cloud])
503
+ delete out[k];
504
+ Object.assign(out, vars);
505
+ }
506
+ return out;
507
+ }
508
+ /**
509
+ * The identity `target` takes in one cloud of a {@link CloudTargetsFile}:
510
+ * the first entry whose globs match, else the job's own. Undefined when
511
+ * the job has none for that cloud.
512
+ */
513
+ export function targetIdentityIn(cloud, target) {
514
+ return cloud.targets.find((t) => t.targets.some((g) => targetGlobMatch(g, target)))?.identity ?? cloud.default;
515
+ }
516
+ /** The {@link CloudTargetsFile} at `path`, refused when it is not one. */
517
+ export function readTargetsFile(path, read = (p) => readFileSync(p, "utf-8")) {
518
+ let file;
519
+ try {
520
+ file = JSON.parse(read(path));
521
+ }
522
+ catch (err) {
523
+ throw new Error(`${CLOUD_TARGETS_ENV} names ${path}, which cannot be read as the trade's targets file: ${err instanceof Error ? err.message : String(err)}`);
524
+ }
525
+ if (!file || file.version !== 1 || typeof file.clouds !== "object" || file.clouds === null)
526
+ throw new Error(`${CLOUD_TARGETS_ENV} names ${path}, which is not a version 1 targets file`);
527
+ return file;
528
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"build.d.ts","sourceRoot":"","sources":["../../../src/cli/handlers/build.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAuOlD,wBAAsB,QAAQ,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAsJnE"}
1
+ {"version":3,"file":"build.d.ts","sourceRoot":"","sources":["../../../src/cli/handlers/build.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAqPlD,wBAAsB,QAAQ,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAsJnE"}
@@ -146,6 +146,22 @@ async function runGenerateComponents(ctx) {
146
146
  if (found && args.prLoop) {
147
147
  try {
148
148
  identity = await (await import("../../workspace/ci-cloud-identity.js")).declaredCloudIdentity(resolve(args.path), args.env ?? "production", plan?.member.name);
149
+ // #4332: the pull-request pipeline applies a member in one job, which may hold one apply identity per cloud.
150
+ const applies = args.applyOnMerge || args.applyBeforeMerge;
151
+ const problem = identity && applies ? (await import("../../ci-identity.js")).oneApplyIdentityProblem(identity.apply) : undefined;
152
+ if (problem)
153
+ throw new Error(`${problem}; the pull-request pipeline applies ${plan?.member.name ?? "the project"} in one job, so give its targets one apply identity, or apply them through Op waves with a job group per identity (cloudIdentityWave)`);
154
+ }
155
+ catch (err) {
156
+ console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
157
+ return 1;
158
+ }
159
+ }
160
+ // #4330: the identity a pull request environment's jobs trade for, from a rule that names those environments.
161
+ let prEnvIdentity;
162
+ if (found && args.prLoop && args.prEnv) {
163
+ try {
164
+ prEnvIdentity = await (await import("../../workspace/ci-cloud-identity.js")).declaredPrEnvCloudIdentity(resolve(args.path), plan?.member.name);
149
165
  }
150
166
  catch (err) {
151
167
  console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
@@ -170,7 +186,7 @@ async function runGenerateComponents(ctx) {
170
186
  : {}),
171
187
  // #3183: a plan job per pull request and an apply job per merge.
172
188
  // #4292: on the branch ci.branches routes the member to, when it routes it.
173
- ...(args.prLoop ? { prLoop: { ...prLoopOption(args, reportUrl, reviewRules, isolatedRunners), ...(plan?.branch ? { branch: plan.branch } : {}), ...(identity ? { identity } : {}) } } : {}),
189
+ ...(args.prLoop ? { prLoop: { ...prLoopOption(args, reportUrl, reviewRules, isolatedRunners), ...(plan?.branch ? { branch: plan.branch } : {}), ...(identity ? { identity } : {}), ...(prEnvIdentity ? { prEnvIdentity } : {}) } } : {}),
174
190
  // #4002: the job image and the lines that install what it lacks, chant itself included.
175
191
  ...(args.image ? { image: args.image } : {}),
176
192
  ...(args.beforeScript?.length ? { beforeScript: args.beforeScript } : {}),
@@ -25,8 +25,14 @@
25
25
  * `down` reads the environment's record on chant/lifecycle, checks it names
26
26
  * this pull request's environment, and tears down what `planTeardown` finds
27
27
  * under the record's marker, after dropping any entry whose marker is not
28
- * exactly that one. No gate holds it: closing the pull request is the
29
- * decision, as is the TTL the base set for `sweep`.
28
+ * exactly that one. The lexicons tear down what they own, and the command
29
+ * `ci.prEnvironment.teardown` names tears down the rest (#4330). No gate
30
+ * holds it: closing the pull request is the decision, as is the TTL the base
31
+ * set for `sweep`.
32
+ *
33
+ * The runs themselves are ../../components/pr-env-run.ts, which a product
34
+ * calls with an adapter of its own (#4330). This file supplies the
35
+ * components adapter and the CLI's ports.
30
36
  */
31
37
  import type { CommandContext } from "../registry.js";
32
38
  /** chant components pr-env <up|down|sweep> */
@@ -1 +1 @@
1
- {"version":3,"file":"pr-env.d.ts","sourceRoot":"","sources":["../../../src/cli/handlers/pr-env.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AA0CH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AASlD,8CAA8C;AAC9C,wBAAsB,kBAAkB,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAO7E"}
1
+ {"version":3,"file":"pr-env.d.ts","sourceRoot":"","sources":["../../../src/cli/handlers/pr-env.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AA2CH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAUlD,8CAA8C;AAC9C,wBAAsB,kBAAkB,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAO7E"}