@yunuseon/kayra 0.7.1 → 0.8.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.
Files changed (58) hide show
  1. package/README.md +5 -5
  2. package/ansible/playbooks/apps-prune.yml +6 -6
  3. package/ansible/playbooks/platform.yml +5 -5
  4. package/ansible/roles/app/tasks/main.yml +26 -14
  5. package/ansible/roles/app/templates/app.env.j2 +1 -1
  6. package/ansible/roles/common/tasks/main.yml +1 -1
  7. package/ansible/roles/forgejo-config/tasks/main.yml +4 -9
  8. package/ansible/roles/platform/defaults/main.yml +8 -15
  9. package/ansible/roles/platform/tasks/main.yml +96 -63
  10. package/ansible/roles/platform/templates/platform.env.j2 +3 -11
  11. package/cli/dist/brand.js +0 -3
  12. package/cli/dist/cli.js +10 -1
  13. package/cli/dist/commands/server.js +16 -2
  14. package/cli/dist/deployer/app.js +47 -0
  15. package/cli/dist/deployer/brand.js +8 -0
  16. package/cli/dist/deployer/builder.js +196 -0
  17. package/cli/dist/deployer/bundle.js +49 -0
  18. package/cli/dist/deployer/compose.js +26 -0
  19. package/cli/dist/deployer/cron.js +44 -0
  20. package/cli/dist/deployer/ctl.js +77 -0
  21. package/cli/dist/deployer/deployer.js +282 -0
  22. package/cli/dist/deployer/forge.js +25 -0
  23. package/cli/dist/deployer/http.js +22 -0
  24. package/cli/dist/deployer/main.js +19 -0
  25. package/cli/dist/deployer/page.js +32 -0
  26. package/cli/dist/deployer/process.js +31 -0
  27. package/cli/dist/deployer/queue.js +28 -0
  28. package/cli/dist/deployer/records.js +46 -0
  29. package/cli/dist/deployer/run.js +76 -0
  30. package/cli/dist/deployer/store.js +31 -0
  31. package/cli/dist/deployer/webhook.js +31 -0
  32. package/cli/dist/fleet.js +1 -1
  33. package/cli/dist/main.js +11 -7
  34. package/cli/dist/settings.js +6 -10
  35. package/compose/authelia/configuration.yml +3 -3
  36. package/compose/deployer/Dockerfile.builder +37 -0
  37. package/compose/deployer/Dockerfile.deployer +40 -0
  38. package/compose/deployer/kayra-deployer +6 -0
  39. package/compose/docker-compose.ci.yml +71 -79
  40. package/hosts.conf +2 -2
  41. package/package.json +2 -3
  42. package/schema/defaults.json +2 -14
  43. package/schema/server.json +1 -1
  44. package/schema/vars-platform.json +2 -14
  45. package/scripts/local/require-main.sh +5 -1
  46. package/scripts/platform/compose-policy.sh +84 -0
  47. package/scripts/platform/registry-gc.sh +99 -0
  48. package/scripts/platform/resolve-images.sh +107 -0
  49. package/scripts/server/{jenkins-app-apply → app-apply} +9 -10
  50. package/scripts/server/app-purge +1 -1
  51. package/scripts/server/app-remove +8 -9
  52. package/ansible/roles/app/templates/casc-app.yaml.j2 +0 -68
  53. package/compose/jenkins/Dockerfile +0 -40
  54. package/compose/jenkins/casc/00-platform.yaml +0 -158
  55. package/compose/jenkins/plugins.txt +0 -97
  56. package/compose/jenkins-agent/Dockerfile +0 -24
  57. package/scripts/server/jenkins-api +0 -28
  58. package/scripts/server/jenkins-reload +0 -12
@@ -0,0 +1,282 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { createServer } from "node:http";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { createInterface } from "node:readline";
7
+ import { Readable } from "node:stream";
8
+ import { all, err, ok } from "../result.js";
9
+ import { field, registered } from "./app.js";
10
+ import { appName, appOwner, jobOwner, newRunId, sha } from "./brand.js";
11
+ import { matches, parseCron } from "./cron.js";
12
+ import { statusRequest } from "./forge.js";
13
+ import { json, send } from "./http.js";
14
+ import { spawnLines } from "./process.js";
15
+ import { empty, enqueue, finish } from "./queue.js";
16
+ import { expired, releaseName } from "./records.js";
17
+ import { deploy, renovate } from "./run.js";
18
+ import { runsOf } from "./store.js";
19
+ const token = (name) => {
20
+ const path = `/run/platform-secrets/${name}`;
21
+ return existsSync(path) ? readFileSync(path, "utf8").trim() : "";
22
+ };
23
+ const must = (result) => {
24
+ if (!result.ok)
25
+ throw new Error(result.error);
26
+ return result.value;
27
+ };
28
+ const settings = (env) => {
29
+ const scripts = env["PLATFORM_SCRIPTS"] ?? "/opt/kayra/scripts/platform";
30
+ const strings = Object.fromEntries(Object.entries(env).flatMap(([key, value]) => (value === undefined ? [] : [[key, value]])));
31
+ return {
32
+ platformDir: env["PLATFORM_DIR"] ?? "/opt/platform",
33
+ appsDir: env["APPS_DIR"] ?? "/opt/apps",
34
+ builder: env["BUILDER_URL"] ?? "http://builder:8081",
35
+ domain: env["DOMAIN"] ?? "localhost",
36
+ policyScript: join(scripts, "compose-policy.sh"),
37
+ gcScript: join(scripts, "registry-gc.sh"),
38
+ renovateImage: env["RENOVATE_IMAGE"] ?? "",
39
+ renovate: env["PLATFORM_RENOVATE"] === "true" ? must(parseCron(env["RENOVATE_SCHEDULE"] ?? "0 4 * * 1")) : undefined,
40
+ gc: must(parseCron("0 3 * * *")),
41
+ poll: must(parseCron("17 * * * *")),
42
+ env: strings,
43
+ };
44
+ };
45
+ const runsDir = (s) => join(s.platformDir, "runs");
46
+ const keepRuns = 50;
47
+ const registeredApps = (s) => {
48
+ const dir = join(s.platformDir, "apps");
49
+ return (existsSync(dir) ? readdirSync(dir) : []).flatMap((file) => {
50
+ const name = /^(.+)\.json$/.exec(file)?.[1];
51
+ const app = name === undefined ? undefined : registered(s.platformDir, name);
52
+ return app?.ok === true ? [app.value] : [];
53
+ });
54
+ };
55
+ const builderResolve = async (s, app, ref) => {
56
+ const response = await fetch(`${s.builder}/resolve`, { method: "POST", body: JSON.stringify({ app: app.name, ref }) }).catch(() => undefined);
57
+ if (response === undefined)
58
+ return err("the builder did not answer");
59
+ const answer = await response.json().catch(() => undefined);
60
+ const given = typeof answer === "object" && answer !== null ? field(answer, "sha") : undefined;
61
+ const problem = typeof answer === "object" && answer !== null ? field(answer, "error") : undefined;
62
+ return typeof given === "string" ? sha(given) : err(typeof problem === "string" ? problem : `resolve: HTTP ${String(response.status)}`);
63
+ };
64
+ /** The builder's NDJSON: log lines as they come, then the bundle or an error. */
65
+ const builderPrepare = async (s, app, commit, log) => {
66
+ const response = await fetch(`${s.builder}/prepare`, {
67
+ method: "POST",
68
+ body: JSON.stringify({ app: app.name, sha: commit }),
69
+ signal: AbortSignal.timeout(45 * 60_000),
70
+ }).catch(() => undefined);
71
+ if (response?.body == null || !response.ok)
72
+ return err("the builder did not prepare a bundle");
73
+ const lines = createInterface({ input: Readable.fromWeb(response.body) });
74
+ const outcome = await Array.fromAsync(lines, (line) => {
75
+ try {
76
+ const message = JSON.parse(line);
77
+ if (typeof message !== "object" || message === null)
78
+ return undefined;
79
+ const text = field(message, "log");
80
+ if (typeof text === "string")
81
+ log(text);
82
+ const bundle = field(message, "bundle");
83
+ const problem = field(message, "error");
84
+ return bundle !== undefined ? ok(bundle) : typeof problem === "string" ? err(problem) : undefined;
85
+ }
86
+ catch {
87
+ return undefined;
88
+ }
89
+ });
90
+ return outcome.findLast((item) => item !== undefined) ?? err("the builder stopped without a bundle");
91
+ };
92
+ const appSecrets = (app) => {
93
+ const read = all(app.secrets.map((key) => {
94
+ const path = join("/run/app-secrets", app.name, key);
95
+ return existsSync(path) ? ok([key, readFileSync(path, "utf8")]) : err(`${app.name}: secret ${key} is missing; run kayra apps`);
96
+ }));
97
+ return read.ok ? ok(Object.fromEntries(read.value)) : read;
98
+ };
99
+ const target = (s, who, id) => `https://deploy.${s.domain}/runs/${who}/${id}`;
100
+ const effects = (s, who, id, log) => ({
101
+ log,
102
+ resolve: (app, ref) => builderResolve(s, app, ref),
103
+ prepare: (app, commit) => builderPrepare(s, app, commit, log),
104
+ exec: (argv, options) => spawnLines(argv, { ...options, timeoutMs: 20 * 60_000 }, log),
105
+ secrets: appSecrets,
106
+ workdir: (bundle, images) => {
107
+ const dir = mkdtempSync(join(tmpdir(), `${id}-`));
108
+ writeFileSync(join(dir, "images.env"), images);
109
+ bundle.compose.forEach((file) => {
110
+ mkdirSync(dirname(join(dir, file.path)), { recursive: true });
111
+ writeFileSync(join(dir, file.path), file.content);
112
+ });
113
+ return Promise.resolve(dir);
114
+ },
115
+ record: (app, commit, images) => {
116
+ const dir = join(s.platformDir, "releases", app.name);
117
+ mkdirSync(dir, { recursive: true });
118
+ writeFileSync(join(dir, releaseName(new Date(), commit)), images);
119
+ return Promise.resolve();
120
+ },
121
+ status: async (app, commit, context, state, description) => {
122
+ const request = statusRequest(app.repo, commit, { context, state, description, target: target(s, who, id) }, token("GIT_TOKEN"));
123
+ if (request === undefined) {
124
+ log(`(no commit status: ${app.repo} is on no forge kayra knows)`);
125
+ return;
126
+ }
127
+ const response = await fetch(request.url, { method: "POST", headers: request.headers, body: request.body }).catch(() => undefined);
128
+ if (response?.ok !== true)
129
+ log(`(commit status ${context} not posted: ${response === undefined ? "no answer" : `HTTP ${String(response.status)}`})`);
130
+ },
131
+ });
132
+ const describe = (request) => request.work.kind === "deploy"
133
+ ? { what: `deploy ${request.work.ref}`, why: request.work.why }
134
+ : { what: `${request.work.job}${request.work.dryRun ? " (dry run)" : ""}`, why: "schedule" };
135
+ const work = async (s, request, fx) => {
136
+ if (request.work.kind === "deploy") {
137
+ const app = registered(s.platformDir, request.work.app);
138
+ if (!app.ok) {
139
+ fx.log(app.error);
140
+ return { ok: false };
141
+ }
142
+ return deploy(app.value, request.work.ref, { appsDir: s.appsDir, policyScript: s.policyScript }, fx);
143
+ }
144
+ const argv = request.work.job === "gc" ? ["bash", s.gcScript] : renovate(s.env, request.work.dryRun, s.renovateImage);
145
+ const env = request.work.job === "renovate" ? { RENOVATE_TOKEN: token("RENOVATE_TOKEN") } : {};
146
+ const exit = await fx.exec(argv, { cwd: s.platformDir, env });
147
+ return { ok: exit.code === 0 };
148
+ };
149
+ /** The daemon's one mutable cell: the queue, swapped whole on every change. */
150
+ // eslint-disable-next-line functional/prefer-immutable-types -- the daemon's state lives here, nowhere else
151
+ const cell = { queue: empty(2) };
152
+ /** Applies a change to the queue and starts what it lets start; returns those. */
153
+ const swap = (s, change) => {
154
+ const next = change(cell.queue);
155
+ // eslint-disable-next-line functional/immutable-data -- the daemon's state lives here, nowhere else
156
+ cell.queue = next.queue;
157
+ next.start.forEach((request) => {
158
+ void start(s, request);
159
+ });
160
+ return next.start;
161
+ };
162
+ const start = async (s, request) => {
163
+ const id = newRunId(new Date(), randomBytes(3).toString("hex"));
164
+ const dir = join(runsDir(s), request.owner, id);
165
+ mkdirSync(dir, { recursive: true });
166
+ const { what, why } = describe(request);
167
+ const begun = { id, owner: request.owner, what, why, state: "running", started: new Date().toISOString() };
168
+ writeFileSync(join(dir, "meta.json"), JSON.stringify(begun));
169
+ const log = (line) => {
170
+ appendFileSync(join(dir, "log"), `${new Date().toISOString().slice(11, 19)} ${line}\n`);
171
+ };
172
+ const outcome = await work(s, request, effects(s, request.owner, id, log)).catch((error) => {
173
+ log(`deployer: ${String(error)}`);
174
+ return { ok: false };
175
+ });
176
+ const ended = {
177
+ ...begun,
178
+ ...(outcome.sha === undefined ? {} : { sha: outcome.sha }),
179
+ state: outcome.ok ? "success" : "failure",
180
+ finished: new Date().toISOString(),
181
+ };
182
+ writeFileSync(join(dir, "meta.json"), JSON.stringify(ended));
183
+ expired(readdirSync(join(runsDir(s), request.owner)), keepRuns).forEach((old) => {
184
+ rmSync(join(runsDir(s), request.owner, old), { recursive: true, force: true });
185
+ });
186
+ readdirSync(tmpdir())
187
+ .filter((name) => name.startsWith(`${id}-`))
188
+ .forEach((name) => {
189
+ rmSync(join(tmpdir(), name), { recursive: true, force: true });
190
+ });
191
+ swap(s, (queue) => finish(queue, request.owner));
192
+ };
193
+ const deployRequest = (app, ref, why) => ({
194
+ owner: appOwner(app.name),
195
+ work: { kind: "deploy", app: app.name, ref, why },
196
+ });
197
+ const jobRequest = (job, dryRun) => ({ owner: jobOwner(job), work: { kind: "job", job, dryRun } });
198
+ /** The webhook's fallback: an app whose ref moved to a commit no run has tried yet. */
199
+ const poll = async (s) => {
200
+ await Promise.all(registeredApps(s).map(async (app) => {
201
+ const commit = await builderResolve(s, app, app.ref);
202
+ const last = runsOf(runsDir(s), appOwner(app.name)).find((run) => run.sha !== undefined);
203
+ if (commit.ok && commit.value !== last?.sha)
204
+ swap(s, (queue) => enqueue(queue, deployRequest(app, app.ref, "poll")));
205
+ }));
206
+ };
207
+ const tick = (s, at) => {
208
+ if (matches(s.poll, at))
209
+ void poll(s);
210
+ if (matches(s.gc, at))
211
+ swap(s, (queue) => enqueue(queue, jobRequest("gc", false)));
212
+ if (s.renovate !== undefined && matches(s.renovate, at))
213
+ swap(s, (queue) => enqueue(queue, jobRequest("renovate", false)));
214
+ };
215
+ const schedule = (s) => {
216
+ const wait = 60_000 - (Date.now() % 60_000) + 500;
217
+ setTimeout(() => {
218
+ tick(s, new Date());
219
+ schedule(s);
220
+ }, wait);
221
+ };
222
+ const handle = (handler) => (request, response) => {
223
+ handler(request, response).catch((error) => {
224
+ console.error(error);
225
+ if (!response.headersSent)
226
+ send(response, 500, "internal error\n");
227
+ });
228
+ };
229
+ const accept = (s, response, request) => {
230
+ const started = swap(s, (queue) => enqueue(queue, request)).includes(request);
231
+ send(response, 202, started ? `${request.owner}: started\n` : `${request.owner}: queued, after what runs now\n`);
232
+ };
233
+ const control = async (s, request, response) => {
234
+ const path = new URL(request.url ?? "/", "http://deployer").pathname;
235
+ const input = await json(request);
236
+ if (request.method !== "POST" || typeof input !== "object" || input === null) {
237
+ send(response, 400, "bad request\n");
238
+ return;
239
+ }
240
+ if (path === "/deploy") {
241
+ const name = field(input, "app");
242
+ const ref = field(input, "ref");
243
+ const why = field(input, "why") === "rollback" ? "rollback" : "deploy";
244
+ const app = registered(s.platformDir, typeof name === "string" ? name : "");
245
+ if (!app.ok) {
246
+ send(response, 404, `${app.error}\n`);
247
+ return;
248
+ }
249
+ accept(s, response, deployRequest(app.value, typeof ref === "string" && ref !== "" ? ref : app.value.ref, why));
250
+ return;
251
+ }
252
+ const job = field(input, "job");
253
+ if (path === "/job" && (job === "gc" || job === "renovate")) {
254
+ if (job === "renovate" && s.renovate === undefined) {
255
+ send(response, 409, "this server has no renovate_repositories\n");
256
+ return;
257
+ }
258
+ accept(s, response, jobRequest(job, field(input, "dryRun") === true));
259
+ return;
260
+ }
261
+ send(response, 404, "not found\n");
262
+ };
263
+ export const serveDeployer = (env) => {
264
+ const s = settings(env);
265
+ createServer(handle(async (request, response) => {
266
+ const name = /^\/push\/([^/]+)$/.exec(new URL(request.url ?? "/", "http://deployer").pathname)?.[1];
267
+ const app = name === undefined || !appName(name).ok ? undefined : registered(s.platformDir, name);
268
+ if (request.method !== "POST" || app === undefined) {
269
+ send(response, 404, "not found\n");
270
+ return;
271
+ }
272
+ if (!app.ok) {
273
+ send(response, 404, `${app.error}\n`);
274
+ return;
275
+ }
276
+ accept(s, response, deployRequest(app.value, app.value.ref, "push"));
277
+ return Promise.resolve();
278
+ })).listen(8090);
279
+ createServer(handle((request, response) => control(s, request, response))).listen(8091, "127.0.0.1");
280
+ schedule(s);
281
+ console.log(`deployer: :8090 pushes, 127.0.0.1:8091 control; renovate ${s.renovate === undefined ? "off" : "on"}`);
282
+ };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * A commit status on the app's forge, linking to the run: GitHub's REST API,
3
+ * or for any other host Forgejo's (Gitea's statuses endpoint). Undefined for a
4
+ * repo URL neither understands.
5
+ */
6
+ export const statusRequest = (repo, commit, status, token) => {
7
+ const match = /^(https?:\/\/[^/]+)\/([^/]+)\/([^/]+?)(\.git)?\/?$/.exec(repo);
8
+ if (match === null)
9
+ return undefined;
10
+ const [, origin, owner, name] = match;
11
+ if (origin === undefined || owner === undefined || name === undefined)
12
+ return undefined;
13
+ const body = JSON.stringify({ state: status.state, context: status.context, description: status.description, target_url: status.target });
14
+ return origin === "https://github.com"
15
+ ? {
16
+ url: `https://api.github.com/repos/${owner}/${name}/statuses/${commit}`,
17
+ headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json", "Content-Type": "application/json" },
18
+ body,
19
+ }
20
+ : {
21
+ url: `${origin}/api/v1/repos/${owner}/${name}/statuses/${commit}`,
22
+ headers: { Authorization: `token ${token}`, "Content-Type": "application/json" },
23
+ body,
24
+ };
25
+ };
@@ -0,0 +1,22 @@
1
+ /** The request body, refused past `limit` bytes. */
2
+ export const body = async (request, limit = 1 << 20) => {
3
+ const chunks = await Array.fromAsync(request, (chunk) => chunk);
4
+ const all = Buffer.concat(chunks);
5
+ return all.length > limit ? undefined : all;
6
+ };
7
+ export const json = async (request) => {
8
+ const raw = await body(request);
9
+ if (raw === undefined)
10
+ return undefined;
11
+ try {
12
+ const parsed = JSON.parse(raw.toString("utf8"));
13
+ return parsed;
14
+ }
15
+ catch {
16
+ return undefined;
17
+ }
18
+ };
19
+ export const send = (response, status, text, type = "text/plain; charset=utf-8") => {
20
+ response.writeHead(status, { "Content-Type": type });
21
+ response.end(text);
22
+ };
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ import { serveBuilder } from "./builder.js";
3
+ import { ctl } from "./ctl.js";
4
+ import { serveDeployer } from "./deployer.js";
5
+ /** One program, three roles: `builder`, `deployer` (the two daemons) and `ctl` (the operator, inside the deployer). */
6
+ const [role, ...args] = process.argv.slice(2);
7
+ if (role === "builder")
8
+ serveBuilder(process.env);
9
+ else if (role === "deployer")
10
+ serveDeployer(process.env);
11
+ else if (role === "ctl") {
12
+ // eslint-disable-next-line functional/immutable-data -- the exit status; process.exit() could cut piped output short
13
+ process.exitCode = await ctl(args);
14
+ }
15
+ else {
16
+ console.error("usage: main.ts builder | deployer | ctl …");
17
+ // eslint-disable-next-line functional/immutable-data -- the exit status
18
+ process.exitCode = 2;
19
+ }
@@ -0,0 +1,32 @@
1
+ import { log, meta, owners, runsOf } from "./store.js";
2
+ /** The read-only runs pages at deploy.<domain>, behind Authelia: what Jenkins' job pages were for. */
3
+ const escape = (text) => text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
4
+ const shell = (title, content) => `<!doctype html>
5
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>${escape(title)}</title>
7
+ <style>
8
+ :root { color-scheme: light dark; --fg: #1d1d1f; --bg: #fafafa; --muted: #6e6e73; --line: #e0e0e0; --ok: #1a7f37; --bad: #cf222e; --run: #9a6700; }
9
+ @media (prefers-color-scheme: dark) { :root { --fg: #e6e6e6; --bg: #161618; --muted: #9a9aa0; --line: #2c2c30; --ok: #3fb950; --bad: #f85149; --run: #d29922; } }
10
+ body { font: 15px/1.5 system-ui, sans-serif; color: var(--fg); background: var(--bg); margin: 0 auto; max-width: 72rem; padding: 1.5rem 1rem; }
11
+ h1 { font-size: 1.3rem; } h2 { font-size: 1.05rem; margin-top: 2rem; }
12
+ a { color: inherit; } table { border-collapse: collapse; width: 100%; }
13
+ td { padding: .3rem .6rem .3rem 0; border-bottom: 1px solid var(--line); vertical-align: top; }
14
+ .muted { color: var(--muted); } .success { color: var(--ok); } .failure { color: var(--bad); } .running { color: var(--run); }
15
+ code, pre { font: 13px/1.45 ui-monospace, monospace; } pre { white-space: pre-wrap; overflow-wrap: anywhere; }
16
+ </style></head><body>${content}</body></html>
17
+ `;
18
+ const row = (run) => `<tr><td><a href="/runs/${escape(run.owner)}/${escape(run.id)}"><code>${escape(run.id)}</code></a></td>` +
19
+ `<td class="${run.state}">${run.state}</td><td>${escape(run.what)}</td><td class="muted">${run.why}</td>` +
20
+ `<td><code>${run.sha === undefined ? "" : run.sha.slice(0, 12)}</code></td></tr>`;
21
+ export const page = (runs) => {
22
+ const sections = owners(runs).map((who) => `<h2>${escape(who)}</h2><table>${runsOf(runs, who).slice(0, 10).map(row).join("")}</table>`);
23
+ return shell("Runs", `<h1>Runs</h1>${sections.length === 0 ? `<p class="muted">Nothing has run yet.</p>` : sections.join("")}`);
24
+ };
25
+ export const runPage = (runs, who, id) => {
26
+ const found = meta(runs, who, id);
27
+ if (found === undefined)
28
+ return shell("Not found", `<p><a href="/">Runs</a> · no run ${escape(id)}</p>`);
29
+ return shell(`${who} ${id}`, `<p><a href="/">Runs</a> · ${escape(who)}</p><h1 class="${found.state}">${escape(found.what)} — ${found.state}</h1>` +
30
+ `<p class="muted">${found.why}, started ${escape(found.started)}${found.finished === undefined ? "" : `, finished ${escape(found.finished)}`}</p>` +
31
+ `<pre>${escape(log(runs, who, id) ?? "")}</pre>`);
32
+ };
@@ -0,0 +1,31 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createInterface } from "node:readline";
3
+ import { text } from "node:stream/consumers";
4
+ /** Runs a program; its stderr, and its stdout unless captured, reach `line` one line at a time. */
5
+ export const spawnLines = (argv, options, line) => {
6
+ const [program, ...args] = argv;
7
+ const child = spawn(program, args, {
8
+ cwd: options.cwd,
9
+ env: { ...process.env, ...options.env },
10
+ stdio: ["pipe", "pipe", "pipe"],
11
+ signal: AbortSignal.timeout(options.timeoutMs ?? 30 * 60_000),
12
+ });
13
+ // A child may exit before reading its stdin; that EPIPE is no failure of ours.
14
+ child.stdin.on("error", () => undefined);
15
+ child.stdin.end(options.stdin ?? "");
16
+ const stdout = options.capture === true ? text(child.stdout) : Promise.resolve("");
17
+ if (options.capture !== true)
18
+ createInterface({ input: child.stdout }).on("line", line);
19
+ createInterface({ input: child.stderr }).on("line", line);
20
+ return new Promise((resolve) => {
21
+ child.on("error", (error) => {
22
+ line(error.message);
23
+ resolve({ code: 127, stdout: "" });
24
+ });
25
+ child.on("close", (code) => {
26
+ void stdout.then((captured) => {
27
+ resolve({ code: code ?? 1, stdout: captured });
28
+ });
29
+ });
30
+ });
31
+ };
@@ -0,0 +1,28 @@
1
+ export const empty = (limit) => ({ running: [], waiting: [], limit });
2
+ const exclusive = (request) => request.work.kind === "job" && request.work.job === "gc";
3
+ /** Starts what may start, in order; a waiting exclusive request holds back everything behind it. */
4
+ const fill = (queue) => {
5
+ const pick = queue.waiting.reduce((so, request) => {
6
+ if (so.blocked || so.running.some(exclusive))
7
+ return { ...so, blocked: true };
8
+ if (exclusive(request)) {
9
+ return so.running.length === 0
10
+ ? { running: [request], blocked: true, start: [...so.start, request] }
11
+ : { ...so, blocked: true };
12
+ }
13
+ const free = so.running.length < queue.limit && !so.running.some((running) => running.owner === request.owner);
14
+ return free ? { ...so, running: [...so.running, request], start: [...so.start, request] } : so;
15
+ }, { running: queue.running, blocked: false, start: [] });
16
+ return {
17
+ queue: { ...queue, running: pick.running, waiting: queue.waiting.filter((request) => !pick.start.includes(request)) },
18
+ start: pick.start,
19
+ };
20
+ };
21
+ export const enqueue = (queue, request) => {
22
+ const waits = queue.waiting.some((waiting) => waiting.owner === request.owner);
23
+ const waiting = waits
24
+ ? queue.waiting.map((waiting) => (waiting.owner === request.owner ? request : waiting))
25
+ : [...queue.waiting, request];
26
+ return fill({ ...queue, waiting });
27
+ };
28
+ export const finish = (queue, owner) => fill({ ...queue, running: queue.running.filter((running) => running.owner !== owner) });
@@ -0,0 +1,46 @@
1
+ import { runId, sha } from "./brand.js";
2
+ import { field } from "./app.js";
3
+ /** A release record's name: releases/<app>/<UTC time>-<sha>.env. registry-gc.sh reads the same files. */
4
+ export const releaseName = (at, commit) => `${at.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z")}-${commit}.env`;
5
+ /** Newest first, one per commit: the first is what runs now, the rest are what rollback offers. */
6
+ export const releases = (names) => names
7
+ .toSorted()
8
+ .toReversed()
9
+ .flatMap((name) => {
10
+ const match = /^(\d{8}T\d{6}Z)-([0-9a-f]{40})\.env$/.exec(name);
11
+ const commit = sha(match?.[2] ?? "");
12
+ return match?.[1] === undefined || !commit.ok ? [] : [{ sha: commit.value, time: match[1] }];
13
+ })
14
+ .filter((release, i, all) => all.findIndex((other) => other.sha === release.sha) === i);
15
+ const whys = ["push", "poll", "deploy", "rollback", "schedule", "manual"];
16
+ const states = ["running", "success", "failure"];
17
+ const isWhy = (value) => whys.some((why) => why === value);
18
+ const isState = (value) => states.some((state) => state === value);
19
+ export const parseMeta = (json) => {
20
+ if (typeof json !== "object" || json === null)
21
+ return undefined;
22
+ const id = runId(String(field(json, "id")));
23
+ const owner = field(json, "owner");
24
+ const what = field(json, "what");
25
+ const why = field(json, "why");
26
+ const commit = sha(String(field(json, "sha")));
27
+ const state = field(json, "state");
28
+ const started = field(json, "started");
29
+ const finished = field(json, "finished");
30
+ if (!id.ok || typeof owner !== "string" || typeof what !== "string" || typeof started !== "string")
31
+ return undefined;
32
+ if (!isWhy(why) || !isState(state))
33
+ return undefined;
34
+ return {
35
+ id: id.value,
36
+ owner,
37
+ what,
38
+ why,
39
+ state,
40
+ started,
41
+ ...(commit.ok ? { sha: commit.value } : {}),
42
+ ...(typeof finished === "string" ? { finished } : {}),
43
+ };
44
+ };
45
+ /** Run directories past the newest `keep`, oldest first: what retention deletes. */
46
+ export const expired = (ids, keep) => ids.toSorted().slice(0, Math.max(0, ids.length - keep));
@@ -0,0 +1,76 @@
1
+ import { err, ok } from "../result.js";
2
+ import { imagesEnv, parseBundle } from "./bundle.js";
3
+ import { config, policy, up } from "./compose.js";
4
+ const step = async (fx, label, argv, options) => {
5
+ fx.log(`── ${label}`);
6
+ const exit = await fx.exec(argv, options);
7
+ return exit.code === 0 ? ok(exit.stdout) : err(`${label} failed (exit ${String(exit.code)})`);
8
+ };
9
+ /**
10
+ * Admission, then up: the compose config of the commit being deployed must
11
+ * pass the policy before anything starts, and what it checked is what runs.
12
+ */
13
+ const deployBundle = async (app, bundle, paths, fx) => {
14
+ const images = imagesEnv(bundle);
15
+ const secrets = fx.secrets(app);
16
+ if (!secrets.ok)
17
+ return secrets;
18
+ const cwd = await fx.workdir(bundle, images);
19
+ const resolved = await step(fx, "Admission: compose config", config(app, paths.appsDir, bundle), { cwd, env: secrets.value, capture: true });
20
+ if (!resolved.ok)
21
+ return resolved;
22
+ const admitted = await step(fx, "Admission: policy", policy(app, paths.policyScript), { cwd, env: {}, stdin: resolved.value });
23
+ if (!admitted.ok)
24
+ return err("refused by the compose policy");
25
+ const started = await step(fx, "Up", up(app, paths.appsDir, bundle), { cwd, env: secrets.value });
26
+ if (!started.ok)
27
+ return started;
28
+ await fx.record(app, bundle.sha, images);
29
+ return ok(images);
30
+ };
31
+ /**
32
+ * One deploy: the ref to a commit, its images built where missing (kayra/build),
33
+ * then admitted and started (kayra/deploy). Each phase posts its status on the
34
+ * commit; a failed build never reaches deploy.
35
+ */
36
+ export const deploy = async (app, ref, paths, fx) => {
37
+ fx.log(`Deploying ${app.name} at ${ref}`);
38
+ const commit = await fx.resolve(app, ref);
39
+ if (!commit.ok) {
40
+ fx.log(commit.error);
41
+ return { ok: false };
42
+ }
43
+ const sha = commit.value;
44
+ fx.log(`── ${ref} is ${sha}`);
45
+ await fx.status(app, sha, "kayra/build", "pending", "Building images");
46
+ const raw = await fx.prepare(app, sha);
47
+ const bundle = raw.ok ? parseBundle(app.name, sha, raw.value) : raw;
48
+ if (!bundle.ok) {
49
+ fx.log(bundle.error);
50
+ await fx.status(app, sha, "kayra/build", "failure", "Build failed");
51
+ return { ok: false, sha };
52
+ }
53
+ await fx.status(app, sha, "kayra/build", "success", "Images built");
54
+ await fx.status(app, sha, "kayra/deploy", "pending", "Deploying");
55
+ const deployed = await deployBundle(app, bundle.value, paths, fx);
56
+ if (!deployed.ok) {
57
+ fx.log(deployed.error);
58
+ await fx.status(app, sha, "kayra/deploy", "failure", deployed.error);
59
+ return { ok: false, sha };
60
+ }
61
+ fx.log(`Deployed ${sha}`);
62
+ await fx.status(app, sha, "kayra/deploy", "success", "Deployed");
63
+ return { ok: true, sha };
64
+ };
65
+ /** Renovate's settings, as the platform's .env holds them; the optional ones only when set. */
66
+ export const renovate = (env, dryRun, image) => {
67
+ const optional = ["RENOVATE_ENDPOINT", "RENOVATE_GIT_URL", "RENOVATE_HOST_RULES", "RENOVATE_GITHUB_COM_TOKEN"]
68
+ .filter((key) => (env[key] ?? "") !== "")
69
+ .flatMap((key) => ["-e", key]);
70
+ return [
71
+ "docker", "run", "--rm", "--network", "platform-internal",
72
+ "-e", "RENOVATE_TOKEN", "-e", "RENOVATE_PLATFORM", ...optional, "-e", "RENOVATE_REPOSITORIES",
73
+ "-e", `RENOVATE_DRY_RUN=${dryRun ? "full" : "null"}`, "-e", "LOG_LEVEL=info",
74
+ image,
75
+ ];
76
+ };
@@ -0,0 +1,31 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { owner as mintOwner, runId } from "./brand.js";
4
+ import { parseMeta } from "./records.js";
5
+ /** Reading runs/: <owner>/<id>/{meta.json,log}. Only the deployer writes there. */
6
+ const entries = (dir) => (existsSync(dir) ? readdirSync(dir).toSorted() : []);
7
+ export const owners = (runs) => entries(runs).flatMap((name) => {
8
+ const minted = mintOwner(name);
9
+ return minted.ok ? [minted.value] : [];
10
+ });
11
+ export const meta = (runs, who, id) => {
12
+ const path = join(runs, who, id, "meta.json");
13
+ if (!existsSync(path))
14
+ return undefined;
15
+ try {
16
+ return parseMeta(JSON.parse(readFileSync(path, "utf8")));
17
+ }
18
+ catch {
19
+ return undefined;
20
+ }
21
+ };
22
+ /** Newest first. */
23
+ export const runsOf = (runs, who) => entries(join(runs, who)).toReversed().flatMap((name) => {
24
+ const id = runId(name);
25
+ const found = id.ok ? meta(runs, who, id.value) : undefined;
26
+ return found === undefined ? [] : [found];
27
+ });
28
+ export const log = (runs, who, id) => {
29
+ const path = join(runs, who, id, "log");
30
+ return existsSync(path) ? readFileSync(path, "utf8") : undefined;
31
+ };
@@ -0,0 +1,31 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ import { field } from "./app.js";
3
+ /**
4
+ * Whether a webhook was signed with the platform's secret: GitHub's
5
+ * `X-Hub-Signature-256: sha256=<hex>` (Forgejo sends it too), or Forgejo's own
6
+ * `X-Forgejo-Signature: <hex>`. HMAC-SHA256 over the raw body.
7
+ */
8
+ export const signed = (secret, body, headers) => {
9
+ if (secret === "")
10
+ return false;
11
+ const hub = headers["x-hub-signature-256"];
12
+ const forgejo = headers["x-forgejo-signature"] ?? headers["x-gitea-signature"];
13
+ const given = typeof hub === "string" && hub.startsWith("sha256=") ? hub.slice(7) : typeof forgejo === "string" ? forgejo : undefined;
14
+ if (given === undefined || !/^[0-9a-f]{64}$/.test(given))
15
+ return false;
16
+ const expected = createHmac("sha256", secret).update(body).digest();
17
+ return timingSafeEqual(expected, Buffer.from(given, "hex"));
18
+ };
19
+ /** The ref a push event moved (`refs/heads/main`); undefined for any other event, e.g. GitHub's ping. */
20
+ export const pushedRef = (body) => {
21
+ try {
22
+ const event = JSON.parse(body.toString("utf8"));
23
+ if (typeof event !== "object" || event === null)
24
+ return undefined;
25
+ const ref = field(event, "ref");
26
+ return typeof ref === "string" ? ref : undefined;
27
+ }
28
+ catch {
29
+ return undefined;
30
+ }
31
+ };
package/cli/dist/fleet.js CHANGED
@@ -39,7 +39,7 @@ const vault = (descriptor) => {
39
39
  ? map(absolutePath(file), (path) => ({ kind: "file", file: vaultPasswordFile(path) }))
40
40
  : err("vault_password_file must be a path");
41
41
  };
42
- /** Apps live in their own repo: Jenkins clones it over HTTPS, and the app root stays inside it. */
42
+ /** Apps live in their own repo: the builder clones it over HTTPS, and the app root stays inside it. */
43
43
  const app = (value) => {
44
44
  const entry = mapping(value);
45
45
  if (entry === undefined)