@intentius/sql-yodeler 0.3.0 → 0.4.0
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/README.md +1 -1
- package/dist/apply-steps/index.d.ts +65 -0
- package/dist/apply-steps/index.js +106 -0
- package/dist/apply-steps/index.js.map +1 -0
- package/dist/approval-mode/index.d.ts +78 -0
- package/dist/approval-mode/index.js +105 -0
- package/dist/approval-mode/index.js.map +1 -0
- package/dist/chant/access.d.ts +87 -0
- package/dist/chant/access.js +169 -0
- package/dist/chant/access.js.map +1 -0
- package/dist/chant/drift.d.ts +17 -1
- package/dist/chant/drift.js +55 -2
- package/dist/chant/drift.js.map +1 -1
- package/dist/chant/plan.d.ts +18 -2
- package/dist/chant/plan.js +21 -3
- package/dist/chant/plan.js.map +1 -1
- package/dist/ci/render.d.ts +54 -10
- package/dist/ci/render.js +249 -52
- package/dist/ci/render.js.map +1 -1
- package/dist/cleanup/index.d.ts +86 -0
- package/dist/cleanup/index.js +207 -0
- package/dist/cleanup/index.js.map +1 -0
- package/dist/commands/apply.js +153 -20
- package/dist/commands/apply.js.map +1 -1
- package/dist/commands/approve.d.ts +13 -0
- package/dist/commands/approve.js +206 -24
- package/dist/commands/approve.js.map +1 -1
- package/dist/commands/ci.js +12 -0
- package/dist/commands/ci.js.map +1 -1
- package/dist/commands/cleanup.d.ts +13 -0
- package/dist/commands/cleanup.js +138 -0
- package/dist/commands/cleanup.js.map +1 -0
- package/dist/commands/config.d.ts +3 -0
- package/dist/commands/config.js +136 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/drift.js +34 -3
- package/dist/commands/drift.js.map +1 -1
- package/dist/commands/index.js +6 -0
- package/dist/commands/index.js.map +1 -1
- package/dist/commands/lint.js +30 -6
- package/dist/commands/lint.js.map +1 -1
- package/dist/commands/new.js +133 -34
- package/dist/commands/new.js.map +1 -1
- package/dist/commands/plan.js +42 -5
- package/dist/commands/plan.js.map +1 -1
- package/dist/commands/test.d.ts +3 -0
- package/dist/commands/test.js +164 -0
- package/dist/commands/test.js.map +1 -0
- package/dist/config/define.d.ts +117 -2
- package/dist/config/define.js +2 -0
- package/dist/config/define.js.map +1 -1
- package/dist/config/load.js +74 -10
- package/dist/config/load.js.map +1 -1
- package/dist/config/settings.d.ts +18 -0
- package/dist/config/settings.js +26 -0
- package/dist/config/settings.js.map +1 -1
- package/dist/credentials.d.ts +119 -0
- package/dist/credentials.js +119 -0
- package/dist/credentials.js.map +1 -0
- package/dist/exit.js +1 -1
- package/dist/exit.js.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/init/adopt.js +1 -1
- package/dist/init/adopt.js.map +1 -1
- package/dist/init/clickhouse-access.d.ts +64 -0
- package/dist/init/clickhouse-access.js +211 -0
- package/dist/init/clickhouse-access.js.map +1 -0
- package/dist/init/live.d.ts +4 -0
- package/dist/init/live.js +16 -0
- package/dist/init/live.js.map +1 -1
- package/dist/lint/access.d.ts +18 -0
- package/dist/lint/access.js +34 -0
- package/dist/lint/access.js.map +1 -0
- package/dist/lint/check.js +23 -6
- package/dist/lint/check.js.map +1 -1
- package/dist/lint/config.d.ts +7 -1
- package/dist/lint/config.js +11 -5
- package/dist/lint/config.js.map +1 -1
- package/dist/lint/index.d.ts +2 -0
- package/dist/lint/index.js +2 -0
- package/dist/lint/index.js.map +1 -1
- package/dist/lint/naming.d.ts +62 -0
- package/dist/lint/naming.js +138 -0
- package/dist/lint/naming.js.map +1 -0
- package/dist/lint/rules.d.ts +37 -4
- package/dist/lint/rules.js +16 -4
- package/dist/lint/rules.js.map +1 -1
- package/dist/lint/schema-checks.d.ts +63 -0
- package/dist/lint/schema-checks.js +128 -0
- package/dist/lint/schema-checks.js.map +1 -0
- package/dist/lint/statements.d.ts +22 -1
- package/dist/lint/statements.js +40 -1
- package/dist/lint/statements.js.map +1 -1
- package/dist/mcp/tools.d.ts +1 -1
- package/dist/mcp/tools.js +1 -1
- package/dist/mcp/tools.js.map +1 -1
- package/dist/migrations/checkpoint.js +0 -2
- package/dist/migrations/checkpoint.js.map +1 -1
- package/dist/migrations/checks.d.ts +19 -1
- package/dist/migrations/checks.js +15 -0
- package/dist/migrations/checks.js.map +1 -1
- package/dist/migrations/create.d.ts +13 -4
- package/dist/migrations/create.js +24 -9
- package/dist/migrations/create.js.map +1 -1
- package/dist/migrations/routines.d.ts +5 -15
- package/dist/migrations/routines.js +5 -30
- package/dist/migrations/routines.js.map +1 -1
- package/dist/pr/model.d.ts +4 -1
- package/dist/pr/model.js +13 -5
- package/dist/pr/model.js.map +1 -1
- package/dist/pr/render.d.ts +4 -1
- package/dist/pr/render.js +22 -8
- package/dist/pr/render.js.map +1 -1
- package/dist/pr/tenants.d.ts +37 -0
- package/dist/pr/tenants.js +138 -0
- package/dist/pr/tenants.js.map +1 -0
- package/dist/probe.d.ts +27 -0
- package/dist/probe.js +106 -0
- package/dist/probe.js.map +1 -0
- package/dist/project.d.ts +16 -0
- package/dist/project.js +29 -1
- package/dist/project.js.map +1 -1
- package/dist/replay/clickhouse.d.ts +31 -0
- package/dist/replay/clickhouse.js +103 -6
- package/dist/replay/clickhouse.js.map +1 -1
- package/dist/replay/replay.d.ts +5 -2
- package/dist/replay/replay.js.map +1 -1
- package/dist/report/audit.d.ts +1 -1
- package/dist/report/audit.js +20 -3
- package/dist/report/audit.js.map +1 -1
- package/dist/schemas.d.ts +3 -1
- package/dist/schemas.js +2 -0
- package/dist/schemas.js.map +1 -1
- package/dist/sources/ddl.d.ts +15 -20
- package/dist/sources/ddl.js +34 -328
- package/dist/sources/ddl.js.map +1 -1
- package/dist/sources/index.js +2 -2
- package/dist/sources/index.js.map +1 -1
- package/dist/steps/backfill.d.ts +40 -5
- package/dist/steps/backfill.js +128 -8
- package/dist/steps/backfill.js.map +1 -1
- package/dist/steps/index.d.ts +19 -1
- package/dist/steps/index.js +34 -7
- package/dist/steps/index.js.map +1 -1
- package/dist/steps/kinds.d.ts +42 -4
- package/dist/steps/kinds.js +88 -14
- package/dist/steps/kinds.js.map +1 -1
- package/dist/steps/postgres-migration.d.ts +3 -0
- package/dist/steps/postgres-migration.js +7 -3
- package/dist/steps/postgres-migration.js.map +1 -1
- package/dist/steps/rebuild.d.ts +15 -6
- package/dist/steps/rebuild.js +12 -9
- package/dist/steps/rebuild.js.map +1 -1
- package/dist/tenants/index.d.ts +2 -1
- package/dist/tenants/index.js +3 -4
- package/dist/tenants/index.js.map +1 -1
- package/dist/tenants/wave.d.ts +128 -0
- package/dist/tenants/wave.js +254 -0
- package/dist/tenants/wave.js.map +1 -0
- package/dist/testing/define.d.ts +62 -0
- package/dist/testing/define.js +32 -0
- package/dist/testing/define.js.map +1 -0
- package/dist/testing/files.d.ts +5 -0
- package/dist/testing/files.js +18 -0
- package/dist/testing/files.js.map +1 -0
- package/dist/testing/report.d.ts +14 -0
- package/dist/testing/report.js +83 -0
- package/dist/testing/report.js.map +1 -0
- package/dist/testing/run.d.ts +104 -0
- package/dist/testing/run.js +328 -0
- package/dist/testing/run.js.map +1 -0
- package/dist/versioned/approval.d.ts +6 -0
- package/dist/versioned/approval.js +11 -5
- package/dist/versioned/approval.js.map +1 -1
- package/dist/versioned/checks.d.ts +8 -2
- package/dist/versioned/checks.js +34 -13
- package/dist/versioned/checks.js.map +1 -1
- package/dist/versioned/driver.d.ts +2 -2
- package/dist/versioned/engine.d.ts +10 -0
- package/dist/versioned/engine.js +8 -4
- package/dist/versioned/engine.js.map +1 -1
- package/dist/versioned/explain.d.ts +2 -0
- package/dist/versioned/explain.js +3 -1
- package/dist/versioned/explain.js.map +1 -1
- package/dist/versioned/state.js +1 -1
- package/dist/versioned/state.js.map +1 -1
- package/dist/waves/index.d.ts +58 -13
- package/dist/waves/index.js +109 -24
- package/dist/waves/index.js.map +1 -1
- package/dist/waves/record.d.ts +10 -0
- package/dist/waves/record.js.map +1 -1
- package/package.json +7 -7
- package/schemas/apply.schema.json +1 -1
- package/schemas/cleanup.schema.json +40 -0
- package/schemas/lint-rules.schema.json +4 -0
- package/schemas/new.schema.json +41 -3
- package/schemas/plan.schema.json +39 -0
- package/schemas/report.schema.json +1 -1
- package/schemas/test.schema.json +41 -0
package/dist/ci/render.js
CHANGED
|
@@ -71,7 +71,11 @@
|
|
|
71
71
|
import { existsSync } from "node:fs";
|
|
72
72
|
import { join, resolve } from "node:path";
|
|
73
73
|
import { pathToFileURL } from "node:url";
|
|
74
|
+
import { APPROVAL_MODES } from "../config/define.js";
|
|
75
|
+
import { RECORD_PLANS_ENV } from "../approval-mode/index.js";
|
|
76
|
+
import { cloudToken, credentialsOf, roleVariables } from "../credentials.js";
|
|
74
77
|
import { loadYodelConfig } from "../config/load.js";
|
|
78
|
+
import { testFiles } from "../testing/files.js";
|
|
75
79
|
import { emitYAMLEntry, parseYAMLDocument } from "@intentius/chant/yaml";
|
|
76
80
|
import { Concurrency, Job as ActionsJob, Permissions, Service as ActionsService, Step, Workflow as ActionsWorkflow, } from "@intentius/chant-lexicon-github";
|
|
77
81
|
import { githubSerializer } from "@intentius/chant-lexicon-github/serializer";
|
|
@@ -94,16 +98,54 @@ const SETUP_NODE = "actions/setup-node@v7";
|
|
|
94
98
|
export const prefixOf = (env) => env.toUpperCase().replace(/[^A-Z0-9]/g, "_");
|
|
95
99
|
/** DEV_CLICKHOUSE for dev on ClickHouse: what an environment's variables start with. */
|
|
96
100
|
export const variablePrefix = (env, dialect) => `${prefixOf(env)}_${dialect.toUpperCase()}`;
|
|
97
|
-
/** The variables a job reading `env` holds: the URL and the reader. */
|
|
98
|
-
export const readerVariables = (env, dialect) => {
|
|
101
|
+
/** The variables a job reading `env` holds: the URL and the reader (the variables credentials() names; none for a token-minted password). */
|
|
102
|
+
export const readerVariables = (env, dialect, creds) => {
|
|
99
103
|
const p = variablePrefix(env, dialect);
|
|
100
|
-
return [`${p}_URL`, `${p}_READER_USER`, `${p}_READER_PASSWORD`];
|
|
104
|
+
return [`${p}_URL`, ...(creds?.reader.variables ?? [`${p}_READER_USER`, `${p}_READER_PASSWORD`])];
|
|
101
105
|
};
|
|
102
106
|
/** The variables the apply job for `env` holds: the URL and the writer. */
|
|
103
|
-
export const writerVariables = (env, dialect) => {
|
|
107
|
+
export const writerVariables = (env, dialect, creds) => {
|
|
104
108
|
const p = variablePrefix(env, dialect);
|
|
105
|
-
return [`${p}_URL`, `${p}_WRITER_USER`, `${p}_WRITER_PASSWORD`];
|
|
109
|
+
return [`${p}_URL`, ...(creds?.writer.variables ?? [`${p}_WRITER_USER`, `${p}_WRITER_PASSWORD`])];
|
|
106
110
|
};
|
|
111
|
+
// ── Cloud identity (#50) ────────────────────────────────────────────────────
|
|
112
|
+
//
|
|
113
|
+
// A role whose password is minted by a cloud token source (rds-iam,
|
|
114
|
+
// cloud-sql-iam, entra) mints it with the cloud's command line, from the
|
|
115
|
+
// job's identity. A job holding one gets an OIDC token from the forge: on
|
|
116
|
+
// GitHub the workflow grants `id-token: write`; on GitLab the job declares
|
|
117
|
+
// an ID token for the cloud's audience. Forgejo issues no OIDC token to a
|
|
118
|
+
// job (its dialect drops `permissions:`), so there the runner's own cloud
|
|
119
|
+
// identity mints.
|
|
120
|
+
/** The GitLab ID token per cloud: its variable and its audience. */
|
|
121
|
+
export const ID_TOKENS = {
|
|
122
|
+
"rds-iam": { variable: "AWS_ID_TOKEN", aud: "sts.amazonaws.com" },
|
|
123
|
+
"cloud-sql-iam": { variable: "GCP_ID_TOKEN", aud: "https://iam.googleapis.com/" },
|
|
124
|
+
entra: { variable: "AZURE_ID_TOKEN", aud: "api://AzureADTokenExchange" },
|
|
125
|
+
};
|
|
126
|
+
/** The cloud token sources the roles `held` mint from, each once, in order. */
|
|
127
|
+
const cloudsOf = (envs, held) => [
|
|
128
|
+
...new Set(held.map(([env, role]) => envs.credentials?.[env]?.[role].cloud).filter((c) => c !== undefined)),
|
|
129
|
+
];
|
|
130
|
+
/** A GitLab job's `id_tokens` for `clouds`. */
|
|
131
|
+
const idTokens = (clouds) => Object.fromEntries(clouds.map((c) => [ID_TOKENS[c].variable, { aud: ID_TOKENS[c].aud }]));
|
|
132
|
+
/** The roles a pull request's plan job for `env` holds: the environment's reader and the reader of the one it requires. */
|
|
133
|
+
const planRoles = (waves, env) => [env, ...requiredOf(waves, env)].map((e) => [e, "reader"]);
|
|
134
|
+
/** The roles a wave job for `env` holds: the writer, and the reader of the environment it requires. */
|
|
135
|
+
const waveRoles = (waves, env) => [[env, "writer"], ...requiredOf(waves, env).map((e) => [e, "reader"])];
|
|
136
|
+
/** The header lines saying what the cloud identity is for, when a job here holds a cloud-minted role. */
|
|
137
|
+
function identityHeader(forge, clouds) {
|
|
138
|
+
if (clouds.length === 0)
|
|
139
|
+
return [];
|
|
140
|
+
const names = clouds.join(", ");
|
|
141
|
+
if (forge === "github")
|
|
142
|
+
return [`# Cloud identity: a password here is a ${names} token, minted from the job's`, "# identity, so the workflow grants id-token: write (an OIDC token)."];
|
|
143
|
+
if (forge === "gitlab")
|
|
144
|
+
return [`# Cloud identity: a password here is a ${names} token, minted from the job's`, `# identity: the jobs that hold one declare id_tokens (${clouds.map((c) => ID_TOKENS[c].variable).join(", ")}).`];
|
|
145
|
+
return [`# Cloud identity: a password here is a ${names} token. Forgejo gives a job no`, "# OIDC token, so the runner's own cloud identity mints it."];
|
|
146
|
+
}
|
|
147
|
+
/** The write probe a pull request job runs before its plan: it holds no writer for `envs`. */
|
|
148
|
+
export const writeProbe = (envs) => `yodel config check ${envs.join(" ")} --write-probe`;
|
|
107
149
|
const secret = (name) => `\${{ secrets.${name} }}`;
|
|
108
150
|
const secrets = (names) => Object.fromEntries(names.map((n) => [n, secret(n)]));
|
|
109
151
|
/**
|
|
@@ -154,6 +196,8 @@ export const REPLAY_SERVICE = {
|
|
|
154
196
|
export const waitFor = (host, port) => `node -e 'const net = require("net"); let n = 0; (function t() { const s = net.connect(${String(port)}, "${host}", () => { s.end(); process.exit(0); }); s.on("error", () => { if (++n > 90) { console.error("${host}:${String(port)} did not answer"); process.exit(1); } setTimeout(t, 1000); }); })()'`;
|
|
155
197
|
export const LINT = "yodel lint";
|
|
156
198
|
export const REPLAY = `yodel lint --replay ${REPLAY_PROFILE} --reset`;
|
|
199
|
+
/** yodel test (#54), after the replay check on the same throwaway server, its outcome as JUnit XML. */
|
|
200
|
+
export const TEST = `yodel test --env ${REPLAY_PROFILE} --reset --junit yodel-test.xml`;
|
|
157
201
|
/** Fails a pull request whose pipelines differ from what yodel ci renders. */
|
|
158
202
|
export const CI_CHECK = "yodel ci --check";
|
|
159
203
|
/**
|
|
@@ -225,7 +269,7 @@ const runner = (container, tc) => ({ "runs-on": "ubuntu-latest", ...(container ?
|
|
|
225
269
|
function commentToken(forge) {
|
|
226
270
|
return forge === "github" ? { GITHUB_TOKEN: "${{ github.token }}" } : { FORGEJO_TOKEN: "${{ github.token }}" };
|
|
227
271
|
}
|
|
228
|
-
function lintJob(forge, dialect, replay, tc) {
|
|
272
|
+
function lintJob(forge, dialect, replay, tc, tests = false) {
|
|
229
273
|
// With the replay check, the job runs in IMAGE on every forge and reaches
|
|
230
274
|
// its replay server by the service's name on the job's own network. On
|
|
231
275
|
// GitHub's VM the service would be on localhost through a published port,
|
|
@@ -254,31 +298,35 @@ function lintJob(forge, dialect, replay, tc) {
|
|
|
254
298
|
...steps,
|
|
255
299
|
new Step({ name: `Wait for the replay server (${svc.name})`, run: waitFor(svc.name, svc.port) }),
|
|
256
300
|
new Step({ name: "yodel lint --replay", run: REPLAY }),
|
|
301
|
+
...(tests ? [new Step({ name: "yodel test", run: TEST })] : []),
|
|
257
302
|
],
|
|
258
303
|
});
|
|
259
304
|
}
|
|
260
305
|
export function actionsReview(forge, envs_) {
|
|
261
306
|
const { dialect, names: envs, replay, waves, jobs: own } = envs_;
|
|
262
307
|
const tc = toolchainOf(envs_);
|
|
263
|
-
const jobs = { lint: lintJob(forge, dialect, replay, tc) };
|
|
308
|
+
const jobs = { lint: lintJob(forge, dialect, replay, tc, envs_.tests === true) };
|
|
264
309
|
for (const env of envs) {
|
|
310
|
+
const read = [env, ...requiredOf(waves, env)];
|
|
265
311
|
jobs[`plan-${env}`] = new ActionsJob({
|
|
266
312
|
...runner(inContainer(forge, tc), tc),
|
|
267
313
|
// The plan shows where each migration has run: the required environment's reader too.
|
|
268
|
-
env: secrets([...
|
|
314
|
+
env: secrets([...read.flatMap((e) => readerVariables(e, dialect, envs_.credentials?.[e])), ...WEBHOOK_VARIABLES]),
|
|
269
315
|
steps: [
|
|
270
316
|
// The plan explains what moved since an approval from git's history of migrations/.
|
|
271
317
|
...setupSteps(inContainer(forge, tc), tc),
|
|
318
|
+
new Step({ name: "This job cannot write", run: writeProbe(read) }),
|
|
272
319
|
new Step({ name: `yodel plan ${env} --comment`, run: `yodel plan ${env} --comment`, env: commentToken(forge) }),
|
|
273
320
|
...reportSteps(env, `plan-${env}`, tc),
|
|
274
321
|
],
|
|
275
322
|
});
|
|
276
323
|
}
|
|
277
324
|
Object.assign(jobs, ownJobs(own?.actions, jobs, "actions"));
|
|
325
|
+
const clouds = cloudsOf(envs_, envs.flatMap((env) => planRoles(waves, env)));
|
|
278
326
|
const workflow = new ActionsWorkflow({
|
|
279
327
|
name: "yodel-pr",
|
|
280
328
|
on: { pull_request: { branches: [MAIN] } },
|
|
281
|
-
permissions: new Permissions({ contents: "read", "pull-requests": "write" }),
|
|
329
|
+
permissions: new Permissions({ contents: "read", "pull-requests": "write", ...(clouds.length > 0 ? { "id-token": "write" } : {}) }),
|
|
282
330
|
concurrency: new Concurrency({ group: "yodel-pr-${{ github.event.pull_request.number }}", "cancel-in-progress": true }),
|
|
283
331
|
jobs,
|
|
284
332
|
});
|
|
@@ -287,10 +335,13 @@ export function actionsReview(forge, envs_) {
|
|
|
287
335
|
"#",
|
|
288
336
|
"# Pull requests: yodel lint and the replay check (into a throwaway server the",
|
|
289
337
|
"# lint job starts), and the plan comment per environment with the",
|
|
290
|
-
"# environment's reader credentials. No job here holds a writer
|
|
338
|
+
"# environment's reader credentials. No job here holds a writer, and each plan",
|
|
339
|
+
"# job shows it first: yodel config check --write-probe tries a write and",
|
|
340
|
+
"# fails the job when the server lets it through.",
|
|
291
341
|
...(forge === "forgejo"
|
|
292
342
|
? ["# The comment is posted with the job's own token (Forgejo gives a same-repository", "# pull request one that can comment)."]
|
|
293
343
|
: []),
|
|
344
|
+
...identityHeader(forge, clouds),
|
|
294
345
|
];
|
|
295
346
|
return {
|
|
296
347
|
path: `${forge === "github" ? ".github" : ".forgejo"}/workflows/yodel-pr.yml`,
|
|
@@ -300,6 +351,10 @@ export function actionsReview(forge, envs_) {
|
|
|
300
351
|
// ── GitLab CI ───────────────────────────────────────────────────────────────
|
|
301
352
|
export const GITLAB_WATCH_FILE = ".gitlab/yodel-watch.gitlab-ci.yml";
|
|
302
353
|
export const GITLAB_APPLY_FILE = ".gitlab/yodel-apply.gitlab-ci.yml";
|
|
354
|
+
/** The stage of the scheduled resume job in GITLAB_APPLY_FILE, as chant names it. */
|
|
355
|
+
const RESUME_STAGE = "resume";
|
|
356
|
+
/** The stage of the merge request job that records the plans a pr-review wave's review approves (#48), as chant names it. */
|
|
357
|
+
const PLANS_STAGE = "plans";
|
|
303
358
|
/** The stage of each wave's job in GITLAB_APPLY_FILE, as chant names it. */
|
|
304
359
|
const waveStages = (waves) => waves.map((w, i) => `wave-${String(i + 1)}-${w.env}`);
|
|
305
360
|
export function gitlabPipeline(envs_) {
|
|
@@ -325,18 +380,20 @@ export function gitlabPipeline(envs_) {
|
|
|
325
380
|
rules: mr(),
|
|
326
381
|
...(replay
|
|
327
382
|
? {
|
|
328
|
-
script: [CI_CHECK, LINT, waitFor(svc.name, svc.port), REPLAY],
|
|
383
|
+
script: [CI_CHECK, LINT, waitFor(svc.name, svc.port), REPLAY, ...(envs_.tests === true ? [TEST] : [])],
|
|
329
384
|
services: [new GitLabService({ name: svc.image, alias: svc.name, variables: svc.env })],
|
|
330
385
|
variables: { [`${variablePrefix(REPLAY_PROFILE, dialect)}_URL`]: svc.url(svc.name), ...svc.jobEnv },
|
|
331
386
|
}
|
|
332
387
|
: { script: [CI_CHECK, LINT] }),
|
|
333
388
|
}));
|
|
334
389
|
for (const env of envs) {
|
|
390
|
+
const clouds = cloudsOf(envs_, planRoles(waves, env));
|
|
335
391
|
jobs.set(`plan-${env}`, new GitLabJob({
|
|
336
392
|
extends: ".yodel",
|
|
337
393
|
stage: "review",
|
|
338
394
|
rules: mr(),
|
|
339
|
-
script: [`yodel plan ${env} --comment`],
|
|
395
|
+
script: [writeProbe([env, ...requiredOf(waves, env)]), `yodel plan ${env} --comment`],
|
|
396
|
+
...(clouds.length > 0 ? { id_tokens: idTokens(clouds) } : {}),
|
|
340
397
|
after_script: [reportCommand(env, tc)],
|
|
341
398
|
artifacts: new GitLabArtifacts({ name: reportArtifact(`plan-${env}`), when: "always", paths: [`${REPORT_DIR}/`] }),
|
|
342
399
|
}));
|
|
@@ -356,19 +413,26 @@ export function gitlabPipeline(envs_) {
|
|
|
356
413
|
throw new Error("gitlab: expected the jobs' YAML to start with their stages, review first");
|
|
357
414
|
const derived = jobsYaml.slice(0, end + 2);
|
|
358
415
|
// The stages this file's jobs use (review, then any the project's own jobs add), then the apply file's waves and the watch file's ops.
|
|
359
|
-
|
|
416
|
+
// A pr-review wave (#48) adds the merge request job that records the plans, in chant's stage "plans".
|
|
417
|
+
const plans = waves.some((w) => w.approval === "pr-review") ? [PLANS_STAGE] : [];
|
|
418
|
+
const stages = [...new Set([...derivedStages.map(String), ...plans, ...waveStages(waves), ...(envs_.resume ? [RESUME_STAGE] : []), "ops"])];
|
|
360
419
|
const topLevel = [
|
|
361
420
|
emitYAMLEntry("include", [{ local: GITLAB_APPLY_FILE }, { local: GITLAB_WATCH_FILE }]),
|
|
362
421
|
emitYAMLEntry("stages", stages),
|
|
363
422
|
emitYAMLEntry("variables", { GIT_DEPTH: "0" }),
|
|
364
423
|
];
|
|
365
424
|
const yaml = `${serialize(new Map([["workflow", workflow]]))}\n${topLevel.join("\n\n")}\n\n${jobsYaml.slice(derived.length)}`;
|
|
366
|
-
const variables = envs.flatMap((env) =>
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
425
|
+
const variables = envs.flatMap((env) => {
|
|
426
|
+
const creds = envs_.credentials?.[env];
|
|
427
|
+
const writer = writerVariables(env, dialect, creds).slice(1);
|
|
428
|
+
return [
|
|
429
|
+
`# ${readerVariables(env, dialect, creds).join(", ")}: not protected (merge request`,
|
|
430
|
+
"# pipelines run on unprotected branches), masked; no environment scope.",
|
|
431
|
+
...(writer.length > 0
|
|
432
|
+
? [`# ${writer.join(", ")}: protected, masked,`, `# environment scope "${env}": only ${env}'s wave job declares that environment.`]
|
|
433
|
+
: []),
|
|
434
|
+
];
|
|
435
|
+
});
|
|
372
436
|
const header = [
|
|
373
437
|
...HEADER,
|
|
374
438
|
"#",
|
|
@@ -380,12 +444,22 @@ export function gitlabPipeline(envs_) {
|
|
|
380
444
|
"# protected or scoped to an environment, so the split between reader and",
|
|
381
445
|
"# writer is in the variables' settings (Settings > CI/CD > Variables):",
|
|
382
446
|
...variables,
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
447
|
+
...(envs_.resume
|
|
448
|
+
? [
|
|
449
|
+
"# GITLAB_TOKEN: a project access token with the api scope (Developer), for",
|
|
450
|
+
"# the plan comment, the drift watch's tracking issue and the resume job's",
|
|
451
|
+
"# retry of a wave whose approval arrived; CI_JOB_TOKEN cannot write merge",
|
|
452
|
+
"# request notes or issues, or retry a job.",
|
|
453
|
+
]
|
|
454
|
+
: [
|
|
455
|
+
"# GITLAB_TOKEN: a project access token with the api scope (Reporter), for",
|
|
456
|
+
"# the plan comment and the drift watch's tracking issue; CI_JOB_TOKEN",
|
|
457
|
+
"# cannot write merge request notes or issues.",
|
|
458
|
+
]),
|
|
386
459
|
"# The jobs that hold no writer read with the reader: chant.config.ts picks an",
|
|
387
460
|
"# environment's writer only under YODEL_CREDENTIALS=writer:<env>, which only",
|
|
388
461
|
"# that environment's wave job sets.",
|
|
462
|
+
...identityHeader("gitlab", cloudsOf(envs_, envs.flatMap((env) => [...planRoles(waves, env), ...waveRoles(waves, env), [env, "reader"]]))),
|
|
389
463
|
];
|
|
390
464
|
return { path: ".gitlab-ci.yml", content: withHeader(header, yaml) };
|
|
391
465
|
}
|
|
@@ -402,7 +476,7 @@ export const APPLY_PIPELINE = "yodel-apply";
|
|
|
402
476
|
* The waves spec. What the file holds is the same for every forge; each
|
|
403
477
|
* forge's jobs get their own variables on top (`variables`).
|
|
404
478
|
*/
|
|
405
|
-
export function wavesSpec(waves, variables) {
|
|
479
|
+
export function wavesSpec(waves, variables, resume) {
|
|
406
480
|
const first = waves[0];
|
|
407
481
|
if (!first)
|
|
408
482
|
throw new Error("no waves to render");
|
|
@@ -417,8 +491,10 @@ export function wavesSpec(waves, variables) {
|
|
|
417
491
|
runs: (w.targets ?? [w.env]).map((target) => ({ target, op: `migrate-${w.env}` })),
|
|
418
492
|
gate: w.gate,
|
|
419
493
|
...(w.shares !== undefined && w.shares > 1 ? { shares: w.shares } : {}),
|
|
494
|
+
...(w.approval !== undefined && w.approval !== "ledger" ? { approval: w.approval } : {}),
|
|
420
495
|
...(variables ? { environment: { name: w.env }, variables: variables(w.env) } : {}),
|
|
421
496
|
})),
|
|
497
|
+
...(resume ? { resume } : {}),
|
|
422
498
|
};
|
|
423
499
|
}
|
|
424
500
|
/** The waves spec file and the apply pipeline on each forge. */
|
|
@@ -429,13 +505,19 @@ export async function applyPipelines(dir, envs) {
|
|
|
429
505
|
const out = [{ path: WAVES_SPEC, content: `${JSON.stringify(wavesSpec(waves), null, 2)}\n` }];
|
|
430
506
|
// A GitLab job already has the project's variables, a writer's only under its environment.
|
|
431
507
|
const variables = (forge) => (env) => {
|
|
432
|
-
const
|
|
508
|
+
const wave = waves.find((w) => w.env === env);
|
|
509
|
+
const required = wave?.requires;
|
|
433
510
|
return {
|
|
434
511
|
YODEL_CREDENTIALS: `writer:${env}`,
|
|
435
512
|
...APPLY_LOCK,
|
|
436
513
|
...(forge === "gitlab"
|
|
437
514
|
? {}
|
|
438
|
-
: {
|
|
515
|
+
: {
|
|
516
|
+
...secrets([...writerVariables(env, dialect, envs.credentials?.[env]), ...WEBHOOK_VARIABLES]),
|
|
517
|
+
...(required ? secrets(readerVariables(required, dialect, envs.credentials?.[required])) : {}),
|
|
518
|
+
// A pr-review wave (#48) reads the merged pull request's reviews with the job's token.
|
|
519
|
+
...(wave?.approval === "pr-review" ? { GITHUB_TOKEN: "${{ github.token }}" } : {}),
|
|
520
|
+
}),
|
|
439
521
|
};
|
|
440
522
|
};
|
|
441
523
|
const header = [
|
|
@@ -446,13 +528,35 @@ export async function applyPipelines(dir, envs) {
|
|
|
446
528
|
`# wave's gate policy is read from ${WAVES_SPEC} as the commit before the push`,
|
|
447
529
|
"# has it (yodel.config.ts waves, rendered), and a wave that needs an approval",
|
|
448
530
|
"# exits 3 with the chant approve command, so the waves after it do not run.",
|
|
449
|
-
"#
|
|
450
|
-
"#
|
|
531
|
+
"# yodel approve <env> records the approval and starts the waiting job again",
|
|
532
|
+
"# (or approve, push chant/lifecycle, and run the failed job again). These are",
|
|
533
|
+
"# the only jobs that hold a writer's credentials, one environment's each, with",
|
|
451
534
|
"# the reader of the environment before (waves[].requires): yodel apply refuses",
|
|
452
535
|
"# a migration that environment's history does not record as applied.",
|
|
453
536
|
];
|
|
537
|
+
const resumeHeader = (forge) => [
|
|
538
|
+
...HEADER,
|
|
539
|
+
"#",
|
|
540
|
+
`# On its schedule: chant run resume --op ${APPLY_PIPELINE} starts again each apply wave`,
|
|
541
|
+
"# that waits at its gate and whose approval has arrived since. It approves",
|
|
542
|
+
"# nothing; a wave whose approval does not count waits again.",
|
|
543
|
+
...(forge === "github"
|
|
544
|
+
? ["# It re-runs the run's failed jobs with the job's token (actions: write)."]
|
|
545
|
+
: ["# Forgejo has no re-run: it dispatches yodel-apply.yml again on main, with the", "# CHANT_FORGE_TOKEN secret, a token with the write:repository scope."]),
|
|
546
|
+
];
|
|
547
|
+
const waveClouds = cloudsOf(envs, waves.flatMap((w) => waveRoles(waves, w.env)));
|
|
548
|
+
const plansHeader = [
|
|
549
|
+
...HEADER,
|
|
550
|
+
"#",
|
|
551
|
+
"# A pull request into main, when a wave's approval is pr-review (yodel.config.ts",
|
|
552
|
+
"# waves[].approval): chant run wave --record-plans plans every wave at the pull",
|
|
553
|
+
"# request's head, as it will be after the merge, and records the digests on",
|
|
554
|
+
"# chant/lifecycle. After the merge, an approving review of that head by a writer",
|
|
555
|
+
"# other than its author approves the digest it recorded, and only that one. It",
|
|
556
|
+
"# holds no writer's credentials: it plans with each environment's reader.",
|
|
557
|
+
];
|
|
454
558
|
for (const forge of FORGES) {
|
|
455
|
-
const result = await generateOpWavesPipeline(wavesSpec(waves, variables(forge)), forge, {
|
|
559
|
+
const result = await generateOpWavesPipeline(wavesSpec(waves, variables(forge), envs.resume), forge, {
|
|
456
560
|
specFile: WAVES_SPEC,
|
|
457
561
|
image: tc.image,
|
|
458
562
|
beforeScript: [...installLines(tc, forge), FETCH_APPROVALS],
|
|
@@ -462,7 +566,15 @@ export async function applyPipelines(dir, envs) {
|
|
|
462
566
|
throw new Error(`${forge}: ${result.error ?? "generateOpWavesPipeline rendered nothing"}`);
|
|
463
567
|
for (const file of result.files) {
|
|
464
568
|
const at = forge === "gitlab" ? GITLAB_APPLY_FILE : `${forge === "github" ? ".github" : ".forgejo"}/workflows/${file.name}`;
|
|
465
|
-
|
|
569
|
+
const yaml = file.yaml.endsWith("\n") ? file.yaml : `${file.yaml}\n`;
|
|
570
|
+
// The resume workflow (GitHub, Forgejo) has no wave job to report on.
|
|
571
|
+
if (forge !== "gitlab" && file.name === `${APPLY_PIPELINE}-resume.yml`)
|
|
572
|
+
out.push({ path: at, content: withHeader(resumeHeader(forge), yaml) });
|
|
573
|
+
// The pull request workflow that records the plans a review approves (#48).
|
|
574
|
+
else if (forge !== "gitlab" && file.name === `${APPLY_PIPELINE}-plans.yml`)
|
|
575
|
+
out.push({ path: at, content: withHeader(plansHeader, withPlansReaders(forge, yaml, waves, dialect, envs)) });
|
|
576
|
+
else
|
|
577
|
+
out.push({ path: at, content: withHeader([...header, ...identityHeader(forge, waveClouds)], withWaveReports(forge, yaml, waves, tc, envs)) });
|
|
466
578
|
}
|
|
467
579
|
}
|
|
468
580
|
return out;
|
|
@@ -475,7 +587,7 @@ export async function applyPipelines(dir, envs) {
|
|
|
475
587
|
* back chant's text unchanged before anything is added, so a change in
|
|
476
588
|
* chant's emitter fails here instead of rendering something else.
|
|
477
589
|
*/
|
|
478
|
-
function withWaveReports(forge, yaml, waves, tc) {
|
|
590
|
+
function withWaveReports(forge, yaml, waves, tc, envs = {}) {
|
|
479
591
|
// A wide wave's jobs are wave-<k>-<env>-decide and -share-<i>.
|
|
480
592
|
const envOf = (job) => waves.find((w, i) => new RegExp(`^wave-${String(i + 1)}-${w.env.replace(/[^A-Za-z0-9]/g, "\\$&")}(-decide|-share-\\d+)?$`).test(job))?.env;
|
|
481
593
|
const body = yaml.search(/^[^#\n]/m);
|
|
@@ -485,11 +597,20 @@ function withWaveReports(forge, yaml, waves, tc) {
|
|
|
485
597
|
const emit = (d) => `${header}${Object.entries(d).map(([k, v]) => emitYAMLEntry(k, v)).join("\n\n")}\n`;
|
|
486
598
|
if (emit(doc) !== yaml)
|
|
487
599
|
throw new Error("gitlab: the apply waves do not emit back to chant's text");
|
|
600
|
+
// The merge request job that records the plans a review approves (#48): the waves as after the merge.
|
|
601
|
+
const record = doc[`${APPLY_PIPELINE}-record-plans`];
|
|
602
|
+
if (record) {
|
|
603
|
+
record.variables = { ...(record.variables ?? {}), [RECORD_PLANS_ENV]: "1" };
|
|
604
|
+
const readers = cloudsOf(envs, [...new Set(waves.map((w) => w.env))].map((e) => [e, "reader"]));
|
|
605
|
+
if (readers.length > 0)
|
|
606
|
+
record.id_tokens = idTokens(readers);
|
|
607
|
+
}
|
|
488
608
|
for (const [name, job] of Object.entries(doc)) {
|
|
489
609
|
const env = envOf(name);
|
|
490
610
|
if (!env || typeof job !== "object" || job === null)
|
|
491
611
|
continue;
|
|
492
|
-
|
|
612
|
+
const clouds = cloudsOf(envs, waveRoles(waves, env));
|
|
613
|
+
Object.assign(job, { after_script: [reportCommand(env, tc)], artifacts: { name: reportArtifact(name), when: "always", paths: [`${REPORT_DIR}/`] }, ...(clouds.length > 0 ? { id_tokens: idTokens(clouds) } : {}) });
|
|
493
614
|
}
|
|
494
615
|
return emit(doc);
|
|
495
616
|
}
|
|
@@ -497,8 +618,9 @@ function withWaveReports(forge, yaml, waves, tc) {
|
|
|
497
618
|
if (at < 0)
|
|
498
619
|
throw new Error(`${forge}: the apply waves have no jobs`);
|
|
499
620
|
const jobs = doc.jobs;
|
|
500
|
-
const
|
|
501
|
-
|
|
621
|
+
const top = yaml.slice(0, at + 1);
|
|
622
|
+
const emitJobs = () => `${emitYAMLEntry("jobs", jobs)}\n`;
|
|
623
|
+
if (`${top}${emitJobs()}` !== yaml)
|
|
502
624
|
throw new Error(`${forge}: the apply waves do not emit back to chant's text`);
|
|
503
625
|
for (const [name, job] of Object.entries(jobs)) {
|
|
504
626
|
const env = envOf(name);
|
|
@@ -508,7 +630,33 @@ function withWaveReports(forge, yaml, waves, tc) {
|
|
|
508
630
|
// chant's forgejo output is already in Forgejo's dialect, so the action is named as the forgejo lexicon names it.
|
|
509
631
|
{ name: "Keep the report", if: "always()", uses: forge === "forgejo" ? `https://code.forgejo.org/${UPLOAD_ARTIFACT}` : UPLOAD_ARTIFACT, with: { name: reportArtifact(name), path: REPORT_DIR, "if-no-files-found": "ignore" } });
|
|
510
632
|
}
|
|
511
|
-
return
|
|
633
|
+
return `${withIdToken(forge, top, cloudsOf(envs, waves.flatMap((w) => waveRoles(waves, w.env))))}${emitJobs()}`;
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* The plans workflow (GitHub, Forgejo) for a pr-review wave (#48): chant's,
|
|
637
|
+
* with each environment's reader (the waves are planned on every one, and a
|
|
638
|
+
* wave reads the history of the one it requires) and {@link RECORD_PLANS_ENV}
|
|
639
|
+
* on its job. The output is parsed and emitted again, checked first to give
|
|
640
|
+
* back chant's text unchanged.
|
|
641
|
+
*/
|
|
642
|
+
function withPlansReaders(forge, yaml, waves, dialect, creds = {}) {
|
|
643
|
+
const at = yaml.indexOf("\njobs:\n");
|
|
644
|
+
if (at < 0)
|
|
645
|
+
throw new Error(`${forge}: the plans workflow has no jobs`);
|
|
646
|
+
const doc = parseYAMLDocument(yaml);
|
|
647
|
+
const top = yaml.slice(0, at + 1);
|
|
648
|
+
const emitJobs = () => `${emitYAMLEntry("jobs", doc.jobs)}\n`;
|
|
649
|
+
if (`${top}${emitJobs()}` !== yaml)
|
|
650
|
+
throw new Error(`${forge}: the plans workflow does not emit back to chant's text`);
|
|
651
|
+
const job = doc.jobs["record-plans"];
|
|
652
|
+
if (!job)
|
|
653
|
+
throw new Error(`${forge}: the plans workflow has no record-plans job`);
|
|
654
|
+
const envs = [...new Set(waves.map((w) => w.env))];
|
|
655
|
+
const steps = job.steps;
|
|
656
|
+
delete job.steps;
|
|
657
|
+
Object.assign(job, { env: { [RECORD_PLANS_ENV]: "1", ...secrets(envs.flatMap((e) => readerVariables(e, dialect, creds.credentials?.[e]))) }, steps });
|
|
658
|
+
// A reader whose password is a cloud token (#50) is minted from the job's identity.
|
|
659
|
+
return `${withIdToken(forge, top, cloudsOf(creds, envs.map((e) => [e, "reader"])))}${emitJobs()}`;
|
|
512
660
|
}
|
|
513
661
|
/** yodel.config.ts `waves`, checked against the environments; one wave per environment, gate `always`, without it. */
|
|
514
662
|
export async function readWaves(dir, envs) {
|
|
@@ -527,28 +675,28 @@ export async function readWaves(dir, envs) {
|
|
|
527
675
|
const requires = w.requires === false ? undefined : w.requires === undefined ? (i > 0 ? String(all[i - 1]?.env) : undefined) : typeof w.requires === "string" ? w.requires : JSON.stringify(w.requires);
|
|
528
676
|
if (requires !== undefined && !envs.includes(requires))
|
|
529
677
|
throw new Error(`yodel.config.ts: waves[${String(i)}].requires ${requires} is not an environment (${envs.join(", ")})`);
|
|
530
|
-
|
|
678
|
+
const approval = w.approval;
|
|
679
|
+
if (approval !== undefined && !APPROVAL_MODES.includes(approval))
|
|
680
|
+
throw new Error(`yodel.config.ts: waves[${String(i)}].approval ${String(w.approval)} is not ${APPROVAL_MODES.join(", ")}`);
|
|
681
|
+
return { env, gate, ...(requires ? { requires } : {}), ...(typeof w.shares === "number" && w.shares > 1 ? { shares: w.shares } : {}), ...(approval && approval !== "ledger" ? { approval } : {}) };
|
|
531
682
|
});
|
|
532
683
|
}
|
|
533
684
|
/**
|
|
534
|
-
* Each wave of an environment with a tenant set (#56), with
|
|
535
|
-
* tenant
|
|
536
|
-
*
|
|
685
|
+
* Each wave of an environment with a tenant set (#56), with its runs: a
|
|
686
|
+
* run per tenant of a list or a file, or for a set given by a query,
|
|
687
|
+
* `shares` slots (one without) that read the tenants when the wave runs.
|
|
688
|
+
* The query is not run here, so the pipeline stays the same as tenants come
|
|
689
|
+
* and go; a list or a file is in the repository, and changing it is
|
|
690
|
+
* rendering again.
|
|
537
691
|
*/
|
|
538
692
|
export async function withTenants(dir, waves) {
|
|
539
693
|
const { loadProject } = await import("../project.js");
|
|
540
|
-
const {
|
|
694
|
+
const { waveTargets } = await import("../tenants/wave.js");
|
|
541
695
|
const project = await loadProject(dir);
|
|
542
696
|
const out = [];
|
|
543
697
|
for (const w of waves) {
|
|
544
|
-
const
|
|
545
|
-
|
|
546
|
-
out.push(w);
|
|
547
|
-
continue;
|
|
548
|
-
}
|
|
549
|
-
if (tenants.length === 0)
|
|
550
|
-
throw new Error(`${w.env}'s tenant set lists no tenant, so its wave would apply nothing`);
|
|
551
|
-
out.push({ ...w, targets: tenants.map((t) => tenantTarget(w.env, t)) });
|
|
698
|
+
const targets = await waveTargets(project, w.env, w.shares);
|
|
699
|
+
out.push(targets === undefined ? w : { ...w, targets });
|
|
552
700
|
}
|
|
553
701
|
return out;
|
|
554
702
|
}
|
|
@@ -558,16 +706,43 @@ export async function withTenants(dir, waves) {
|
|
|
558
706
|
* writer's only under its environment, which the watch does not declare), and
|
|
559
707
|
* `NAME: $NAME` there would refer to itself.
|
|
560
708
|
*/
|
|
561
|
-
export function watchSpec(env, dialect, forge) {
|
|
709
|
+
export function watchSpec(env, dialect, forge, creds) {
|
|
562
710
|
if (forge === "gitlab")
|
|
563
711
|
return { name: `watch-${env}` };
|
|
564
712
|
// The job's token, for the tracking issue: GitHub grants it issues: write here, Forgejo's can write already.
|
|
565
713
|
return {
|
|
566
714
|
name: `watch-${env}`,
|
|
567
|
-
variables: { ...secrets([...readerVariables(env, dialect), ...WEBHOOK_VARIABLES]), GITHUB_TOKEN: "${{ github.token }}" },
|
|
568
|
-
...(forge === "github" ? { permissions: { issues: "write" } } : {}),
|
|
715
|
+
variables: { ...secrets([...readerVariables(env, dialect, creds), ...WEBHOOK_VARIABLES]), GITHUB_TOKEN: "${{ github.token }}" },
|
|
716
|
+
...(forge === "github" ? { permissions: { issues: "write", ...(creds?.reader.cloud ? { "id-token": "write" } : {}) } } : {}),
|
|
569
717
|
};
|
|
570
718
|
}
|
|
719
|
+
/** A GitHub workflow's top-level `permissions:` with `id-token: write` added, when a job holds a cloud-minted role. */
|
|
720
|
+
function withIdToken(forge, top, clouds) {
|
|
721
|
+
if (forge !== "github" || clouds.length === 0)
|
|
722
|
+
return top;
|
|
723
|
+
const added = top.replace(/^permissions:\n((?: {2}\S.*\n)*)/m, (_m, scopes) => `permissions:\n${scopes} id-token: write\n`);
|
|
724
|
+
if (added === top)
|
|
725
|
+
throw new Error("github: the apply waves have no top-level permissions to add id-token: write to");
|
|
726
|
+
return added;
|
|
727
|
+
}
|
|
728
|
+
/** The GitLab watch file with `id_tokens` on each watch job whose reader is cloud-minted. Parsed and emitted again, checked against chant's text first. */
|
|
729
|
+
function withWatchIdTokens(yaml, envs) {
|
|
730
|
+
const clouds = (env) => cloudsOf(envs, [[env, "reader"]]);
|
|
731
|
+
if (!envs.names.some((env) => clouds(env).length > 0))
|
|
732
|
+
return yaml;
|
|
733
|
+
const body = yaml.search(/^[^#\n]/m);
|
|
734
|
+
const header = yaml.slice(0, body);
|
|
735
|
+
const doc = parseYAMLDocument(yaml);
|
|
736
|
+
const emit = (d) => `${header}${Object.entries(d).map(([k, v]) => emitYAMLEntry(k, v)).join("\n\n")}\n`;
|
|
737
|
+
if (emit(doc) !== yaml)
|
|
738
|
+
throw new Error("gitlab: the watch file does not emit back to chant's text");
|
|
739
|
+
for (const env of envs.names) {
|
|
740
|
+
const job = doc[`watch-${env}`];
|
|
741
|
+
if (clouds(env).length > 0 && typeof job === "object" && job !== null)
|
|
742
|
+
Object.assign(job, { id_tokens: idTokens(clouds(env)) });
|
|
743
|
+
}
|
|
744
|
+
return emit(doc);
|
|
745
|
+
}
|
|
571
746
|
export async function watchPipelines(dir, envs_) {
|
|
572
747
|
const { dialect, names: envs } = envs_;
|
|
573
748
|
const tc = toolchainOf(envs_);
|
|
@@ -584,19 +759,22 @@ export async function watchPipelines(dir, envs_) {
|
|
|
584
759
|
throw new Error(`${forge}: ${result.error ?? "generateOpsPipeline rendered nothing"}`);
|
|
585
760
|
for (const file of result.files) {
|
|
586
761
|
const at = forge === "gitlab" ? GITLAB_WATCH_FILE : `${forge === "github" ? ".github" : ".forgejo"}/workflows/${file.name}`;
|
|
587
|
-
|
|
762
|
+
const yaml = file.yaml.endsWith("\n") ? file.yaml : `${file.yaml}\n`;
|
|
763
|
+
out.push({ path: at, content: `${HEADER.join("\n")}\n${forge === "gitlab" ? withWatchIdTokens(yaml, envs_) : yaml}` });
|
|
588
764
|
}
|
|
589
765
|
};
|
|
590
766
|
// `chant run` exits 0 when a watch finds drift; the last line, yodel drift, fails the job then.
|
|
591
767
|
for (const forge of ["github", "forgejo"]) {
|
|
592
768
|
for (const env of envs)
|
|
593
|
-
await emit(forge, [watchSpec(env, dialect, forge)], [watchDrift(env)]);
|
|
769
|
+
await emit(forge, [watchSpec(env, dialect, forge, envs_.credentials?.[env])], [watchDrift(env)]);
|
|
594
770
|
}
|
|
595
771
|
// GitLab: one file for every watch, so the last line takes the environment from the job's own name.
|
|
596
772
|
await emit("gitlab", envs.map((env) => watchSpec(env, dialect, "gitlab")), [watchDrift('"${CI_JOB_NAME#watch-}"')]);
|
|
597
773
|
return out;
|
|
598
774
|
}
|
|
599
775
|
// ── The project ─────────────────────────────────────────────────────────────
|
|
776
|
+
/** Whether the project has a test file for yodel test (tests/*.test.ts). */
|
|
777
|
+
const hasTests = (dir) => testFiles(dir).length > 0;
|
|
600
778
|
/** The dialect and the environments chant.config.ts declares, in order. */
|
|
601
779
|
export async function environments(dir) {
|
|
602
780
|
const { loadChantConfig } = await import("@intentius/chant/config");
|
|
@@ -617,16 +795,35 @@ export async function environments(dir) {
|
|
|
617
795
|
}
|
|
618
796
|
}
|
|
619
797
|
const ci = (await readConfig(dir))?.ci;
|
|
798
|
+
const credentials = credentialsIn(sql?.profiles ?? {}, envs);
|
|
620
799
|
return {
|
|
621
800
|
dialect,
|
|
622
801
|
names: envs,
|
|
802
|
+
...(Object.keys(credentials).length > 0 ? { credentials } : {}),
|
|
623
803
|
replay,
|
|
804
|
+
...(hasTests(dir) ? { tests: true } : {}),
|
|
624
805
|
waves: await withTenants(dir, await readWaves(dir, envs)),
|
|
625
806
|
forges: ci?.forges ?? FORGES,
|
|
626
807
|
...(ci?.jobs ? { jobs: await loadProjectJobs(dir, ci.jobs) } : {}),
|
|
627
808
|
...(ci?.image ? { image: ci.image } : {}),
|
|
809
|
+
...(ci?.resume ? { resume: { schedule: ci.resume.schedule } } : {}),
|
|
628
810
|
};
|
|
629
811
|
}
|
|
812
|
+
/** The roles of each environment whose profile is made with credentials(). */
|
|
813
|
+
export function credentialsIn(profiles, envs) {
|
|
814
|
+
const out = {};
|
|
815
|
+
for (const env of envs) {
|
|
816
|
+
const c = credentialsOf(profiles[env]);
|
|
817
|
+
if (!c)
|
|
818
|
+
continue;
|
|
819
|
+
const needs = (role) => {
|
|
820
|
+
const cloud = cloudToken(c, role);
|
|
821
|
+
return { variables: roleVariables(c, role), ...(cloud ? { cloud } : {}) };
|
|
822
|
+
};
|
|
823
|
+
out[env] = { reader: needs("reader"), writer: needs("writer") };
|
|
824
|
+
}
|
|
825
|
+
return out;
|
|
826
|
+
}
|
|
630
827
|
/** yodel.config.ts, checked; undefined when the project has none. */
|
|
631
828
|
async function readConfig(dir) {
|
|
632
829
|
return (await loadYodelConfig(dir))?.config;
|