@intentius/chant-lexicon-github 0.124.44 → 0.124.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/generate-op-waves-pipeline.d.ts +7 -0
- package/dist/components/generate-op-waves-pipeline.d.ts.map +1 -1
- package/dist/components/generate-op-waves-pipeline.js +26 -5
- package/dist/components/generate-pipeline.d.ts.map +1 -1
- package/dist/components/generate-pipeline.js +12 -4
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/package.json +2 -2
- package/src/components/generate-op-waves-pipeline.identity.test.ts +141 -0
- package/src/components/generate-op-waves-pipeline.ts +28 -5
- package/src/components/generate-pipeline.ts +12 -4
- package/src/components/generate-pr-env-pipeline.test.ts +28 -0
|
@@ -34,6 +34,13 @@
|
|
|
34
34
|
* environment, variables over the wave's, setup after the wave's, and id
|
|
35
35
|
* tokens. A job with id tokens gets `permissions:` of its own, the
|
|
36
36
|
* workflow's plus `id-token: write`, and each audience as `<NAME>_AUDIENCE`.
|
|
37
|
+
*
|
|
38
|
+
* A job whose wave or group gives `cloud` (#4332, `cloudIdentityWave` in
|
|
39
|
+
* `@intentius/chant/ci-identity`) gets `id-token: write` and starts with a
|
|
40
|
+
* step that trades the job's OIDC token for those identities, before the
|
|
41
|
+
* checkout. Every later run step starts with an unset of the variables a
|
|
42
|
+
* token is requested with, so the code the job runs holds no OIDC token
|
|
43
|
+
* and cannot request one for another group's identity.
|
|
37
44
|
*/
|
|
38
45
|
import { type OpWavesSpec } from "@intentius/chant/op/op-waves";
|
|
39
46
|
import type { OpWavesPipelineOptions, OpWavesPipelineResult } from "@intentius/chant/lexicon";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"generate-op-waves-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-waves-pipeline.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"generate-op-waves-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-waves-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AAEH,OAAO,EAAoE,KAAK,WAAW,EAAE,MAAM,8BAA8B,CAAC;AAClI,OAAO,KAAK,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AAiB9F,OAAO,EAAsB,KAAK,mBAAmB,EAAE,MAAM,wBAAwB,CAAC;AAEtF,yFAAyF;AACzF,eAAO,MAAM,cAAc,YAAW,CAAC;AAEvC,oEAAoE;AACpE,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,CAAC,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAE1F;AAED,qEAAqE;AACrE,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,WAAW,EACjB,OAAO,EAAE,sBAAsB,GAC9B;IAAE,GAAG,EAAE,mBAAmB,CAAC;IAAC,IAAI,EAAE,qBAAqB,CAAC,MAAM,CAAC,CAAC;IAAC,SAAS,CAAC,EAAE,mBAAmB,CAAC;IAAC,QAAQ,CAAC,EAAE,mBAAmB,CAAA;CAAE,CAgEpI;AAoDD,4EAA4E;AAC5E,wBAAgB,6BAA6B,CAAC,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,sBAAsB,GAAG,qBAAqB,CAMvH"}
|
|
@@ -34,9 +34,17 @@
|
|
|
34
34
|
* environment, variables over the wave's, setup after the wave's, and id
|
|
35
35
|
* tokens. A job with id tokens gets `permissions:` of its own, the
|
|
36
36
|
* workflow's plus `id-token: write`, and each audience as `<NAME>_AUDIENCE`.
|
|
37
|
+
*
|
|
38
|
+
* A job whose wave or group gives `cloud` (#4332, `cloudIdentityWave` in
|
|
39
|
+
* `@intentius/chant/ci-identity`) gets `id-token: write` and starts with a
|
|
40
|
+
* step that trades the job's OIDC token for those identities, before the
|
|
41
|
+
* checkout. Every later run step starts with an unset of the variables a
|
|
42
|
+
* token is requested with, so the code the job runs holds no OIDC token
|
|
43
|
+
* and cannot request one for another group's identity.
|
|
37
44
|
*/
|
|
38
45
|
import { notifyVariables, opWaveGroupOf, opWaveJobs, opWavesResumeCommand } from "@intentius/chant/op/op-waves";
|
|
39
46
|
import { assertEnvironment, assertSetupSteps, CI_IMAGE, dirnameOf, environmentDoc, GITHUB_JOB_TOKEN, githubRunsOn, githubSecretEnv, idTokenAudienceEnv, nonEmpty, pushBranches, setupStepDoc, } from "@intentius/chant/ci-jobs";
|
|
47
|
+
import { OIDC_REQUEST_VARS, TRADE_SPEC_ENV, tradeLine } from "@intentius/chant/ci-identity";
|
|
40
48
|
import { checkoutStep, concurrency, downloadArtifactStep, keepRecordStep, runSteps } from "./ci-jobs.js";
|
|
41
49
|
import { emitOpPipelineYAML } from "./generate-op-pipeline.js";
|
|
42
50
|
/** The default image: it carries git, which the runner needs to read the base commit. */
|
|
@@ -66,25 +74,30 @@ export function buildGithubOpWavesDoc(spec, options) {
|
|
|
66
74
|
const wave = spec.waves[job.wave - 1];
|
|
67
75
|
// #3914: a job group's job runs with the group's settings over the wave's.
|
|
68
76
|
const group = opWaveGroupOf(spec, job);
|
|
69
|
-
|
|
77
|
+
// #4332: the job's cloud identities, traded before the checkout.
|
|
78
|
+
const cloud = group?.cloud ?? wave.cloud;
|
|
79
|
+
const steps = [...(cloud ? [tradeStep(cloud)] : []), checkoutStep(true)];
|
|
70
80
|
if (job.kind === "share") {
|
|
71
81
|
steps.push(downloadArtifactStep(`Download wave ${job.wave}'s decision`, opWaveRecordArtifact(spec, job.wave), dirnameOf(job.record)));
|
|
72
82
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
83
|
+
const later = [];
|
|
84
|
+
later.push(...[...(wave.setup ?? []), ...(group?.setup ?? [])].map(setupStepDoc), ...runSteps(options.beforeScript ?? []));
|
|
85
|
+
later.push({ name: describeJob(job.kind, job.wave, wave.name, job.share, job.group), run: job.command.join(" ") });
|
|
86
|
+
later.push(...runSteps(options.extraScript ?? []));
|
|
87
|
+
steps.push(...(cloud ? later.map(withoutTokenRequest) : later));
|
|
76
88
|
if (job.kind === "decide") {
|
|
77
89
|
steps.push(keepRecordStep(`Keep wave ${job.wave}'s decision`, opWaveRecordArtifact(spec, job.wave), job.record));
|
|
78
90
|
}
|
|
79
91
|
const environment = group?.environment ?? wave.environment;
|
|
80
92
|
const idTokens = group?.idTokens ?? wave.idTokens;
|
|
93
|
+
const idToken = idTokens !== undefined || cloud !== undefined;
|
|
81
94
|
jobsDoc[job.jobName] = {
|
|
82
95
|
"runs-on": githubRunsOn(group?.runner ?? wave.runner ?? spec.runner),
|
|
83
96
|
...(job.needs.length > 0 ? { needs: job.needs } : {}),
|
|
84
97
|
container: image,
|
|
85
98
|
...(environment ? { environment: environmentDoc(environment) } : {}),
|
|
86
99
|
// #3914: a job's own permissions replace the workflow's, so they repeat them.
|
|
87
|
-
...(
|
|
100
|
+
...(idToken ? { permissions: { ...permissions, "id-token": "write" } } : {}),
|
|
88
101
|
...nonEmpty("env", group || idTokens ? { ...wave.variables, ...group?.variables, ...idTokenAudienceEnv(idTokens) } : wave.variables),
|
|
89
102
|
steps,
|
|
90
103
|
};
|
|
@@ -137,6 +150,14 @@ function buildPlansDoc(spec, options) {
|
|
|
137
150
|
jobsDoc: { "record-plans": { "runs-on": githubRunsOn(spec.runner), container: options.image ?? OP_WAVES_IMAGE, steps } },
|
|
138
151
|
};
|
|
139
152
|
}
|
|
153
|
+
/** The step that trades the job's OIDC token for `cloud` (#4332), writing the credentials to GITHUB_ENV. */
|
|
154
|
+
function tradeStep(cloud) {
|
|
155
|
+
return { name: "Trade the job's OIDC token for its cloud identities", env: { [TRADE_SPEC_ENV]: JSON.stringify(cloud) }, run: tradeLine("$GITHUB_ENV", "github-env") };
|
|
156
|
+
}
|
|
157
|
+
/** A run step after the trade (#4332), with the token request variables unset first. A `uses:` step is left as it is. */
|
|
158
|
+
function withoutTokenRequest(step) {
|
|
159
|
+
return typeof step.run === "string" ? { ...step, run: `unset ${OIDC_REQUEST_VARS.join(" ")}\n${step.run}` } : step;
|
|
160
|
+
}
|
|
140
161
|
function describeJob(kind, wave, name, share, group) {
|
|
141
162
|
if (kind === "decide")
|
|
142
163
|
return `Plan wave ${wave} (${name}) and decide its gate`;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"generate-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwDG;AAEH,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,oCAAoC,CAAC;AA4BjG,OAAO,KAAK,EACV,oBAAoB,IAAI,YAAY,EACpC,wBAAwB,IAAI,qBAAqB,EACjD,uBAAuB,IAAI,oBAAoB,EAEhD,MAAM,0BAA0B,CAAC;AAIlC,YAAY,EAAE,YAAY,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,CAAC;AAE1E;;;;;;GAMG;AACH,MAAM,WAAW,iBAAiB;IAChC;;;;;OAKG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,6FAA6F;IAC7F,WAAW,EAAE,MAAM,CAAC;IACpB,8DAA8D;IAC9D,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC5B,mIAAmI;IACnI,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC;;;;;;OAMG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,qDAAqD;IACrD,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,uEAAuE;IACvE,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,0DAA0D;IAC1D,IAAI,EAAE,YAAY,EAAE,CAAC;CACtB;
|
|
1
|
+
{"version":3,"file":"generate-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwDG;AAEH,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,oCAAoC,CAAC;AA4BjG,OAAO,KAAK,EACV,oBAAoB,IAAI,YAAY,EACpC,wBAAwB,IAAI,qBAAqB,EACjD,uBAAuB,IAAI,oBAAoB,EAEhD,MAAM,0BAA0B,CAAC;AAIlC,YAAY,EAAE,YAAY,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,CAAC;AAE1E;;;;;;GAMG;AACH,MAAM,WAAW,iBAAiB;IAChC;;;;;OAKG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,6FAA6F;IAC7F,WAAW,EAAE,MAAM,CAAC;IACpB,8DAA8D;IAC9D,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC5B,mIAAmI;IACnI,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC;;;;;;OAMG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,qDAAqD;IACrD,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,uEAAuE;IACvE,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,0DAA0D;IAC1D,IAAI,EAAE,YAAY,EAAE,CAAC;CACtB;AAmcD;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,CAWpD;AAED;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,iBAAiB,CA+JnB;AA4CD;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,iBAAiB,GAAG,MAAM,CAE/D;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,oBAAoB,CAGtB"}
|
|
@@ -382,17 +382,24 @@ function prEnvGithubJobs(forge, image, beforeScript, extraScript, loop, member)
|
|
|
382
382
|
const writes = { contents: "write", "pull-requests": "write", statuses: "write" };
|
|
383
383
|
const keep = (name) => ({ name: `Keep the ${name} report`, if: "always()", uses: UPLOAD_ARTIFACT, with: { name: `chant-pr-${name}`, path: reportPath, "if-no-files-found": "ignore", "include-hidden-files": true } });
|
|
384
384
|
const opened = "github.event_name == 'pull_request' && github.event.action != 'closed'";
|
|
385
|
-
// #
|
|
386
|
-
const
|
|
385
|
+
// #4330: env-up, env-down and env-sweep trade the job's OIDC token for the pull request environments' identity, before the checkout.
|
|
386
|
+
const identity = loop.prEnvIdentity;
|
|
387
|
+
const tradeStep = identity
|
|
388
|
+
? [{ name: "Trade the job's OIDC token for the pull request environment's identity", ...(rooted ? {} : { "working-directory": "." }), env: { [TRADE_SPEC_ENV]: JSON.stringify(identity) }, run: tradeLine("$GITHUB_ENV", "github-env") }]
|
|
389
|
+
: [];
|
|
390
|
+
const withIdToken = (permissions) => (identity ? { ...permissions, "id-token": "write" } : permissions);
|
|
391
|
+
// #4295: env-up runs the change's code and holds no forge token while it does; #4330: nor the token request variables.
|
|
392
|
+
const drop = dropForgeTokens("sh", identity ? OIDC_REQUEST_VARS : []);
|
|
387
393
|
const headStep = (line, name, env) => ({ ...(name ? { name } : {}), ...(env ? { env } : {}), run: `${drop}\n${line}` });
|
|
388
394
|
const upJob = {
|
|
389
395
|
if: opened,
|
|
390
396
|
"runs-on": GITHUB_RUNNER,
|
|
391
397
|
container: image,
|
|
392
398
|
concurrency: concurrency(group),
|
|
393
|
-
permissions: { contents: "read" },
|
|
399
|
+
permissions: withIdToken({ contents: "read" }),
|
|
394
400
|
outputs: handoverOutputs(PR_ENV_UP_STEP_ID, forge),
|
|
395
401
|
steps: [
|
|
402
|
+
...tradeStep,
|
|
396
403
|
{
|
|
397
404
|
name: "Check out the change",
|
|
398
405
|
...(rooted ? {} : { "working-directory": "." }),
|
|
@@ -431,8 +438,9 @@ function prEnvGithubJobs(forge, image, beforeScript, extraScript, loop, member)
|
|
|
431
438
|
"runs-on": GITHUB_RUNNER,
|
|
432
439
|
container: image,
|
|
433
440
|
concurrency: concurrency(groupName),
|
|
434
|
-
permissions: writes,
|
|
441
|
+
permissions: withIdToken(writes),
|
|
435
442
|
steps: [
|
|
443
|
+
...tradeStep,
|
|
436
444
|
{ uses: actionRef("actions/checkout"), with: { "fetch-depth": 0, ...(ref ? { ref } : {}) } },
|
|
437
445
|
...runSteps(prLoopSetup(image)),
|
|
438
446
|
...runSteps(beforeScript),
|
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "31e716129909d295783c66980716d2d3077348f8e6c9f9fc4f31707aab742d1e",
|
|
5
5
|
"meta.json": "c50e0ef3845c370c3ad2210b8bba455980417c0f818aa6ed46e06d4fb8b1b9f9",
|
|
6
6
|
"types/index.d.ts": "0ead95536e947ddfb98a5acf0ddb88547913b5a53a342df67711c59266f84b3e",
|
|
7
7
|
"rules/deprecated-action-version.ts": "a9ba7cc924ff1c7055110416d8989273690415bfb5e8dcc32dc9431b9351f5ca",
|
|
@@ -78,5 +78,5 @@
|
|
|
78
78
|
"skills/chant-github-patterns.md": "bb3abef289a8fdfcf07d6bb2d7289dcb2f38bc0cb0321ea320b78b45a6f548c0",
|
|
79
79
|
"skills/chant-github-security.md": "aab111cb0871cad30281ce48d7da23663689619351029219e2be019a1a61e394"
|
|
80
80
|
},
|
|
81
|
-
"composite": "
|
|
81
|
+
"composite": "293318381369a23a408795bd0404ffc1810465a35bbc984e8ef5aedfaff31f63"
|
|
82
82
|
}
|
package/dist/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-github",
|
|
3
|
-
"version": "0.124.
|
|
3
|
+
"version": "0.124.46",
|
|
4
4
|
"description": "GitHub Actions lexicon for chant — declarative IaC in TypeScript",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://intentius.io/chant",
|
|
@@ -61,6 +61,6 @@
|
|
|
61
61
|
"typescript": "^5.9.3"
|
|
62
62
|
},
|
|
63
63
|
"peerDependencies": {
|
|
64
|
-
"@intentius/chant": "^0.124.
|
|
64
|
+
"@intentius/chant": "^0.124.46"
|
|
65
65
|
}
|
|
66
66
|
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4332: an Op wave's apply split into one job per cloud identity. The
|
|
3
|
+
* deciding job trades its OIDC token for every plan identity of the wave's
|
|
4
|
+
* targets before the checkout, each group's job for its group's apply
|
|
5
|
+
* identities alone, and every later run step starts without the variables
|
|
6
|
+
* a token is requested with. The behaviour test runs the deciding job's
|
|
7
|
+
* trade step and its command step with `sh`, as a runner does, against the
|
|
8
|
+
* local emulator (@intentius/chant/__fixtures__/oidc-emulator), with the
|
|
9
|
+
* command replaced by a probe. Test values only.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { execFile } from "node:child_process";
|
|
13
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { promisify } from "node:util";
|
|
17
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
18
|
+
import { parseYAML } from "@intentius/chant/yaml";
|
|
19
|
+
import type { OpWavesSpec } from "@intentius/chant/op/op-waves";
|
|
20
|
+
import { CLOUD_TARGETS_ENV, cloudIdentityWave, OIDC_REQUEST_VARS, resolveCloudIdentity, TRADE_SPEC_ENV, tradeIdentities, type CloudIdentityDeclaration } from "@intentius/chant/ci-identity";
|
|
21
|
+
import { emulatedCredentials, EMULATED_JWT_PREFIX, startOidcEmulator, type OidcEmulator } from "@intentius/chant/__fixtures__/oidc-emulator";
|
|
22
|
+
import { generateGithubOpWavesPipeline } from "./generate-op-waves-pipeline";
|
|
23
|
+
|
|
24
|
+
const run = promisify(execFile);
|
|
25
|
+
const role = (n: string) => `arn:aws:iam::111111111111:role/${n}`;
|
|
26
|
+
|
|
27
|
+
const DECL: CloudIdentityDeclaration = {
|
|
28
|
+
aws: {
|
|
29
|
+
rules: [
|
|
30
|
+
{ targets: ["envs/prod/**"], plan: role("prod-plan"), apply: role("prod-apply") },
|
|
31
|
+
{ targets: ["envs/staging/**"], plan: role("staging-plan"), apply: role("staging-apply") },
|
|
32
|
+
{ plan: role("own-plan"), apply: role("own-apply") },
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
const ROOTS = ["envs/prod/net", "envs/staging/net", "shared/dns"];
|
|
37
|
+
|
|
38
|
+
function specFor(decl: CloudIdentityDeclaration, roots: string[]): OpWavesSpec {
|
|
39
|
+
return {
|
|
40
|
+
name: "tf-apply",
|
|
41
|
+
op: "tf-apply",
|
|
42
|
+
planWave: ["./bin/plan", "{runs}", "{plans}"],
|
|
43
|
+
applyWave: ["./bin/apply", "{runs}"],
|
|
44
|
+
waves: [{ name: "wave-1", runs: roots.map((target) => ({ target })), ...cloudIdentityWave(decl, "prod", "infra", roots) }],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface Step {
|
|
49
|
+
name?: string;
|
|
50
|
+
uses?: string;
|
|
51
|
+
run?: string;
|
|
52
|
+
env?: Record<string, string>;
|
|
53
|
+
}
|
|
54
|
+
interface Job {
|
|
55
|
+
permissions?: Record<string, string>;
|
|
56
|
+
steps: Step[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const jobsOf = (spec: OpWavesSpec): Record<string, Job> => (parseYAML(generateGithubOpWavesPipeline(spec, { specFile: ".chant/op-waves/tf-apply.spec.json", beforeScript: ["npm ci"] }).files[0]!.yaml) as { jobs: Record<string, Job> }).jobs;
|
|
60
|
+
const tradeOf = (job: Job): unknown => JSON.parse(job.steps[0]!.env![TRADE_SPEC_ENV]!);
|
|
61
|
+
const UNSET = `unset ${OIDC_REQUEST_VARS.join(" ")}`;
|
|
62
|
+
|
|
63
|
+
describe("an Op wave's jobs by cloud identity (#4332)", () => {
|
|
64
|
+
const jobs = jobsOf(specFor(DECL, ROOTS));
|
|
65
|
+
const plan = resolveCloudIdentity(DECL, "prod", "infra")!.plan;
|
|
66
|
+
|
|
67
|
+
test("one deciding job and a job per identity", () => {
|
|
68
|
+
expect(Object.keys(jobs)).toEqual(["wave-1-wave-1-decide", "wave-1-wave-1-envs-prod", "wave-1-wave-1-envs-staging", "wave-1-wave-1-default"]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("the deciding job trades for every plan identity, before the checkout, and names no apply identity", () => {
|
|
72
|
+
const decide = jobs["wave-1-wave-1-decide"]!;
|
|
73
|
+
expect(decide.steps[0]!.name).toBe("Trade the job's OIDC token for its cloud identities");
|
|
74
|
+
expect(decide.steps[0]!.run).toMatch(/^\(cd \/ && node -e '/);
|
|
75
|
+
expect(decide.steps[1]!.uses).toMatch(/^actions\/checkout@/);
|
|
76
|
+
expect(tradeOf(decide)).toEqual(plan);
|
|
77
|
+
expect(decide.permissions).toEqual({ contents: "write", "id-token": "write" });
|
|
78
|
+
for (const id of [role("prod-apply"), role("staging-apply"), role("own-apply")]) expect(JSON.stringify(decide)).not.toContain(id);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("each group's job trades for its own apply identity and holds no other group's", () => {
|
|
82
|
+
const want: Record<string, string> = { "wave-1-wave-1-envs-prod": role("prod-apply"), "wave-1-wave-1-envs-staging": role("staging-apply"), "wave-1-wave-1-default": role("own-apply") };
|
|
83
|
+
for (const [name, id] of Object.entries(want)) {
|
|
84
|
+
const job = jobs[name]!;
|
|
85
|
+
expect(tradeIdentities(tradeOf(job) as never)).toEqual([id]);
|
|
86
|
+
expect(job.steps[1]!.uses).toMatch(/^actions\/checkout@/);
|
|
87
|
+
for (const other of Object.values(want).filter((o) => o !== id)) expect(JSON.stringify(job)).not.toContain(other);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("every run step after the trade starts by unsetting the token request variables", () => {
|
|
92
|
+
for (const job of Object.values(jobs)) {
|
|
93
|
+
const runs = job.steps.slice(1).filter((s) => s.run !== undefined);
|
|
94
|
+
expect(runs.length).toBeGreaterThan(0);
|
|
95
|
+
for (const s of runs) expect(s.run!.split("\n")[0]).toBe(UNSET);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("a wave with no cloud trades nothing and requests no token", () => {
|
|
100
|
+
const plain = jobsOf(specFor({}, ROOTS));
|
|
101
|
+
expect(Object.keys(plain)).toEqual(["wave-1-wave-1"]);
|
|
102
|
+
for (const job of Object.values(plain)) {
|
|
103
|
+
expect(job.permissions?.["id-token"]).toBeUndefined();
|
|
104
|
+
expect(JSON.stringify(job)).not.toContain(TRADE_SPEC_ENV);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const dirs: string[] = [];
|
|
110
|
+
let emu: OidcEmulator;
|
|
111
|
+
beforeAll(async () => {
|
|
112
|
+
emu = await startOidcEmulator();
|
|
113
|
+
});
|
|
114
|
+
afterAll(async () => {
|
|
115
|
+
await emu.close();
|
|
116
|
+
for (const d of dirs) rmSync(d, { recursive: true, force: true });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("the deciding job, run step by step against the emulator (#4332)", () => {
|
|
120
|
+
test("its command step sees the plan identities through the targets file, and cannot request a token", async () => {
|
|
121
|
+
const decide = jobsOf(specFor(DECL, ROOTS))["wave-1-wave-1-decide"]!;
|
|
122
|
+
const temp = mkdtempSync(join(tmpdir(), "chant-op-waves-identity-"));
|
|
123
|
+
dirs.push(temp);
|
|
124
|
+
const githubEnv = join(temp, "github-env");
|
|
125
|
+
writeFileSync(githubEnv, "");
|
|
126
|
+
const base = { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: process.env.HOME ?? "/tmp", RUNNER_TEMP: temp, ...emu.requestEnv };
|
|
127
|
+
const spec = emu.pointAt(JSON.parse(decide.steps[0]!.env![TRADE_SPEC_ENV]!));
|
|
128
|
+
await run("sh", ["-c", decide.steps[0]!.run!.replace("$GITHUB_ENV", githubEnv)], { env: { ...base, [TRADE_SPEC_ENV]: JSON.stringify(spec) } });
|
|
129
|
+
const traded = Object.fromEntries(readFileSync(githubEnv, "utf-8").split("\n").filter(Boolean).map((l) => [l.slice(0, l.indexOf("=")), l.slice(l.indexOf("=") + 1)]));
|
|
130
|
+
expect(traded.AWS_ACCESS_KEY_ID).toBe(emulatedCredentials(role("own-plan")).accessKeyId);
|
|
131
|
+
const before = emu.log.minted.length;
|
|
132
|
+
// The command step with the command replaced by a probe: what it sees, and a token request.
|
|
133
|
+
const step = decide.steps.find((s) => s.run?.includes("chant run wave"))!;
|
|
134
|
+
const probe = `node -e 'const e=process.env;const f=JSON.parse(require("fs").readFileSync(e.${CLOUD_TARGETS_ENV},"utf8"));console.log("roles:"+Object.keys(f.clouds.aws.vars).join(","));console.log("request:"+(e.ACTIONS_ID_TOKEN_REQUEST_URL&&e.ACTIONS_ID_TOKEN_REQUEST_TOKEN?"yes":"no"))'`;
|
|
135
|
+
const r = await run("sh", ["-e", "-c", `${step.run!.split("\n")[0]}\n${probe}`], { env: { ...base, ...traded } });
|
|
136
|
+
expect(r.stdout).toContain(`roles:${[role("own-plan"), role("prod-plan"), role("staging-plan")].join(",")}`);
|
|
137
|
+
expect(r.stdout).toContain("request:no");
|
|
138
|
+
expect(r.stdout).not.toContain(EMULATED_JWT_PREFIX);
|
|
139
|
+
expect(emu.log.minted.length).toBe(before);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -34,6 +34,13 @@
|
|
|
34
34
|
* environment, variables over the wave's, setup after the wave's, and id
|
|
35
35
|
* tokens. A job with id tokens gets `permissions:` of its own, the
|
|
36
36
|
* workflow's plus `id-token: write`, and each audience as `<NAME>_AUDIENCE`.
|
|
37
|
+
*
|
|
38
|
+
* A job whose wave or group gives `cloud` (#4332, `cloudIdentityWave` in
|
|
39
|
+
* `@intentius/chant/ci-identity`) gets `id-token: write` and starts with a
|
|
40
|
+
* step that trades the job's OIDC token for those identities, before the
|
|
41
|
+
* checkout. Every later run step starts with an unset of the variables a
|
|
42
|
+
* token is requested with, so the code the job runs holds no OIDC token
|
|
43
|
+
* and cannot request one for another group's identity.
|
|
37
44
|
*/
|
|
38
45
|
|
|
39
46
|
import { notifyVariables, opWaveGroupOf, opWaveJobs, opWavesResumeCommand, type OpWavesSpec } from "@intentius/chant/op/op-waves";
|
|
@@ -52,6 +59,7 @@ import {
|
|
|
52
59
|
pushBranches,
|
|
53
60
|
setupStepDoc,
|
|
54
61
|
} from "@intentius/chant/ci-jobs";
|
|
62
|
+
import { OIDC_REQUEST_VARS, TRADE_SPEC_ENV, tradeLine, type CloudTradeSpec } from "@intentius/chant/ci-identity";
|
|
55
63
|
import { checkoutStep, concurrency, downloadArtifactStep, keepRecordStep, runSteps } from "./ci-jobs";
|
|
56
64
|
import { emitOpPipelineYAML, type GithubOpPipelineDoc } from "./generate-op-pipeline";
|
|
57
65
|
|
|
@@ -86,25 +94,30 @@ export function buildGithubOpWavesDoc(
|
|
|
86
94
|
const wave = spec.waves[job.wave - 1]!;
|
|
87
95
|
// #3914: a job group's job runs with the group's settings over the wave's.
|
|
88
96
|
const group = opWaveGroupOf(spec, job);
|
|
89
|
-
|
|
97
|
+
// #4332: the job's cloud identities, traded before the checkout.
|
|
98
|
+
const cloud = group?.cloud ?? wave.cloud;
|
|
99
|
+
const steps: Array<Record<string, unknown>> = [...(cloud ? [tradeStep(cloud)] : []), checkoutStep(true)];
|
|
90
100
|
if (job.kind === "share") {
|
|
91
101
|
steps.push(downloadArtifactStep(`Download wave ${job.wave}'s decision`, opWaveRecordArtifact(spec, job.wave), dirnameOf(job.record!)));
|
|
92
102
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
103
|
+
const later: Array<Record<string, unknown>> = [];
|
|
104
|
+
later.push(...[...(wave.setup ?? []), ...(group?.setup ?? [])].map(setupStepDoc), ...runSteps(options.beforeScript ?? []));
|
|
105
|
+
later.push({ name: describeJob(job.kind, job.wave, wave.name, job.share, job.group), run: job.command.join(" ") });
|
|
106
|
+
later.push(...runSteps(options.extraScript ?? []));
|
|
107
|
+
steps.push(...(cloud ? later.map(withoutTokenRequest) : later));
|
|
96
108
|
if (job.kind === "decide") {
|
|
97
109
|
steps.push(keepRecordStep(`Keep wave ${job.wave}'s decision`, opWaveRecordArtifact(spec, job.wave), job.record!));
|
|
98
110
|
}
|
|
99
111
|
const environment = group?.environment ?? wave.environment;
|
|
100
112
|
const idTokens = group?.idTokens ?? wave.idTokens;
|
|
113
|
+
const idToken = idTokens !== undefined || cloud !== undefined;
|
|
101
114
|
jobsDoc[job.jobName] = {
|
|
102
115
|
"runs-on": githubRunsOn(group?.runner ?? wave.runner ?? spec.runner),
|
|
103
116
|
...(job.needs.length > 0 ? { needs: job.needs } : {}),
|
|
104
117
|
container: image,
|
|
105
118
|
...(environment ? { environment: environmentDoc(environment) } : {}),
|
|
106
119
|
// #3914: a job's own permissions replace the workflow's, so they repeat them.
|
|
107
|
-
...(
|
|
120
|
+
...(idToken ? { permissions: { ...permissions, "id-token": "write" } } : {}),
|
|
108
121
|
...nonEmpty("env", group || idTokens ? { ...wave.variables, ...group?.variables, ...idTokenAudienceEnv(idTokens) } : wave.variables),
|
|
109
122
|
steps,
|
|
110
123
|
};
|
|
@@ -161,6 +174,16 @@ function buildPlansDoc(spec: OpWavesSpec, options: OpWavesPipelineOptions): Gith
|
|
|
161
174
|
};
|
|
162
175
|
}
|
|
163
176
|
|
|
177
|
+
/** The step that trades the job's OIDC token for `cloud` (#4332), writing the credentials to GITHUB_ENV. */
|
|
178
|
+
function tradeStep(cloud: CloudTradeSpec): Record<string, unknown> {
|
|
179
|
+
return { name: "Trade the job's OIDC token for its cloud identities", env: { [TRADE_SPEC_ENV]: JSON.stringify(cloud) }, run: tradeLine("$GITHUB_ENV", "github-env") };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** A run step after the trade (#4332), with the token request variables unset first. A `uses:` step is left as it is. */
|
|
183
|
+
function withoutTokenRequest(step: Record<string, unknown>): Record<string, unknown> {
|
|
184
|
+
return typeof step.run === "string" ? { ...step, run: `unset ${OIDC_REQUEST_VARS.join(" ")}\n${step.run}` } : step;
|
|
185
|
+
}
|
|
186
|
+
|
|
164
187
|
function describeJob(kind: "wave" | "decide" | "share", wave: number, name: string, share?: number, group?: string): string {
|
|
165
188
|
if (kind === "decide") return `Plan wave ${wave} (${name}) and decide its gate`;
|
|
166
189
|
if (group !== undefined) return `Apply ${share !== undefined ? `share ${share} of ` : ""}job group ${group} of wave ${wave} (${name})`;
|
|
@@ -475,17 +475,24 @@ function prEnvGithubJobs(
|
|
|
475
475
|
const keep = (name: string) => ({ name: `Keep the ${name} report`, if: "always()", uses: UPLOAD_ARTIFACT, with: { name: `chant-pr-${name}`, path: reportPath, "if-no-files-found": "ignore", "include-hidden-files": true } });
|
|
476
476
|
const opened = "github.event_name == 'pull_request' && github.event.action != 'closed'";
|
|
477
477
|
|
|
478
|
-
// #
|
|
479
|
-
const
|
|
478
|
+
// #4330: env-up, env-down and env-sweep trade the job's OIDC token for the pull request environments' identity, before the checkout.
|
|
479
|
+
const identity = loop.prEnvIdentity;
|
|
480
|
+
const tradeStep: Array<Record<string, unknown>> = identity
|
|
481
|
+
? [{ name: "Trade the job's OIDC token for the pull request environment's identity", ...(rooted ? {} : { "working-directory": "." }), env: { [TRADE_SPEC_ENV]: JSON.stringify(identity) }, run: tradeLine("$GITHUB_ENV", "github-env") }]
|
|
482
|
+
: [];
|
|
483
|
+
const withIdToken = (permissions: Record<string, string>): Record<string, string> => (identity ? { ...permissions, "id-token": "write" } : permissions);
|
|
484
|
+
// #4295: env-up runs the change's code and holds no forge token while it does; #4330: nor the token request variables.
|
|
485
|
+
const drop = dropForgeTokens("sh", identity ? OIDC_REQUEST_VARS : []);
|
|
480
486
|
const headStep = (line: string, name?: string, env?: Record<string, string>): Record<string, unknown> => ({ ...(name ? { name } : {}), ...(env ? { env } : {}), run: `${drop}\n${line}` });
|
|
481
487
|
const upJob = {
|
|
482
488
|
if: opened,
|
|
483
489
|
"runs-on": GITHUB_RUNNER,
|
|
484
490
|
container: image,
|
|
485
491
|
concurrency: concurrency(group),
|
|
486
|
-
permissions: { contents: "read" },
|
|
492
|
+
permissions: withIdToken({ contents: "read" }),
|
|
487
493
|
outputs: handoverOutputs(PR_ENV_UP_STEP_ID, forge),
|
|
488
494
|
steps: [
|
|
495
|
+
...tradeStep,
|
|
489
496
|
{
|
|
490
497
|
name: "Check out the change",
|
|
491
498
|
...(rooted ? {} : { "working-directory": "." }),
|
|
@@ -524,8 +531,9 @@ function prEnvGithubJobs(
|
|
|
524
531
|
"runs-on": GITHUB_RUNNER,
|
|
525
532
|
container: image,
|
|
526
533
|
concurrency: concurrency(groupName),
|
|
527
|
-
permissions: writes,
|
|
534
|
+
permissions: withIdToken(writes),
|
|
528
535
|
steps: [
|
|
536
|
+
...tradeStep,
|
|
529
537
|
{ uses: actionRef("actions/checkout"), with: { "fetch-depth": 0, ...(ref ? { ref } : {}) } },
|
|
530
538
|
...runSteps(prLoopSetup(image)),
|
|
531
539
|
...runSteps(beforeScript),
|
|
@@ -204,3 +204,31 @@ describe("env-up's steps, run, see no forge token and hand the report and record
|
|
|
204
204
|
expect(readFileSync(join(postWork, ".chant", "pr-env", "pr-env-up.json"), "utf-8")).toBe('{"stage":"up"}\n');
|
|
205
205
|
});
|
|
206
206
|
});
|
|
207
|
+
|
|
208
|
+
describe("the environment's cloud identity (#4330)", () => {
|
|
209
|
+
const prEnvIdentity = { aws: { role: "arn:aws:iam::000000000000:role/pr-env-apply", region: "us-east-1", audience: "sts.amazonaws.com", endpoint: "http://localhost:4566" } };
|
|
210
|
+
const jobs = (parseYAML(generateGithubPipeline(ESTATE, { env: "staging", prLoop: { prEnvironment: true, prEnvIdentity } }).yaml) as { jobs: Record<string, Job> }).jobs;
|
|
211
|
+
|
|
212
|
+
test("env-up trades its OIDC token before the checkout, and every later step starts without the token request variables", () => {
|
|
213
|
+
const up = jobs["env-up"]!;
|
|
214
|
+
expect(up.permissions).toEqual({ contents: "read", "id-token": "write" });
|
|
215
|
+
expect(up.steps[0]!.name).toMatch(/Trade the job's OIDC token/);
|
|
216
|
+
expect(JSON.parse(up.steps[0]!.env!.CHANT_CLOUD_TRADE!)).toEqual(prEnvIdentity);
|
|
217
|
+
expect(up.steps[1]!.name).toBe("Check out the change");
|
|
218
|
+
for (const step of up.steps.slice(2)) expect(step.run).toContain("ACTIONS_ID_TOKEN_REQUEST_TOKEN");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("env-down and env-sweep trade for the same identity, and env-post trades for none", () => {
|
|
222
|
+
for (const name of ["env-down", "env-sweep"]) {
|
|
223
|
+
expect(jobs[name]!.permissions["id-token"]).toBe("write");
|
|
224
|
+
expect(jobs[name]!.steps[0]!.env?.CHANT_CLOUD_TRADE).toBe(JSON.stringify(prEnvIdentity));
|
|
225
|
+
}
|
|
226
|
+
expect(jobs["env-post"]!.permissions["id-token"]).toBeUndefined();
|
|
227
|
+
expect(JSON.stringify(jobs["env-post"]!.steps)).not.toContain("CHANT_CLOUD_TRADE");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("without one, no environment job asks for an OIDC token", () => {
|
|
231
|
+
const plain = (parseYAML(generateGithubPipeline(ESTATE, { env: "staging", prLoop: { prEnvironment: true } }).yaml) as { jobs: Record<string, Job> }).jobs;
|
|
232
|
+
for (const name of ["env-up", "env-down", "env-sweep"]) expect(plain[name]!.permissions["id-token"]).toBeUndefined();
|
|
233
|
+
});
|
|
234
|
+
});
|