@intentius/chant 0.124.43 → 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.
- package/dist/ci-identity.d.ts +183 -12
- package/dist/ci-identity.d.ts.map +1 -1
- package/dist/ci-identity.js +362 -53
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/build.js +17 -1
- package/dist/cli/handlers/pr-env.d.ts +8 -2
- package/dist/cli/handlers/pr-env.d.ts.map +1 -1
- package/dist/cli/handlers/pr-env.js +126 -225
- package/dist/cli/handlers/pr.d.ts +3 -0
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +1 -0
- package/dist/components/cli-support.d.ts +2 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/cli-support.js +4 -2
- package/dist/components/fan-out-support.d.ts +3 -0
- package/dist/components/fan-out-support.d.ts.map +1 -1
- package/dist/components/fan-out-support.js +1 -1
- package/dist/components/pr-env-run.d.ts +270 -0
- package/dist/components/pr-env-run.d.ts.map +1 -0
- package/dist/components/pr-env-run.js +440 -0
- package/dist/components/pr-environment.d.ts +89 -14
- package/dist/components/pr-environment.d.ts.map +1 -1
- package/dist/components/pr-environment.js +94 -8
- package/dist/components/pr-pipeline.d.ts +10 -1
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/op/op-waves.d.ts +17 -0
- package/dist/op/op-waves.d.ts.map +1 -1
- package/dist/op/op-waves.js +23 -1
- package/dist/pr-forge.d.ts +15 -1
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/pr-forge.js +28 -16
- package/dist/pr-requires.d.ts +5 -1
- package/dist/pr-requires.d.ts.map +1 -1
- package/dist/wave-runtime.mjs +179 -179
- package/dist/workspace/ci-cloud-identity.d.ts +3 -1
- package/dist/workspace/ci-cloud-identity.d.ts.map +1 -1
- package/dist/workspace/ci-cloud-identity.js +16 -1
- package/dist/workspace/ci-run.d.ts +35 -8
- package/dist/workspace/ci-run.d.ts.map +1 -1
- package/dist/workspace/ci-run.js +95 -21
- package/dist/workspace/ci-secrets.d.ts.map +1 -1
- package/dist/workspace/ci-secrets.js +4 -3
- package/dist/workspace/declaration.d.ts +10 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.js +17 -1
- package/dist/workspace/declaration.schema.json +36 -1
- package/package.json +1 -1
- package/src/ci-identity-targets.test.ts +275 -0
- package/src/ci-identity.ts +394 -61
- package/src/cli/handlers/build.ts +15 -1
- package/src/cli/handlers/pr-env.ts +136 -227
- package/src/cli/handlers/pr.ts +4 -0
- package/src/components/cli-support.ts +6 -1
- package/src/components/fan-out-support.ts +4 -1
- package/src/components/pr-env-run.test.ts +290 -0
- package/src/components/pr-env-run.ts +634 -0
- package/src/components/pr-environment.ts +159 -12
- package/src/components/pr-pipeline.ts +10 -1
- package/src/op/op-waves.ts +38 -1
- package/src/pr-forge-bundle.test.ts +1 -1
- package/src/pr-forge.ts +44 -17
- package/src/pr-requires.test.ts +43 -6
- package/src/pr-requires.ts +5 -1
- package/src/workspace/ci-cloud-identity.test.ts +20 -0
- package/src/workspace/ci-cloud-identity.ts +14 -1
- package/src/workspace/ci-run.test.ts +74 -1
- package/src/workspace/ci-run.ts +122 -29
- package/src/workspace/ci-secrets.test.ts +1 -1
- package/src/workspace/ci-secrets.ts +4 -3
- package/src/workspace/declaration.schema.json +36 -1
- package/src/workspace/declaration.test.ts +7 -0
- package/src/workspace/declaration.ts +24 -2
package/src/ci-identity.ts
CHANGED
|
@@ -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
|
*
|
|
@@ -39,19 +50,27 @@
|
|
|
39
50
|
* itself cannot plan under this block.
|
|
40
51
|
*/
|
|
41
52
|
|
|
42
|
-
|
|
43
|
-
import picomatch from "picomatch";
|
|
53
|
+
import { readFileSync } from "node:fs";
|
|
44
54
|
|
|
45
55
|
/** The clouds `ci.identity.cloud` names. */
|
|
46
56
|
export const CLOUDS = ["aws", "gcp", "azure"] as const;
|
|
47
57
|
export type Cloud = (typeof CLOUDS)[number];
|
|
48
58
|
|
|
49
|
-
/** One rule: the environments and
|
|
59
|
+
/** One rule: the environments, members and targets it covers, and their two identities. */
|
|
50
60
|
export interface CloudIdentityRule {
|
|
51
61
|
/** Environment-name globs. Absent: every environment. */
|
|
52
62
|
environments?: string[];
|
|
53
63
|
/** Member-name globs. Absent: every member, and a pipeline outside a workspace member. */
|
|
54
64
|
members?: string[];
|
|
65
|
+
/**
|
|
66
|
+
* Target globs (#4332): the targets inside the member the rule covers,
|
|
67
|
+
* such as Terraform roots or Op wave run targets. `*` matches within one
|
|
68
|
+
* `/` segment, `**` across segments, `?` one character. Absent: every
|
|
69
|
+
* target, and the pipeline as a whole.
|
|
70
|
+
*/
|
|
71
|
+
targets?: string[];
|
|
72
|
+
/** The name of the apply job group its targets form (#4332). Default: from its first target glob. */
|
|
73
|
+
name?: string;
|
|
55
74
|
/** The identity the plan and record jobs take. */
|
|
56
75
|
plan: string;
|
|
57
76
|
/** The identity the apply jobs take. */
|
|
@@ -99,69 +118,300 @@ export interface CloudIdentityDeclaration {
|
|
|
99
118
|
azure?: AzureCloudIdentity;
|
|
100
119
|
}
|
|
101
120
|
|
|
102
|
-
/**
|
|
121
|
+
/**
|
|
122
|
+
* What one job trades for, per cloud: the job's own identity and where to
|
|
123
|
+
* trade it, and with `targets` (#4332) the identities of the targets that
|
|
124
|
+
* take another, first match wins. A cloud names its own identity, or
|
|
125
|
+
* targets, or both.
|
|
126
|
+
*/
|
|
103
127
|
export interface CloudTradeSpec {
|
|
104
|
-
aws?: { role: string; region: string; audience: string; endpoint?: string };
|
|
105
|
-
gcp?: { serviceAccount: string; provider: string; audience: string; stsEndpoint?: string; iamEndpoint?: string };
|
|
106
|
-
azure?: { clientId: string; tenantId: string; audience: string; subscriptionId?: string; authority?: string };
|
|
128
|
+
aws?: { role?: string; targets?: Array<{ targets: string[]; role: string }>; region: string; audience: string; endpoint?: string };
|
|
129
|
+
gcp?: { serviceAccount?: string; targets?: Array<{ targets: string[]; serviceAccount: string }>; provider: string; audience: string; stsEndpoint?: string; iamEndpoint?: string };
|
|
130
|
+
azure?: { clientId?: string; targets?: Array<{ targets: string[]; clientId: string }>; tenantId: string; audience: string; subscriptionId?: string; authority?: string };
|
|
107
131
|
}
|
|
108
132
|
|
|
133
|
+
/** The key a cloud's trade names its identity under. */
|
|
134
|
+
export const IDENTITY_KEY = { aws: "role", gcp: "serviceAccount", azure: "clientId" } as const;
|
|
135
|
+
|
|
109
136
|
/** A pipeline's two trades: the plan and record jobs', and the apply jobs'. */
|
|
110
137
|
export interface PipelineCloudIdentity {
|
|
111
138
|
plan: CloudTradeSpec;
|
|
112
139
|
apply: CloudTradeSpec;
|
|
113
140
|
}
|
|
114
141
|
|
|
115
|
-
|
|
116
|
-
|
|
142
|
+
/**
|
|
143
|
+
* A glob over a name (#4296): `*` and `?` within the name, `[...]` classes,
|
|
144
|
+
* `{a,b}` alternatives, a leading `!` negates. The grammar `ciMemberGlobs`
|
|
145
|
+
* documents, with no dependency, so this module bundles small (#4332).
|
|
146
|
+
*/
|
|
147
|
+
export function nameGlobMatch(glob: string, name: string): boolean {
|
|
148
|
+
if (glob.startsWith("!")) return !nameGlobMatch(glob.slice(1), name);
|
|
149
|
+
return expandBraces(glob).some((g) => new RegExp(`^${globBody(g, false)}$`).test(name));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A glob over a target (#4332): `*` within one `/` segment, `**` across
|
|
154
|
+
* segments (and `a/**\/b` across none), `?` one character. The reading of
|
|
155
|
+
* `targetGlobMatch` in ./op/gate-quorum.ts and the terraform lexicon's unit
|
|
156
|
+
* globs, so a root's glob means one thing in each.
|
|
157
|
+
*/
|
|
158
|
+
export function targetGlobMatch(glob: string, target: string): boolean {
|
|
159
|
+
const trim = (v: string): string => v.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
160
|
+
return new RegExp(`^${globBody(trim(glob), true)}$`).test(trim(target));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** `{a,b}` alternatives, nested too, expanded into plain globs. */
|
|
164
|
+
function expandBraces(glob: string): string[] {
|
|
165
|
+
const open = glob.indexOf("{");
|
|
166
|
+
if (open < 0) return [glob];
|
|
167
|
+
let depth = 0;
|
|
168
|
+
const cuts: number[] = [];
|
|
169
|
+
for (let i = open; i < glob.length; i++) {
|
|
170
|
+
const c = glob[i];
|
|
171
|
+
if (c === "{") depth++;
|
|
172
|
+
else if (c === "}" && --depth === 0) {
|
|
173
|
+
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));
|
|
174
|
+
const rest = expandBraces(glob.slice(i + 1));
|
|
175
|
+
return parts.flatMap((p) => expandBraces(glob.slice(0, open) + p)).flatMap((head) => rest.map((tail) => head + tail));
|
|
176
|
+
} else if (c === "," && depth === 1) cuts.push(i);
|
|
177
|
+
}
|
|
178
|
+
return [glob];
|
|
179
|
+
}
|
|
117
180
|
|
|
118
|
-
/**
|
|
119
|
-
|
|
120
|
-
|
|
181
|
+
/** A glob's regular expression body. `paths`: `**` crosses `/`, and classes and braces are literal. */
|
|
182
|
+
function globBody(g: string, paths: boolean): string {
|
|
183
|
+
let re = "";
|
|
184
|
+
for (let i = 0; i < g.length; i++) {
|
|
185
|
+
const c = g[i]!;
|
|
186
|
+
if (c === "*" && g[i + 1] === "*" && paths) {
|
|
187
|
+
if (g[i + 2] === "/") {
|
|
188
|
+
re += "(?:.*/)?";
|
|
189
|
+
i += 2;
|
|
190
|
+
} else {
|
|
191
|
+
re += ".*";
|
|
192
|
+
i += 1;
|
|
193
|
+
}
|
|
194
|
+
} else if (c === "*") {
|
|
195
|
+
re += "[^/]*";
|
|
196
|
+
while (g[i + 1] === "*") i++;
|
|
197
|
+
} else if (c === "?") re += "[^/]";
|
|
198
|
+
else if (c === "[" && !paths && g.indexOf("]", i + 2) > i) {
|
|
199
|
+
const end = g.indexOf("]", i + 2);
|
|
200
|
+
const body = g.slice(i + 1, end);
|
|
201
|
+
re += `[${body.startsWith("!") ? `^${body.slice(1)}` : body}]`.replace(/\\/g, "\\\\");
|
|
202
|
+
i = end;
|
|
203
|
+
} else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
204
|
+
}
|
|
205
|
+
return re;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const matches = (globs: readonly string[] | undefined, name: string | undefined): boolean => globs === undefined || (name !== undefined && globs.some((g) => nameGlobMatch(g, name)));
|
|
209
|
+
|
|
210
|
+
const coversTarget = (r: CloudIdentityRule, target: string | undefined): boolean =>
|
|
211
|
+
r.targets === undefined || (target !== undefined && r.targets.some((g) => targetGlobMatch(g, target)));
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The first rule that covers `env`, `member` and `target`, or undefined.
|
|
215
|
+
* With no `target`, the first rule that names no targets: the pipeline's own.
|
|
216
|
+
*/
|
|
217
|
+
export function ruleFor(rules: readonly CloudIdentityRule[], env: string, member?: string, target?: string): CloudIdentityRule | undefined {
|
|
218
|
+
return rules.find((r) => matches(r.environments, env) && matches(r.members, member) && coversTarget(r, target));
|
|
121
219
|
}
|
|
122
220
|
|
|
123
221
|
/** The provider's resource name without the `//iam.googleapis.com/` prefix. */
|
|
124
222
|
const providerPath = (p: string): string => p.replace(/^(https:)?\/\/iam\.googleapis\.com\//, "");
|
|
125
223
|
|
|
224
|
+
/** Where each cloud trades, the same for every identity of the cloud. */
|
|
225
|
+
function cloudPlace(decl: CloudIdentityDeclaration, cloud: Cloud): Record<string, string> {
|
|
226
|
+
if (cloud === "aws") {
|
|
227
|
+
const a = decl.aws!;
|
|
228
|
+
return { region: a.region ?? "us-east-1", audience: a.audience ?? "sts.amazonaws.com", ...(a.endpoint ? { endpoint: a.endpoint } : {}) };
|
|
229
|
+
}
|
|
230
|
+
if (cloud === "gcp") {
|
|
231
|
+
const g = decl.gcp!;
|
|
232
|
+
const provider = providerPath(g.provider);
|
|
233
|
+
return {
|
|
234
|
+
provider,
|
|
235
|
+
audience: g.audience ?? `https://iam.googleapis.com/${provider}`,
|
|
236
|
+
...(g.stsEndpoint ? { stsEndpoint: g.stsEndpoint } : {}),
|
|
237
|
+
...(g.iamEndpoint ? { iamEndpoint: g.iamEndpoint } : {}),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
const z = decl.azure!;
|
|
241
|
+
return {
|
|
242
|
+
tenantId: z.tenantId,
|
|
243
|
+
audience: z.audience ?? "api://AzureADTokenExchange",
|
|
244
|
+
...(z.subscriptionId ? { subscriptionId: z.subscriptionId } : {}),
|
|
245
|
+
...(z.authority ? { authority: z.authority } : {}),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** One cloud's trade: its own identity, the targets that take another, and where it trades. */
|
|
250
|
+
function cloudTrade(decl: CloudIdentityDeclaration, cloud: Cloud, own: string | undefined, targets: Array<{ targets: string[]; id: string }>): Record<string, unknown> {
|
|
251
|
+
const key = IDENTITY_KEY[cloud];
|
|
252
|
+
return {
|
|
253
|
+
...(own !== undefined ? { [key]: own } : {}),
|
|
254
|
+
...(targets.length > 0 ? { targets: targets.map((t) => ({ targets: [...t.targets], [key]: t.id })) } : {}),
|
|
255
|
+
...cloudPlace(decl, cloud),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
126
259
|
/**
|
|
127
260
|
* The plan and apply trades of the pipeline for `env` and `member`, or
|
|
128
261
|
* undefined when no rule of any cloud covers them. A cloud no rule covers
|
|
129
|
-
* is left out of both.
|
|
262
|
+
* is left out of both. Each cloud's own identity is the first rule with no
|
|
263
|
+
* `targets` that covers the pipeline; the rules with `targets` before it
|
|
264
|
+
* are the cloud's `targets` (#4332), so the plan trade holds every plan
|
|
265
|
+
* identity a target of the member takes, and the apply trade every apply
|
|
266
|
+
* identity.
|
|
130
267
|
*/
|
|
131
268
|
export function resolveCloudIdentity(decl: CloudIdentityDeclaration | undefined, env: string, member?: string): PipelineCloudIdentity | undefined {
|
|
132
269
|
if (!decl) return undefined;
|
|
133
|
-
const plan:
|
|
134
|
-
const apply:
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
270
|
+
const plan: Record<string, unknown> = {};
|
|
271
|
+
const apply: Record<string, unknown> = {};
|
|
272
|
+
for (const cloud of CLOUDS) {
|
|
273
|
+
const rules = decl[cloud]?.rules;
|
|
274
|
+
if (!rules) continue;
|
|
275
|
+
const covering = rules.filter((r) => matches(r.environments, env) && matches(r.members, member));
|
|
276
|
+
const cut = covering.findIndex((r) => r.targets === undefined);
|
|
277
|
+
const own = cut < 0 ? undefined : covering[cut];
|
|
278
|
+
const byTarget = (cut < 0 ? covering : covering.slice(0, cut)).filter((r) => r.targets !== undefined);
|
|
279
|
+
if (!own && byTarget.length === 0) continue;
|
|
280
|
+
plan[cloud] = cloudTrade(decl, cloud, own?.plan, byTarget.map((r) => ({ targets: r.targets!, id: r.plan })));
|
|
281
|
+
apply[cloud] = cloudTrade(decl, cloud, own?.apply, byTarget.map((r) => ({ targets: r.targets!, id: r.apply })));
|
|
140
282
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
283
|
+
return Object.keys(plan).length > 0 ? { plan: plan as CloudTradeSpec, apply: apply as CloudTradeSpec } : undefined;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The plan and apply trades of one target (#4332): per cloud, the first
|
|
288
|
+
* rule that covers `env`, `member` and `target`. Undefined when no rule of
|
|
289
|
+
* any cloud covers it. Each trade names one identity per cloud and no
|
|
290
|
+
* `targets`.
|
|
291
|
+
*/
|
|
292
|
+
export function resolveTargetCloudIdentity(decl: CloudIdentityDeclaration | undefined, env: string, member: string | undefined, target: string): PipelineCloudIdentity | undefined {
|
|
293
|
+
if (!decl) return undefined;
|
|
294
|
+
const plan: Record<string, unknown> = {};
|
|
295
|
+
const apply: Record<string, unknown> = {};
|
|
296
|
+
for (const cloud of CLOUDS) {
|
|
297
|
+
const rule = decl[cloud] && ruleFor(decl[cloud]!.rules, env, member, target);
|
|
298
|
+
if (!rule) continue;
|
|
299
|
+
plan[cloud] = cloudTrade(decl, cloud, rule.plan, []);
|
|
300
|
+
apply[cloud] = cloudTrade(decl, cloud, rule.apply, []);
|
|
152
301
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
302
|
+
return Object.keys(plan).length > 0 ? { plan: plan as CloudTradeSpec, apply: apply as CloudTradeSpec } : undefined;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** The name a pull request environment's rule is matched against (#4330): any pull request's, as `pr-*` covers it. */
|
|
306
|
+
export const PR_ENV_IDENTITY_PROBE = "pr-1";
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* The trade a pull request environment's jobs take (#4330): per cloud, the
|
|
310
|
+
* apply identity of the rules {@link resolveCloudIdentity} gives `pr-1` and
|
|
311
|
+
* `member`, from the rules that name `environments` only. env-up runs the
|
|
312
|
+
* change's code with that identity, so a rule with no `environments`, which
|
|
313
|
+
* covers every environment, is never one it gets: give pull request
|
|
314
|
+
* environments a rule of their own, such as `environments: ["pr-*"]`, whose
|
|
315
|
+
* identity reaches only what they may touch. Undefined when no such rule
|
|
316
|
+
* covers them.
|
|
317
|
+
*/
|
|
318
|
+
export function resolvePrEnvCloudIdentity(decl: CloudIdentityDeclaration | undefined, member?: string): CloudTradeSpec | undefined {
|
|
319
|
+
if (!decl) return undefined;
|
|
320
|
+
const named: CloudIdentityDeclaration = {};
|
|
321
|
+
for (const cloud of CLOUDS) {
|
|
322
|
+
const c = decl[cloud];
|
|
323
|
+
if (c) (named as Record<string, unknown>)[cloud] = { ...c, rules: c.rules.filter((r) => r.environments !== undefined) };
|
|
324
|
+
}
|
|
325
|
+
return resolveCloudIdentity(named, PR_ENV_IDENTITY_PROBE, member)?.apply;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** One apply job's targets (#4332): those that take the same identities, per cloud. */
|
|
329
|
+
export interface CloudIdentityGroup {
|
|
330
|
+
/** A job-name-safe name: the first rule with `targets` they took (its `name`, or one from its first glob), else `default`. */
|
|
331
|
+
name: string;
|
|
332
|
+
/** The group's targets, in the order given. */
|
|
333
|
+
targets: string[];
|
|
334
|
+
/** Their plan and apply trades, one identity per cloud. Undefined when no rule covers them. */
|
|
335
|
+
identity?: PipelineCloudIdentity;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** The group name a glob gives: `envs/prod/**` is `envs-prod`. terragucci's `nameFrom` (groups.ts at INTENTIUS/terragucci ca05bd9). */
|
|
339
|
+
const nameFrom = (glob: string): string => glob.toLowerCase().replace(/\*+/g, "").replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
340
|
+
|
|
341
|
+
/** The name of the apply job group for {@link CloudIdentityGroup}s with no rule of their own. */
|
|
342
|
+
export const DEFAULT_IDENTITY_GROUP = "default";
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* `targets` grouped by the identities they take (#4332), so each group's
|
|
346
|
+
* apply runs in a job of its own with its group's identities alone.
|
|
347
|
+
* Targets that take the same plan and apply identity in every cloud are one
|
|
348
|
+
* group. The groups come in the order their first target comes, with
|
|
349
|
+
* `default` (the targets no rule with `targets` took) last. Names are
|
|
350
|
+
* unique: a second group of one name gets `-2`.
|
|
351
|
+
*/
|
|
352
|
+
export function cloudIdentityGroups(decl: CloudIdentityDeclaration | undefined, env: string, member: string | undefined, targets: readonly string[]): CloudIdentityGroup[] {
|
|
353
|
+
const byKey = new Map<string, { base: string; group: CloudIdentityGroup }>();
|
|
354
|
+
for (const target of targets) {
|
|
355
|
+
const identity = resolveTargetCloudIdentity(decl, env, member, target);
|
|
356
|
+
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);
|
|
357
|
+
const at = byKey.get(key);
|
|
358
|
+
if (at) {
|
|
359
|
+
at.group.targets.push(target);
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
const rule = CLOUDS.map((c) => decl?.[c] && ruleFor(decl[c]!.rules, env, member, target)).find((r) => r?.targets !== undefined);
|
|
363
|
+
const named = rule ? (rule.name ?? (nameFrom(rule.targets![0]!) || "group")) : DEFAULT_IDENTITY_GROUP;
|
|
364
|
+
// An Op wave's own jobs are `decide` and `share-<i>` (./op/op-waves.ts), so a group takes neither name.
|
|
365
|
+
const base = named === "decide" || /^share-\d+$/.test(named) ? `${named}-targets` : named;
|
|
366
|
+
byKey.set(key, { base, group: { name: base, targets: [target], ...(identity ? { identity } : {}) } });
|
|
367
|
+
}
|
|
368
|
+
const all = [...byKey.values()];
|
|
369
|
+
const ordered = [...all.filter((g) => g.base !== DEFAULT_IDENTITY_GROUP), ...all.filter((g) => g.base === DEFAULT_IDENTITY_GROUP)];
|
|
370
|
+
const taken = new Set<string>();
|
|
371
|
+
return ordered.map(({ base, group }) => {
|
|
372
|
+
let name = base;
|
|
373
|
+
for (let n = 2; taken.has(name); n++) name = `${base}-${n}`;
|
|
374
|
+
taken.add(name);
|
|
375
|
+
return { ...group, name };
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* An Op wave's cloud identities over its run targets (#4332), as
|
|
381
|
+
* `OpWave.cloud` and `OpWave.jobs` (./op/op-waves.ts) take them. With two
|
|
382
|
+
* or more {@link cloudIdentityGroups}, the deciding job trades for every
|
|
383
|
+
* plan identity of the targets and each group's job for its group's apply
|
|
384
|
+
* identities alone. With one group, the wave's one job trades for that
|
|
385
|
+
* group's apply identities, and `jobs` is absent. Neither, when no rule
|
|
386
|
+
* covers a target.
|
|
387
|
+
*/
|
|
388
|
+
export function cloudIdentityWave(
|
|
389
|
+
decl: CloudIdentityDeclaration | undefined,
|
|
390
|
+
env: string,
|
|
391
|
+
member: string | undefined,
|
|
392
|
+
targets: readonly string[],
|
|
393
|
+
): { cloud?: CloudTradeSpec; jobs?: Array<{ name: string; runs: string[]; cloud?: CloudTradeSpec }> } {
|
|
394
|
+
const groups = cloudIdentityGroups(decl, env, member, targets);
|
|
395
|
+
if (groups.length === 0) return {};
|
|
396
|
+
if (groups.length === 1) return groups[0]!.identity ? { cloud: groups[0]!.identity.apply } : {};
|
|
397
|
+
const plan = resolveCloudIdentity(decl, env, member)?.plan;
|
|
398
|
+
return {
|
|
399
|
+
...(plan ? { cloud: plan } : {}),
|
|
400
|
+
jobs: groups.map((g) => ({ name: g.name, runs: [...g.targets], ...(g.identity ? { cloud: g.identity.apply } : {}) })),
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Why one job may not apply under `spec` (#4332), or undefined: a cloud
|
|
406
|
+
* whose trade names more than one identity would hand one job the apply
|
|
407
|
+
* tokens of targets that apply as different identities.
|
|
408
|
+
*/
|
|
409
|
+
export function oneApplyIdentityProblem(spec: CloudTradeSpec): string | undefined {
|
|
410
|
+
for (const cloud of tradedClouds(spec)) {
|
|
411
|
+
const ids = tradeIdentities({ [cloud]: spec[cloud] });
|
|
412
|
+
if (ids.length > 1) 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)`;
|
|
163
413
|
}
|
|
164
|
-
return
|
|
414
|
+
return undefined;
|
|
165
415
|
}
|
|
166
416
|
|
|
167
417
|
/** The clouds a trade covers, in {@link CLOUDS} order. */
|
|
@@ -169,9 +419,15 @@ export function tradedClouds(spec: CloudTradeSpec): Cloud[] {
|
|
|
169
419
|
return CLOUDS.filter((c) => spec[c] !== undefined);
|
|
170
420
|
}
|
|
171
421
|
|
|
172
|
-
/** Every identity a trade names: the
|
|
422
|
+
/** Every identity a trade names, once each: the roles, the service accounts, the client ids, its targets' too (#4332). */
|
|
173
423
|
export function tradeIdentities(spec: CloudTradeSpec): string[] {
|
|
174
|
-
|
|
424
|
+
const out: string[] = [];
|
|
425
|
+
for (const cloud of tradedClouds(spec)) {
|
|
426
|
+
const c = spec[cloud] as Record<string, unknown> & { targets?: Array<Record<string, string>> };
|
|
427
|
+
const key = IDENTITY_KEY[cloud];
|
|
428
|
+
for (const id of [c[key] as string | undefined, ...(c.targets ?? []).map((t) => t[key])]) if (id !== undefined && !out.includes(id)) out.push(id);
|
|
429
|
+
}
|
|
430
|
+
return out;
|
|
175
431
|
}
|
|
176
432
|
|
|
177
433
|
/**
|
|
@@ -224,15 +480,32 @@ export const TRADED_VARS: Record<Cloud, readonly string[]> = {
|
|
|
224
480
|
*/
|
|
225
481
|
export type TradeFormat = "github-env" | "sh";
|
|
226
482
|
|
|
483
|
+
/**
|
|
484
|
+
* The variable the trade sets when a cloud names `targets` (#4332): the
|
|
485
|
+
* path of a JSON file, mode 0600 and outside the checkout, with each
|
|
486
|
+
* identity's credentials and which targets take it. {@link targetCloudEnv}
|
|
487
|
+
* reads it.
|
|
488
|
+
*/
|
|
489
|
+
export const CLOUD_TARGETS_ENV = "CHANT_CLOUD_TARGETS";
|
|
490
|
+
|
|
491
|
+
/** The file {@link CLOUD_TARGETS_ENV} names: per cloud, the job's own identity, the targets that take another, and each identity's variables. */
|
|
492
|
+
export interface CloudTargetsFile {
|
|
493
|
+
version: 1;
|
|
494
|
+
clouds: Partial<Record<Cloud, { default?: string; targets: Array<{ targets: string[]; identity: string }>; vars: Record<string, Record<string, string>> }>>;
|
|
495
|
+
}
|
|
496
|
+
|
|
227
497
|
/**
|
|
228
498
|
* The trade, a script for `node -e` with no dependency, so it runs before
|
|
229
499
|
* the checkout and reads none of the change's files. argv 1 is the file it
|
|
230
500
|
* writes and argv 2 its {@link TradeFormat}; {@link TRADE_SPEC_ENV} is the
|
|
231
501
|
* spec. The token comes from a cloud's {@link idTokenVar} when the job has
|
|
232
502
|
* it (GitLab's `id_tokens`), else from the forge's request URL with the
|
|
233
|
-
* cloud's audience
|
|
234
|
-
*
|
|
235
|
-
*
|
|
503
|
+
* cloud's audience, once per cloud. Each identity of a cloud (its own, and
|
|
504
|
+
* its targets', #4332) is traded with that one token. The job's own
|
|
505
|
+
* identity's variables go in the file; with targets, every identity's go in
|
|
506
|
+
* the {@link CLOUD_TARGETS_ENV} file too. A value that holds anything but
|
|
507
|
+
* printable ASCII with no space is refused, so no answer can add a line of
|
|
508
|
+
* its own to the file. The script never prints a token.
|
|
236
509
|
*/
|
|
237
510
|
export const TRADE_JS = [
|
|
238
511
|
'const fs=require("fs");',
|
|
@@ -251,27 +524,40 @@ export const TRADE_JS = [
|
|
|
251
524
|
'const r=await fetch(u+(u.includes("?")?"&":"?")+"audience="+encodeURIComponent(aud),{headers:{authorization:"bearer "+k,accept:"application/json"}});',
|
|
252
525
|
'if(!r.ok)throw new Error("the OIDC token request answered "+r.status);',
|
|
253
526
|
'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};',
|
|
254
|
-
|
|
527
|
+
`const KEY=${JSON.stringify(IDENTITY_KEY)};`,
|
|
528
|
+
// #4332: a cloud's identities, its own first, then its targets', once each.
|
|
529
|
+
'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};',
|
|
530
|
+
"const vars={},secrets=[],said=[],by={};",
|
|
255
531
|
"const run=async()=>{",
|
|
256
|
-
|
|
532
|
+
'if(spec.aws){const a=spec.aws;const tok=await idToken("aws",a.audience);by.aws={};',
|
|
257
533
|
'const ep=trim(a.endpoint||process.env.AWS_ENDPOINT_URL_STS||("https://sts."+a.region+".amazonaws.com"+(a.region.startsWith("cn-")?".cn":"")));',
|
|
258
|
-
'const
|
|
534
|
+
'for(const role of ids("aws")){',
|
|
535
|
+
'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());',
|
|
259
536
|
'const g=(k)=>{const m=new RegExp("<"+k+">([^<]+)</"+k+">").exec(t);return m?m[1].trim():undefined};',
|
|
260
|
-
'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 "+
|
|
261
|
-
|
|
262
|
-
"if(
|
|
537
|
+
'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);',
|
|
538
|
+
"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)}",
|
|
539
|
+
"if(a.role)Object.assign(vars,by.aws[a.role])}",
|
|
540
|
+
'if(spec.gcp){const c=spec.gcp;const tok=await idToken("gcp",c.audience);by.gcp={};',
|
|
263
541
|
'const sts=trim(c.stsEndpoint||"https://sts.googleapis.com"),iam=trim(c.iamEndpoint||"https://iamcredentials.googleapis.com");',
|
|
264
542
|
'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;',
|
|
265
543
|
'if(!clean(fed))throw new Error("Google STS gave no token");',
|
|
266
|
-
'const
|
|
267
|
-
'
|
|
268
|
-
|
|
269
|
-
"
|
|
544
|
+
'for(const sa of ids("gcp")){',
|
|
545
|
+
'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;',
|
|
546
|
+
'if(!clean(at))throw new Error("IAM credentials gave no token for "+sa);',
|
|
547
|
+
"by.gcp[sa]={GOOGLE_OAUTH_ACCESS_TOKEN:at,CLOUDSDK_AUTH_ACCESS_TOKEN:at};secrets.push(at);said.push(sa)}",
|
|
548
|
+
"if(c.serviceAccount)Object.assign(vars,by.gcp[c.serviceAccount])}",
|
|
549
|
+
'if(spec.azure){const z=spec.azure;const tok=await idToken("azure",z.audience);by.azure={};',
|
|
270
550
|
'const host=trim(z.authority||"https://login.microsoftonline.com");',
|
|
271
|
-
'const
|
|
272
|
-
'
|
|
273
|
-
|
|
274
|
-
"
|
|
551
|
+
'for(const cid of ids("azure")){',
|
|
552
|
+
'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;',
|
|
553
|
+
'if(!clean(at))throw new Error("Entra gave no token for "+cid);',
|
|
554
|
+
"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)}",
|
|
555
|
+
"if(z.clientId)Object.assign(vars,by.azure[z.clientId])}",
|
|
556
|
+
// #4332: with targets, every identity's variables go in a file of their own, outside the checkout.
|
|
557
|
+
'const clouds=Object.keys(by);if(clouds.some((c)=>(spec[c].targets||[]).length>0)){',
|
|
558
|
+
'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]}}',
|
|
559
|
+
'const tf=trim(process.env.RUNNER_TEMP||process.env.TMPDIR||"/tmp")+"/chant-cloud-targets-"+require("crypto").randomBytes(8).toString("hex")+".json";',
|
|
560
|
+
`fs.writeFileSync(tf,JSON.stringify(doc),{mode:0o600,flag:"wx"});vars.${CLOUD_TARGETS_ENV}=tf}`,
|
|
275
561
|
'for(const [k,v] of Object.entries(vars))if(!clean(v))throw new Error(k+" is not a value the job can hold");',
|
|
276
562
|
'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(""))}',
|
|
277
563
|
'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});',
|
|
@@ -308,3 +594,50 @@ export function gitlabTradeLines(extraUnset: readonly string[] = []): string[] {
|
|
|
308
594
|
export function gitlabTradeIdTokens(spec: CloudTradeSpec): Record<string, { aud: string }> {
|
|
309
595
|
return Object.fromEntries(tradedClouds(spec).map((c) => [idTokenVar(c), { aud: spec[c]!.audience }]));
|
|
310
596
|
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* `env` for one target's tool (#4332): the job's environment with each
|
|
600
|
+
* cloud's credentials replaced by those of the identity `target` takes, as
|
|
601
|
+
* the trade left them in the {@link CLOUD_TARGETS_ENV} file. A target that
|
|
602
|
+
* no rule with `targets` covers keeps the job's own identity. Without the
|
|
603
|
+
* file (no cloud names targets), `env` as it is. terragucci's `rootRoleEnv`
|
|
604
|
+
* (roles.ts at INTENTIUS/terragucci ca05bd9).
|
|
605
|
+
*/
|
|
606
|
+
export function targetCloudEnv(env: Readonly<Record<string, string | undefined>>, target: string, read: (path: string) => string = (p) => readFileSync(p, "utf-8")): Record<string, string | undefined> {
|
|
607
|
+
const out: Record<string, string | undefined> = { ...env };
|
|
608
|
+
const path = env[CLOUD_TARGETS_ENV];
|
|
609
|
+
if (!path) return out;
|
|
610
|
+
const file = readTargetsFile(path, read);
|
|
611
|
+
for (const cloud of CLOUDS) {
|
|
612
|
+
const c = file.clouds[cloud];
|
|
613
|
+
if (!c) continue;
|
|
614
|
+
const id = targetIdentityIn(c, target);
|
|
615
|
+
if (id === undefined || id === c.default) continue;
|
|
616
|
+
const vars = c.vars[id];
|
|
617
|
+
if (!vars) throw new Error(`${CLOUD_TARGETS_ENV} (${path}) holds no ${cloud} credentials for ${id}`);
|
|
618
|
+
for (const k of TRADED_VARS[cloud]) delete out[k];
|
|
619
|
+
Object.assign(out, vars);
|
|
620
|
+
}
|
|
621
|
+
return out;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* The identity `target` takes in one cloud of a {@link CloudTargetsFile}:
|
|
626
|
+
* the first entry whose globs match, else the job's own. Undefined when
|
|
627
|
+
* the job has none for that cloud.
|
|
628
|
+
*/
|
|
629
|
+
export function targetIdentityIn(cloud: { default?: string; targets: Array<{ targets: string[]; identity: string }> }, target: string): string | undefined {
|
|
630
|
+
return cloud.targets.find((t) => t.targets.some((g) => targetGlobMatch(g, target)))?.identity ?? cloud.default;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** The {@link CloudTargetsFile} at `path`, refused when it is not one. */
|
|
634
|
+
export function readTargetsFile(path: string, read: (path: string) => string = (p) => readFileSync(p, "utf-8")): CloudTargetsFile {
|
|
635
|
+
let file: CloudTargetsFile;
|
|
636
|
+
try {
|
|
637
|
+
file = JSON.parse(read(path)) as CloudTargetsFile;
|
|
638
|
+
} catch (err) {
|
|
639
|
+
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)}`);
|
|
640
|
+
}
|
|
641
|
+
if (!file || file.version !== 1 || typeof file.clouds !== "object" || file.clouds === null) throw new Error(`${CLOUD_TARGETS_ENV} names ${path}, which is not a version 1 targets file`);
|
|
642
|
+
return file;
|
|
643
|
+
}
|
|
@@ -151,6 +151,20 @@ async function runGenerateComponents(ctx: CommandContext): Promise<number> {
|
|
|
151
151
|
if (found && args.prLoop) {
|
|
152
152
|
try {
|
|
153
153
|
identity = await (await import("../../workspace/ci-cloud-identity")).declaredCloudIdentity(resolve(args.path), args.env ?? "production", plan?.member.name);
|
|
154
|
+
// #4332: the pull-request pipeline applies a member in one job, which may hold one apply identity per cloud.
|
|
155
|
+
const applies = args.applyOnMerge || args.applyBeforeMerge;
|
|
156
|
+
const problem = identity && applies ? (await import("../../ci-identity")).oneApplyIdentityProblem(identity.apply) : undefined;
|
|
157
|
+
if (problem) 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)`);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
|
|
160
|
+
return 1;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// #4330: the identity a pull request environment's jobs trade for, from a rule that names those environments.
|
|
164
|
+
let prEnvIdentity: import("../../ci-identity").CloudTradeSpec | undefined;
|
|
165
|
+
if (found && args.prLoop && args.prEnv) {
|
|
166
|
+
try {
|
|
167
|
+
prEnvIdentity = await (await import("../../workspace/ci-cloud-identity")).declaredPrEnvCloudIdentity(resolve(args.path), plan?.member.name);
|
|
154
168
|
} catch (err) {
|
|
155
169
|
console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
|
|
156
170
|
return 1;
|
|
@@ -178,7 +192,7 @@ async function runGenerateComponents(ctx: CommandContext): Promise<number> {
|
|
|
178
192
|
: {}),
|
|
179
193
|
// #3183: a plan job per pull request and an apply job per merge.
|
|
180
194
|
// #4292: on the branch ci.branches routes the member to, when it routes it.
|
|
181
|
-
...(args.prLoop ? { prLoop: { ...prLoopOption(args, reportUrl, reviewRules, isolatedRunners), ...(plan?.branch ? { branch: plan.branch } : {}), ...(identity ? { identity } : {}) } } : {}),
|
|
195
|
+
...(args.prLoop ? { prLoop: { ...prLoopOption(args, reportUrl, reviewRules, isolatedRunners), ...(plan?.branch ? { branch: plan.branch } : {}), ...(identity ? { identity } : {}), ...(prEnvIdentity ? { prEnvIdentity } : {}) } } : {}),
|
|
182
196
|
// #4002: the job image and the lines that install what it lacks, chant itself included.
|
|
183
197
|
...(args.image ? { image: args.image } : {}),
|
|
184
198
|
...(args.beforeScript?.length ? { beforeScript: args.beforeScript } : {}),
|