@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
@@ -289,6 +289,33 @@
289
289
  "type": "string",
290
290
  "pattern": "^[1-9][0-9]*(m|h|d)$",
291
291
  "description": "How long a pull request's environment lives after its last apply, such as 72h, 90m or 7d. chant components pr-env sweep tears down one whose TTL passed. 72h when absent."
292
+ },
293
+ "teardown": {
294
+ "type": "object",
295
+ "description": "A command that tears down what no lexicon does, such as a Terraform root's copy of the environment (#4330). pr-env down and pr-env sweep run it after the ownership check, twice: CHANT_PR_ENV_STAGE=owned writes the candidates with their read-back markers to CHANT_PR_ENV_RESULT, and CHANT_PR_ENV_STAGE=teardown deletes the candidates in CHANT_PR_ENV_CANDIDATES and writes their outcomes. It runs without the forge token variables. Added in schema 1 by chant 0.124.45, so a declaration that uses it sets minReader to 0.124.45 or newer.",
296
+ "required": ["command"],
297
+ "additionalProperties": false,
298
+ "patternProperties": {
299
+ "^x-": true
300
+ },
301
+ "properties": {
302
+ "name": {
303
+ "type": "string",
304
+ "pattern": "^[a-z0-9][a-z0-9-]*$",
305
+ "description": "The name the teardown's outcomes are attributed to, as a lexicon's are. teardown when absent."
306
+ },
307
+ "command": {
308
+ "type": "array",
309
+ "minItems": 1,
310
+ "items": { "type": "string", "minLength": 1 },
311
+ "description": "Argv, run in the member's directory at the commit the job runs at. {env}, {pr} and {stage} are filled in."
312
+ },
313
+ "lexicons": {
314
+ "type": "array",
315
+ "items": { "type": "string", "minLength": 1 },
316
+ "description": "The lexicons the command tears down in place of, such as terraform. Their own teardown is left out, so one with no teardown execution does not leave the environment incomplete."
317
+ }
318
+ }
292
319
  }
293
320
  }
294
321
  }
@@ -363,7 +390,7 @@
363
390
  "ciCloudIdentityRules": {
364
391
  "type": "array",
365
392
  "minItems": 1,
366
- "description": "Rules, first match wins: a rule matches a pipeline when its environments match the pipeline's environment and its members match the pipeline's member. A rule with no environments matches every environment, and one with no members every member and a pipeline outside a member.",
393
+ "description": "Rules, first match wins: a rule matches a pipeline when its environments match the pipeline's environment and its members match the pipeline's member, and a target inside the member when its targets match the target too. A rule with no environments matches every environment, one with no members every member and a pipeline outside a member, and one with no targets every target.",
367
394
  "items": {
368
395
  "type": "object",
369
396
  "additionalProperties": false,
@@ -378,6 +405,14 @@
378
405
  "description": "Environment-name globs, such as prod or staging-*."
379
406
  },
380
407
  "members": { "$ref": "#/$defs/ciMemberGlobs" },
408
+ "targets": {
409
+ "type": "array",
410
+ "minItems": 1,
411
+ "uniqueItems": true,
412
+ "items": { "type": "string", "minLength": 1 },
413
+ "description": "Target globs inside the member (#4332), such as Terraform roots or Op wave run targets: * within one / segment, ** across segments, ? one character. The plan job trades for every plan identity the member's targets take, and an apply splits into one job per apply identity. Added in schema 1 by chant 0.124.45, so a declaration that uses it sets minReader to 0.124.45 or newer."
414
+ },
415
+ "name": { "type": "string", "pattern": "^[a-z0-9_-]+$", "description": "The name of the apply job group the rule's targets form (#4332). Default: from its first target glob. Added by chant 0.124.45." },
381
416
  "plan": { "type": "string", "minLength": 1, "description": "The identity the plan and record jobs take: a role ARN, a service account email or a client id." },
382
417
  "apply": { "type": "string", "minLength": 1, "description": "The identity the apply jobs take." }
383
418
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.124.44",
3
+ "version": "0.124.45",
4
4
  "description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -0,0 +1,275 @@
1
+ /**
2
+ * #4332: per-target cloud identities. A rule with `targets` covers only the
3
+ * targets inside a member those globs match. The plan trade holds every
4
+ * plan identity a member's targets take, the trade leaves each identity's
5
+ * credentials in the CHANT_CLOUD_TARGETS file, targetCloudEnv hands a
6
+ * target its own, and cloudIdentityGroups splits an apply into one job per
7
+ * apply identity. The trade tests run the generated `node -e` line against
8
+ * the local emulator (./__fixtures__/oidc-emulator.ts). Test values only.
9
+ */
10
+
11
+ import { execFile } from "node:child_process";
12
+ import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { promisify } from "node:util";
16
+ import { build } from "esbuild";
17
+ // @ts-ignore: picomatch has no types declaration
18
+ import picomatch from "picomatch";
19
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
20
+ import {
21
+ CLOUD_TARGETS_ENV,
22
+ cloudIdentityGroups,
23
+ cloudIdentityProblems,
24
+ cloudIdentityWave,
25
+ nameGlobMatch,
26
+ oneApplyIdentityProblem,
27
+ readTargetsFile,
28
+ resolveCloudIdentity,
29
+ resolveTargetCloudIdentity,
30
+ targetCloudEnv,
31
+ targetGlobMatch,
32
+ TRADE_SPEC_ENV,
33
+ tradeIdentities,
34
+ tradeLine,
35
+ type CloudIdentityDeclaration,
36
+ type CloudTradeSpec,
37
+ } from "./ci-identity";
38
+ import { assertOpWavesSpec, type OpWavesSpec } from "./op/op-waves";
39
+ import { emulatedCredentials, EMULATED_JWT_PREFIX, startOidcEmulator, type OidcEmulator } from "./__fixtures__/oidc-emulator";
40
+ import { REPO } from "./workspace/__fixtures__/contract-repo";
41
+
42
+ const run = promisify(execFile);
43
+
44
+ const role = (n: string) => `arn:aws:iam::111111111111:role/${n}`;
45
+
46
+ /** terragucci's shape: prod and staging roots plan and apply as their environment's roles, the rest as the member's own. */
47
+ const DECL: CloudIdentityDeclaration = {
48
+ aws: {
49
+ region: "eu-west-1",
50
+ rules: [
51
+ { environments: ["prod"], members: ["infra"], targets: ["envs/prod/**"], plan: role("prod-plan"), apply: role("prod-apply") },
52
+ { environments: ["prod"], members: ["infra"], targets: ["envs/staging/**"], name: "staging", plan: role("staging-plan"), apply: role("staging-apply") },
53
+ { environments: ["prod"], plan: role("own-plan"), apply: role("own-apply") },
54
+ { targets: ["unreachable/**"], plan: role("late-plan"), apply: role("late-apply") },
55
+ ],
56
+ },
57
+ gcp: {
58
+ provider: "projects/123/locations/global/workloadIdentityPools/ci/providers/forge",
59
+ rules: [{ targets: ["envs/prod/**"], plan: "prod-plan@p.iam.gserviceaccount.com", apply: "prod-apply@p.iam.gserviceaccount.com" }],
60
+ },
61
+ };
62
+
63
+ const ROOTS = ["envs/prod/net", "envs/staging/net", "shared/dns", "envs/prod/app"];
64
+
65
+ describe("which identity a target takes (#4332)", () => {
66
+ test("the first rule that covers the environment, the member and the target wins, per cloud", () => {
67
+ const prod = resolveTargetCloudIdentity(DECL, "prod", "infra", "envs/prod/net")!;
68
+ expect(prod.plan.aws).toEqual({ role: role("prod-plan"), region: "eu-west-1", audience: "sts.amazonaws.com" });
69
+ expect(prod.apply.gcp!.serviceAccount).toBe("prod-apply@p.iam.gserviceaccount.com");
70
+ const staging = resolveTargetCloudIdentity(DECL, "prod", "infra", "envs/staging/net")!;
71
+ expect(staging.apply.aws!.role).toBe(role("staging-apply"));
72
+ expect(staging.apply.gcp).toBeUndefined();
73
+ expect(resolveTargetCloudIdentity(DECL, "prod", "infra", "shared/dns")!.plan.aws!.role).toBe(role("own-plan"));
74
+ });
75
+
76
+ test("a rule with targets covers no target of another member, and a rule after one without targets is never reached", () => {
77
+ expect(resolveTargetCloudIdentity(DECL, "prod", "web", "envs/prod/net")!.plan.aws!.role).toBe(role("own-plan"));
78
+ expect(resolveTargetCloudIdentity(DECL, "prod", "infra", "unreachable/x")!.plan.aws!.role).toBe(role("own-plan"));
79
+ expect(resolveTargetCloudIdentity(DECL, "dev", "infra", "unreachable/x")!.plan.aws!.role).toBe(role("late-plan"));
80
+ expect(resolveTargetCloudIdentity({ aws: DECL.aws }, "dev", "infra", "shared/dns")).toBeUndefined();
81
+ });
82
+
83
+ test("the pipeline's trade names its own identity and, before it, the targets that take another", () => {
84
+ const p = resolveCloudIdentity(DECL, "prod", "infra")!;
85
+ expect(p.plan.aws).toEqual({
86
+ role: role("own-plan"),
87
+ targets: [
88
+ { targets: ["envs/prod/**"], role: role("prod-plan") },
89
+ { targets: ["envs/staging/**"], role: role("staging-plan") },
90
+ ],
91
+ region: "eu-west-1",
92
+ audience: "sts.amazonaws.com",
93
+ });
94
+ // GCP has no rule for the member as a whole: targets alone.
95
+ expect(p.plan.gcp).toMatchObject({ targets: [{ targets: ["envs/prod/**"], serviceAccount: "prod-plan@p.iam.gserviceaccount.com" }] });
96
+ expect(p.plan.gcp!.serviceAccount).toBeUndefined();
97
+ expect(tradeIdentities(p.plan)).toEqual([role("own-plan"), role("prod-plan"), role("staging-plan"), "prod-plan@p.iam.gserviceaccount.com"]);
98
+ // The plan trade names no apply identity.
99
+ for (const id of tradeIdentities(p.apply)) expect(JSON.stringify(p.plan)).not.toContain(id);
100
+ // Without targets rules, as #4296 resolved it.
101
+ expect(resolveCloudIdentity(DECL, "prod", "web")!.plan.aws).toEqual({ role: role("own-plan"), region: "eu-west-1", audience: "sts.amazonaws.com" });
102
+ });
103
+
104
+ test("a plan identity that any rule applies as is still refused, targets or not", () => {
105
+ expect(cloudIdentityProblems(DECL)).toEqual([]);
106
+ const crossed = cloudIdentityProblems({ aws: { rules: [{ targets: ["a/**"], plan: "x", apply: "y" }, { plan: "y", apply: "z" }] } });
107
+ expect(crossed).toEqual([{ pointer: "/ci/identity/cloud/aws/rules/1", message: expect.stringContaining("plans as y, which another rule applies as") }]);
108
+ });
109
+ });
110
+
111
+ describe("one apply job per identity (#4332)", () => {
112
+ test("targets that take the same identities in every cloud are one group, named from the rule, default last", () => {
113
+ const groups = cloudIdentityGroups(DECL, "prod", "infra", ROOTS);
114
+ expect(groups.map((g) => [g.name, g.targets])).toEqual([
115
+ ["envs-prod", ["envs/prod/net", "envs/prod/app"]],
116
+ ["staging", ["envs/staging/net"]],
117
+ ["default", ["shared/dns"]],
118
+ ]);
119
+ expect(tradeIdentities(groups[0]!.identity!.apply)).toEqual([role("prod-apply"), "prod-apply@p.iam.gserviceaccount.com"]);
120
+ expect(tradeIdentities(groups[2]!.identity!.apply)).toEqual([role("own-apply")]);
121
+ });
122
+
123
+ test("two rules whose names collide get -2, and targets no rule covers form a group with no identity", () => {
124
+ const decl: CloudIdentityDeclaration = { aws: { rules: [{ targets: ["a/*"], name: "x", plan: "p1", apply: "a1" }, { targets: ["b/*"], name: "x", plan: "p2", apply: "a2" }] } };
125
+ expect(cloudIdentityGroups(decl, "prod", undefined, ["a/1", "b/1", "c/1"])).toEqual([
126
+ expect.objectContaining({ name: "x", targets: ["a/1"] }),
127
+ expect.objectContaining({ name: "x-2", targets: ["b/1"] }),
128
+ { name: "default", targets: ["c/1"] },
129
+ ]);
130
+ });
131
+
132
+ test("an Op wave's deciding job trades every plan identity and each group's job its apply identities alone", () => {
133
+ const wave = cloudIdentityWave(DECL, "prod", "infra", ROOTS);
134
+ expect(wave.cloud).toEqual(resolveCloudIdentity(DECL, "prod", "infra")!.plan);
135
+ expect(wave.jobs!.map((j) => [j.name, j.runs, tradeIdentities(j.cloud!)])).toEqual([
136
+ ["envs-prod", ["envs/prod/net", "envs/prod/app"], [role("prod-apply"), "prod-apply@p.iam.gserviceaccount.com"]],
137
+ ["staging", ["envs/staging/net"], [role("staging-apply")]],
138
+ ["default", ["shared/dns"], [role("own-apply")]],
139
+ ]);
140
+ const spec: OpWavesSpec = { name: "tf-apply", op: "tf-apply", plan: ["plan", "{target}", "{plan}"], apply: ["apply", "{target}"], waves: [{ name: "wave-1", runs: ROOTS.map((target) => ({ target })), ...wave }] } as OpWavesSpec;
141
+ expect(() => assertOpWavesSpec(spec)).not.toThrow();
142
+ });
143
+
144
+ test("with one group the wave's one job trades its apply identities and has no jobs", () => {
145
+ expect(cloudIdentityWave(DECL, "prod", "infra", ["envs/prod/net"])).toEqual({ cloud: resolveTargetCloudIdentity(DECL, "prod", "infra", "envs/prod/net")!.apply });
146
+ expect(cloudIdentityWave(undefined, "prod", "infra", ["x"])).toEqual({});
147
+ });
148
+
149
+ test("an Op wave's cloud that names no identity is refused", () => {
150
+ const spec = { name: "w", op: "w", plan: ["p", "{plan}"], apply: ["a"], waves: [{ name: "one", runs: [{ target: "t" }], cloud: { aws: { region: "eu-west-1", audience: "sts.amazonaws.com" } } }] } as unknown as OpWavesSpec;
151
+ expect(() => assertOpWavesSpec(spec)).toThrow(/cloud\.aws must give an audience and a role/);
152
+ expect(() => assertOpWavesSpec({ ...spec, waves: [{ ...spec.waves[0]!, cloud: { k8s: {} } as never }] })).toThrow(/k8s is not a cloud/);
153
+ });
154
+
155
+ test("one job may hold one apply identity per cloud", () => {
156
+ expect(oneApplyIdentityProblem(resolveCloudIdentity(DECL, "prod", "web")!.apply)).toBeUndefined();
157
+ expect(oneApplyIdentityProblem(resolveCloudIdentity(DECL, "prod", "infra")!.apply)).toMatch(/3 aws apply identities/);
158
+ });
159
+ });
160
+
161
+ describe("the glob grammars (#4332)", () => {
162
+ test("member and environment globs read as picomatch reads the ciMemberGlobs grammar", () => {
163
+ const names = ["api", "web", "prod", "prod-eu", "staging-eu", "staging", "a1", "b2", "app-x"];
164
+ const globs = ["api", "prod-*", "staging-*", "*", "a?", "[ab][0-9]", "{api,web}", "app-{x,y}", "!prod*", "{a,b}{1,2}", "*-eu"];
165
+ for (const g of globs) for (const n of names) expect([g, n, nameGlobMatch(g, n)]).toEqual([g, n, (picomatch(g) as (s: string) => boolean)(n)]);
166
+ expect(nameGlobMatch("[!a]*", "web")).toBe(true);
167
+ expect(nameGlobMatch("[!a]*", "api")).toBe(false);
168
+ });
169
+
170
+ test("target globs read as the terraform lexicon's unit globs", () => {
171
+ expect(targetGlobMatch("envs/prod/**", "envs/prod/net")).toBe(true);
172
+ expect(targetGlobMatch("envs/prod/**", "envs/prod")).toBe(false);
173
+ expect(targetGlobMatch("envs/*/net", "envs/prod/net")).toBe(true);
174
+ expect(targetGlobMatch("envs/*", "envs/prod/net")).toBe(false);
175
+ expect(targetGlobMatch("a/**/b", "a/b")).toBe(true);
176
+ expect(targetGlobMatch("a/?", "a/b")).toBe(true);
177
+ expect(targetGlobMatch("envs/{a,b}", "envs/a")).toBe(false);
178
+ });
179
+ });
180
+
181
+ const dirs: string[] = [];
182
+ let emu: OidcEmulator;
183
+ beforeAll(async () => {
184
+ emu = await startOidcEmulator();
185
+ });
186
+ afterAll(async () => {
187
+ await emu.close();
188
+ for (const d of dirs) rmSync(d, { recursive: true, force: true });
189
+ });
190
+
191
+ const BASE = { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: process.env.HOME ?? "/tmp" };
192
+
193
+ /** Run the trade line as a GitHub step does, with RUNNER_TEMP its own directory. */
194
+ async function trade(spec: CloudTradeSpec): Promise<{ out: string; stderr: string; stdout: string; temp: string }> {
195
+ const temp = mkdtempSync(join(tmpdir(), "chant-trade-targets-"));
196
+ dirs.push(temp);
197
+ const file = join(temp, "github-env");
198
+ writeFileSync(file, "");
199
+ const r = await run("sh", ["-c", tradeLine(file, "github-env")], { env: { ...BASE, ...emu.requestEnv, RUNNER_TEMP: temp, [TRADE_SPEC_ENV]: JSON.stringify(emu.pointAt(spec)) } });
200
+ return { out: readFileSync(file, "utf-8"), stderr: r.stderr, stdout: r.stdout, temp };
201
+ }
202
+
203
+ const envOf = (lines: string): Record<string, string> => Object.fromEntries(lines.split("\n").filter(Boolean).map((l) => [l.slice(0, l.indexOf("=")), l.slice(l.indexOf("=") + 1)]));
204
+
205
+ describe("the plan trade with targets, run against the emulator (#4332)", () => {
206
+ test("it trades one token per cloud for every plan identity, keeps the job's own in GITHUB_ENV and each in the targets file", async () => {
207
+ const before = { minted: emu.log.minted.length, assumed: emu.log.assumed.length, impersonated: emu.log.impersonated.length };
208
+ const plan = resolveCloudIdentity(DECL, "prod", "infra")!.plan;
209
+ const r = await trade(plan);
210
+ expect(r.stderr).not.toContain("failed");
211
+ // One minted token per cloud, however many identities it is traded for.
212
+ expect(emu.log.minted.slice(before.minted)).toEqual(["sts.amazonaws.com", "https://iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/ci/providers/forge"]);
213
+ expect(emu.log.assumed.slice(before.assumed).map((a) => a.role)).toEqual([role("own-plan"), role("prod-plan"), role("staging-plan")]);
214
+ expect(emu.log.impersonated.slice(before.impersonated)).toEqual(["prod-plan@p.iam.gserviceaccount.com"]);
215
+ const env = envOf(r.out);
216
+ expect(env.AWS_ACCESS_KEY_ID).toBe(emulatedCredentials(role("own-plan")).accessKeyId);
217
+ // GCP names no identity of the job's own, so the job's environment has none.
218
+ expect(env.GOOGLE_OAUTH_ACCESS_TOKEN).toBeUndefined();
219
+ const path = env[CLOUD_TARGETS_ENV]!;
220
+ expect(path.startsWith(r.temp)).toBe(true);
221
+ expect(statSync(path).mode & 0o777).toBe(0o600);
222
+ const file = readTargetsFile(path);
223
+ expect(file.clouds.aws!.default).toBe(role("own-plan"));
224
+ expect(Object.keys(file.clouds.aws!.vars)).toEqual([role("own-plan"), role("prod-plan"), role("staging-plan")]);
225
+ // Every secret is masked, and the minted token is written nowhere.
226
+ expect(r.stdout).toContain(`::add-mask::${emulatedCredentials(role("staging-plan")).token}`);
227
+ expect(r.out + readFileSync(path, "utf-8") + r.stdout + r.stderr).not.toContain(EMULATED_JWT_PREFIX);
228
+ // No apply identity was asked for.
229
+ for (const id of tradeIdentities(resolveCloudIdentity(DECL, "prod", "infra")!.apply)) expect(emu.log.assumed.map((a) => a.role)).not.toContain(id);
230
+
231
+ // targetCloudEnv hands each target its own identity, as terragucci's rootRoleEnv did.
232
+ const job = { ...env, PATH: "/bin", AWS_ROLE_ARN: undefined };
233
+ const prod = targetCloudEnv(job, "envs/prod/net");
234
+ expect(prod.AWS_ACCESS_KEY_ID).toBe(emulatedCredentials(role("prod-plan")).accessKeyId);
235
+ expect(prod.AWS_SESSION_TOKEN).toBe(emulatedCredentials(role("prod-plan")).token);
236
+ expect(prod.AWS_REGION).toBe("eu-west-1");
237
+ expect(prod.GOOGLE_OAUTH_ACCESS_TOKEN).toBe(`ya29.${emulatedCredentials("prod-plan@p.iam.gserviceaccount.com").token}`);
238
+ expect(prod.PATH).toBe("/bin");
239
+ expect(targetCloudEnv(job, "envs/staging/net").AWS_ACCESS_KEY_ID).toBe(emulatedCredentials(role("staging-plan")).accessKeyId);
240
+ const shared = targetCloudEnv(job, "shared/dns");
241
+ expect(shared.AWS_ACCESS_KEY_ID).toBe(emulatedCredentials(role("own-plan")).accessKeyId);
242
+ expect(shared.GOOGLE_OAUTH_ACCESS_TOKEN).toBeUndefined();
243
+ });
244
+
245
+ test("an apply group's trade names one identity per cloud and leaves no targets file", async () => {
246
+ const before = emu.log.assumed.length;
247
+ const group = cloudIdentityGroups(DECL, "prod", "infra", ROOTS)[1]!;
248
+ const r = await trade(group.identity!.apply);
249
+ expect(emu.log.assumed.slice(before).map((a) => a.role)).toEqual([role("staging-apply")]);
250
+ expect(envOf(r.out)[CLOUD_TARGETS_ENV]).toBeUndefined();
251
+ expect(targetCloudEnv(envOf(r.out), "envs/staging/net")).toEqual(envOf(r.out));
252
+ });
253
+
254
+ test("a targets file that is not one is refused by name", () => {
255
+ expect(() => targetCloudEnv({ [CLOUD_TARGETS_ENV]: "/nowhere.json" }, "x", () => "{}")).toThrow(/not a version 1 targets file/);
256
+ expect(() => targetCloudEnv({ [CLOUD_TARGETS_ENV]: "/nowhere.json" }, "x", () => "nope")).toThrow(/cannot be read/);
257
+ });
258
+ });
259
+
260
+ describe("the ci-identity bundle (#4332)", () => {
261
+ test("holds ci-identity.ts alone, with no glob library, and stays small", async () => {
262
+ const result = await build({
263
+ entryPoints: [join(REPO, "packages", "core", "src", "ci-identity.ts")],
264
+ bundle: true,
265
+ platform: "node",
266
+ format: "esm",
267
+ write: false,
268
+ metafile: true,
269
+ outdir: "out",
270
+ logLevel: "silent",
271
+ });
272
+ expect(Object.keys(result.metafile.inputs).map((i) => i.replace(/^.*?packages\//, "packages/"))).toEqual(["packages/core/src/ci-identity.ts"]);
273
+ expect(result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0)).toBeLessThan(24 * 1024);
274
+ });
275
+ });