@yunuseon/kayra 0.9.1 → 0.11.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.
@@ -27,6 +27,10 @@ acme_dns_token: ""
27
27
  git_token: ""
28
28
  # The secret forges sign webhooks with (deploy.<domain>/hook/<app>).
29
29
  webhook_secret: ""
30
+ # Where the deployer raises alerts: {} for nowhere, or {kind: github-issue,
31
+ # repository: owner/name, app_id: …} with notifier_github_app_key in secrets.yml.
32
+ notifier: {}
33
+ notifier_github_app_key: ""
30
34
 
31
35
  # owner/repo of the repos this server's Renovate job keeps up to date
32
36
  # (weekly version-bump PRs); the job runs only where the list is not empty.
@@ -39,8 +43,9 @@ renovate_token: ""
39
43
  # people's releases; a server whose repos carry the operator's own images
40
44
  # runs it more often, so a new build reaches a PR soon after it is pushed.
41
45
  renovate_schedule: "0 4 * * 1"
42
- # Where those repos live: github, or forgejo with renovate_endpoint, its API
43
- # on platform-internal (http://forgejo:3000/api/v1). With a Forgejo, also
46
+ # Where those repos live: github, gitlab (gitlab.com), or forgejo with
47
+ # renovate_endpoint, its API on platform-internal
48
+ # (http://forgejo:3000/api/v1). With a Forgejo, also
44
49
  # renovate_git_url: endpoint, so Renovate clones over that address and not the
45
50
  # public one the API reports, and a renovate_host_rules entry allowing it as
46
51
  # an internal host.
@@ -14,6 +14,13 @@
14
14
  fail_msg: renovate_repositories is set, so renovate_token must be set in servers/{{ server }}/secrets.yml
15
15
  when: renovate_repositories | length > 0
16
16
 
17
+ - name: Check the notifier has its key
18
+ ansible.builtin.assert:
19
+ that: notifier_github_app_key | length > 0
20
+ fail_msg: notifier is a github-issue notifier, so notifier_github_app_key must be set in servers/{{ server }}/secrets.yml
21
+ quiet: true
22
+ when: notifier.kind | default('') == 'github-issue'
23
+
17
24
  - name: Create platform directories
18
25
  ansible.builtin.file:
19
26
  path: "{{ item }}"
@@ -50,7 +57,7 @@
50
57
  owner: "1000"
51
58
  group: "1000"
52
59
  mode: "0755"
53
- loop: [runs, releases]
60
+ loop: [runs, releases, alerts]
54
61
  become: true
55
62
  register: deployer_dirs
56
63
 
@@ -276,6 +283,27 @@
276
283
  become: true
277
284
  register: platform_tokens
278
285
 
286
+ # Only the deployer mounts this: the builder faces the internet.
287
+ - name: Create the deployer's secrets directory
288
+ ansible.builtin.file:
289
+ path: "{{ platform_dir }}/deployer-secrets"
290
+ state: directory
291
+ owner: "1000"
292
+ group: "1000"
293
+ mode: "0500"
294
+ become: true
295
+
296
+ - name: Render the notifier's key
297
+ ansible.builtin.copy:
298
+ content: "{{ notifier_github_app_key }}"
299
+ dest: "{{ platform_dir }}/deployer-secrets/NOTIFIER_GITHUB_APP_KEY"
300
+ owner: "1000"
301
+ group: "1000"
302
+ mode: "0400"
303
+ when: notifier_github_app_key | length > 0
304
+ no_log: true
305
+ become: true
306
+
279
307
  - name: Render platform .env
280
308
  ansible.builtin.template:
281
309
  src: platform.env.j2
@@ -23,6 +23,8 @@ RENOVATE_HOST_RULES={{ renovate_host_rules | to_json if renovate_host_rules else
23
23
  RENOVATE_REPOSITORIES={{ renovate_repositories | to_json }}
24
24
 
25
25
  WEBHOOK_SECRET={{ webhook_secret }}
26
+ SERVER={{ server }}
27
+ NOTIFIER={{ notifier | to_json if notifier else '' }}
26
28
 
27
29
  # Authelia SMTP. Empty address falls the notifier back to a file — passkey
28
30
  # enrolment then needs the code read off the server. The password is NOT here:
package/cli/dist/cli.js CHANGED
@@ -14,7 +14,7 @@ export const parser = (fleet) => {
14
14
  dryRun: option("--dry-run", { description: message `Print the plan instead of running it.` }),
15
15
  command: or(
16
16
  // On one server
17
- or(onServer("up", message `Provision, set up everything, point /etc/hosts at it.`), onServer("provision", message `Create or update the machine (target home: build the image to import).`), onServer("deploy", message `Host setup and every app, no provisioning.`), onServer("setup", message `The host layer only: the playbook for the server's kind.`), onServer("plan", message `What provision and setup would change, changing nothing (OpenTofu plan, Ansible check mode).`), onServer("apps", message `Make the server's apps match server.yml: register each, remove the rest (data kept).`), onServer("hosts", message `Point the domain at a multipass VM in /etc/hosts (sudo).`), command("ssh", object({ name: constant("ssh"), server, remote: multiple(argument(string({ metavar: "COMMAND" }))) }), { brief: message `SSH into the server, or run a command there (after --).` }), onServer("down", message `Destroy the machine.`), onServer("adopt", message `Once, for a server we did not create: add the deploy user as root.`), command("backup", command("test", object({ name: constant("backup test"), server })), {
17
+ or(onServer("up", message `Provision, set up everything, point /etc/hosts at it.`), onServer("provision", message `Create or update the machine (target home: build the image to import).`), onServer("deploy", message `Host setup and every app, no provisioning.`), onServer("setup", message `The host layer only: the playbook for the server's kind.`), onServer("plan", message `What provision and setup would change, changing nothing (OpenTofu plan, Ansible check mode).`), onServer("verify", message `Whether a platform server works as its users see it: logins, certificates, webhooks, apps, runs, alerts.`), onServer("apps", message `Make the server's apps match server.yml: register each, remove the rest (data kept).`), onServer("hosts", message `Point the domain at a multipass VM in /etc/hosts (sudo).`), command("ssh", object({ name: constant("ssh"), server, remote: multiple(argument(string({ metavar: "COMMAND" }))) }), { brief: message `SSH into the server, or run a command there (after --).` }), onServer("down", message `Destroy the machine.`), onServer("adopt", message `Once, for a server we did not create: add the deploy user as root.`), command("backup", command("test", object({ name: constant("backup test"), server })), {
18
18
  brief: message `Restore the latest backup to a scratch dir, check it, delete it.`,
19
19
  }), command("app", or(command("check", object({ name: constant("app check"), dir: optional(argument(string({ metavar: "DIR" }))) }), {
20
20
  brief: message `In an app's repository: what the platform checks at deploy (app.yml, images, compose policy). No fleet needed.`,
@@ -148,7 +148,13 @@ export const hosts = (engine, server) => ok(server.target === "multipass"
148
148
  ]
149
149
  : [{ kind: "note", text: `hosts: ${server.target} uses real DNS, nothing to do.` }]);
150
150
  const sequence = (plans) => map(all(plans), (each) => each.flat());
151
- export const deploy = (engine, server) => sequence([setup(engine, server), apps(engine, server)]);
151
+ /** Whether a platform server works as its users see it (scripts/local/verify.sh); other kinds have nothing to check yet. */
152
+ const verifyStep = (engine, server) => run(["bash", script(engine, "verify.sh"), server.name], fleetEnv(engine));
153
+ export const verify = (engine, server) => server.kind === "platform"
154
+ ? ok([verifyStep(engine, server)])
155
+ : err(`'${server.name}' is of kind ${server.kind}: kayra verify checks platform servers only.`);
156
+ /** Host setup, then the apps; a platform server is verified at the end, so a deploy that leaves it broken fails. */
157
+ export const deploy = (engine, server) => map(sequence([setup(engine, server), apps(engine, server)]), (plan) => server.kind === "platform" ? [...plan, verifyStep(engine, server)] : plan);
152
158
  export const up = (engine, server) => sequence([provision(engine, server), deploy(engine, server), hosts(engine, server)]);
153
159
  export const down = (engine, server) => server.terraform
154
160
  ? ok([
@@ -7,7 +7,8 @@ import { owner, runId, sha } from "./brand.js";
7
7
  import { body, json, send } from "./http.js";
8
8
  import { page, runPage } from "./page.js";
9
9
  import { spawnLines } from "./process.js";
10
- import { pushedRef, signed } from "./webhook.js";
10
+ import { forgeRepo, signed } from "./forge.js";
11
+ import { pushedRef } from "./webhook.js";
11
12
  const fromFile = (path) => () => (existsSync(path) ? readFileSync(path, "utf8").trim() : "");
12
13
  const settings = (env) => {
13
14
  const platformDir = env["PLATFORM_DIR"] ?? "/opt/platform";
@@ -109,15 +110,16 @@ export const prepare = async (app, commit, s, line) => {
109
110
  };
110
111
  const hook = async (s, name, request, response) => {
111
112
  const raw = await body(request);
112
- if (raw === undefined || !signed(s.webhookSecret(), raw, request.headers)) {
113
- send(response, 401, "bad signature\n");
114
- return;
115
- }
116
113
  const app = registered(s.platformDir, name);
117
114
  if (!app.ok) {
118
115
  send(response, 404, `${app.error}\n`);
119
116
  return;
120
117
  }
118
+ const repo = forgeRepo(app.value.repo);
119
+ if (raw === undefined || repo === undefined || !signed(repo, s.webhookSecret(), raw, request.headers)) {
120
+ send(response, 401, "bad signature\n");
121
+ return;
122
+ }
121
123
  const ref = pushedRef(raw);
122
124
  if (ref !== `refs/heads/${app.value.ref}`) {
123
125
  send(response, 202, `ignored: ${ref ?? "not a push"} (deploys follow ${app.value.ref})\n`);
@@ -9,11 +9,13 @@ import { all, err, ok } from "../result.js";
9
9
  import { field, registered } from "./app.js";
10
10
  import { appName, appOwner, jobOwner, newRunId, sha } from "./brand.js";
11
11
  import { matches, parseCron } from "./cron.js";
12
- import { statusRequest } from "./forge.js";
12
+ import { forge, forgeRepo } from "./forge.js";
13
13
  import { json, send } from "./http.js";
14
14
  import { spawnLines } from "./process.js";
15
15
  import { empty, enqueue, finish } from "./queue.js";
16
16
  import { expired, moved, releaseName, releases } from "./records.js";
17
+ import { githubIssues } from "./github-issues.js";
18
+ import { decide, parseNotify, silent } from "./notify.js";
17
19
  import { deploy, renovate } from "./run.js";
18
20
  import { runsOf } from "./store.js";
19
21
  const token = (name) => {
@@ -40,8 +42,48 @@ const settings = (env) => {
40
42
  gc: must(parseCron("0 3 * * *")),
41
43
  poll: must(parseCron("17 * * * *")),
42
44
  env: strings,
45
+ server: env["SERVER"] ?? env["DOMAIN"] ?? "this server",
46
+ notifier: notifierFor(must(parseNotify(env["NOTIFIER"])), env["PLATFORM_DIR"] ?? "/opt/platform"),
43
47
  };
44
48
  };
49
+ /** The configured notifier; its open alerts are kept in <platformDir>/alerts/<key>. */
50
+ const notifierFor = (config, platformDir) => {
51
+ if (config.kind === "none")
52
+ return silent;
53
+ const alerts = join(platformDir, "alerts");
54
+ const keyFile = "/run/deployer-secrets/NOTIFIER_GITHUB_APP_KEY";
55
+ return githubIssues(config.repository, config.appId, {
56
+ fetch: async (url, init) => {
57
+ const response = await fetch(url, init);
58
+ return { status: response.status, json: await response.json().catch(() => undefined) };
59
+ },
60
+ privateKey: () => (existsSync(keyFile) ? ok(readFileSync(keyFile, "utf8")) : err(`${keyFile} is missing: notifier_github_app_key in secrets.yml`)),
61
+ now: () => Date.now(),
62
+ openIssue: (key) => {
63
+ const path = join(alerts, key);
64
+ const number = existsSync(path) ? Number(readFileSync(path, "utf8")) : Number.NaN;
65
+ return Number.isInteger(number) ? number : undefined;
66
+ },
67
+ remember: (key, issue) => {
68
+ mkdirSync(alerts, { recursive: true });
69
+ if (issue === undefined)
70
+ rmSync(join(alerts, key), { force: true });
71
+ else
72
+ writeFileSync(join(alerts, key), String(issue));
73
+ },
74
+ });
75
+ };
76
+ /** Raises or resolves what the finished run means; a notifier's failure is logged, never the run's. */
77
+ const notify = async (s, end, log) => {
78
+ const decision = decide(end);
79
+ const done = decision.kind === "raise"
80
+ ? await s.notifier.raise(decision.alert)
81
+ : decision.kind === "resolve"
82
+ ? await s.notifier.resolve(decision.key, decision.note)
83
+ : ok(undefined);
84
+ if (!done.ok)
85
+ log(`(alert not sent: ${done.error})`);
86
+ };
45
87
  const runsDir = (s) => join(s.platformDir, "runs");
46
88
  const keepRuns = 50;
47
89
  const registeredApps = (s) => {
@@ -119,14 +161,14 @@ const effects = (s, who, id, log) => ({
119
161
  return Promise.resolve();
120
162
  },
121
163
  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) {
164
+ const repo = forgeRepo(app.repo);
165
+ if (repo === undefined) {
124
166
  log(`(no commit status: ${app.repo} is on no forge kayra knows)`);
125
167
  return;
126
168
  }
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)}`})`);
169
+ const posted = await forge(repo, token("GIT_TOKEN"), fetch).postStatus(commit, { context, state, description, target: target(s, who, id) });
170
+ if (!posted.ok)
171
+ log(`(commit status ${context} not posted: ${posted.error})`);
130
172
  },
131
173
  });
132
174
  const describe = (request) => request.work.kind === "deploy"
@@ -180,6 +222,7 @@ const start = async (s, request) => {
180
222
  finished: new Date().toISOString(),
181
223
  };
182
224
  writeFileSync(join(dir, "meta.json"), JSON.stringify(ended));
225
+ await notify(s, { server: s.server, request, ok: outcome.ok, ...(outcome.sha === undefined ? {} : { sha: outcome.sha }), page: target(s, request.owner, id) }, log);
183
226
  expired(readdirSync(join(runsDir(s), request.owner)), keepRuns).forEach((old) => {
184
227
  rmSync(join(runsDir(s), request.owner, old), { recursive: true, force: true });
185
228
  });
@@ -1,25 +1,17 @@
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);
1
+ import { forgejo } from "./forgejo.js";
2
+ import { github } from "./github.js";
3
+ import { gitlab } from "./gitlab.js";
4
+ const adapters = { github, forgejo, gitlab };
5
+ const kinds = { "https://github.com": "github", "https://gitlab.com": "gitlab" };
6
+ /** GitHub for https://github.com, GitLab for https://gitlab.com, a Forgejo for any other host; undefined for a URL none understands. */
7
+ export const forgeRepo = (repo) => {
8
+ const match = /^(https?:\/\/[^/]+)\/(.+)\/([^/]+?)(\.git)?\/?$/.exec(repo);
8
9
  if (match === null)
9
10
  return undefined;
10
11
  const [, origin, owner, name] = match;
11
12
  if (origin === undefined || owner === undefined || name === undefined)
12
13
  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
- };
14
+ return { kind: kinds[origin] ?? "forgejo", origin, owner, name };
25
15
  };
16
+ export const forge = (repo, token, fetch) => adapters[repo.kind].api(repo, token, fetch);
17
+ export const signed = (repo, secret, body, headers) => adapters[repo.kind].signed(secret, body, headers);
@@ -0,0 +1,22 @@
1
+ import { err, ok } from "../result.js";
2
+ import { header, hmacMatches } from "./webhook.js";
3
+ /** A Forgejo's API (Gitea's statuses endpoint); webhooks signed `X-Forgejo-Signature: <hex>` (or Gitea's, or GitHub's, which it sends too). */
4
+ export const forgejo = {
5
+ api: (repo, token, fetch) => ({
6
+ postStatus: async (commit, status) => {
7
+ const response = await fetch(`${repo.origin}/api/v1/repos/${repo.owner}/${repo.name}/statuses/${commit}`, {
8
+ method: "POST",
9
+ headers: { Authorization: `token ${token}`, "Content-Type": "application/json" },
10
+ body: JSON.stringify({ state: status.state, context: status.context, description: status.description, target_url: status.target }),
11
+ }).catch(() => undefined);
12
+ if (response === undefined)
13
+ return err("no answer");
14
+ return response.ok ? ok(undefined) : err(`HTTP ${String(response.status)}`);
15
+ },
16
+ }),
17
+ signed: (secret, body, headers) => {
18
+ const hub = header(headers, "x-hub-signature-256");
19
+ const own = header(headers, "x-forgejo-signature") ?? header(headers, "x-gitea-signature");
20
+ return hmacMatches(secret, body, own ?? (hub?.startsWith("sha256=") === true ? hub.slice(7) : undefined));
21
+ },
22
+ };
@@ -0,0 +1,71 @@
1
+ import { createSign } from "node:crypto";
2
+ import { err, ok } from "../result.js";
3
+ import { field } from "./app.js";
4
+ const base64url = (data) => Buffer.from(data).toString("base64url");
5
+ /** The app's own token: a JWT signed with its private key, valid nine minutes. */
6
+ export const appJwt = (appId, privateKey, nowSeconds) => {
7
+ const header = base64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
8
+ const payload = base64url(JSON.stringify({ iat: nowSeconds - 60, exp: nowSeconds + 540, iss: appId }));
9
+ const signature = createSign("RSA-SHA256").update(`${header}.${payload}`).sign(privateKey);
10
+ return `${header}.${payload}.${base64url(signature)}`;
11
+ };
12
+ const api = "https://api.github.com";
13
+ const headers = (token) => ({
14
+ Authorization: `Bearer ${token}`,
15
+ Accept: "application/vnd.github+json",
16
+ "Content-Type": "application/json",
17
+ });
18
+ export const githubIssues = (repository, appId, world) => {
19
+ const call = async (token, method, path, body) => {
20
+ const response = await world
21
+ .fetch(`${api}${path}`, { method, headers: headers(token), ...(body === undefined ? {} : { body: JSON.stringify(body) }) })
22
+ .catch((error) => ({ status: 0, json: String(error) }));
23
+ return response.status >= 200 && response.status < 300 ? ok(response.json) : err(`GitHub ${method} ${path}: ${String(response.status)}`);
24
+ };
25
+ /** The installation's token for the repository, through the app's JWT. */
26
+ const token = async () => {
27
+ const key = world.privateKey();
28
+ if (!key.ok)
29
+ return key;
30
+ const jwt = appJwt(appId, key.value, Math.floor(world.now() / 1000));
31
+ const installation = await call(jwt, "GET", `/repos/${repository}/installation`);
32
+ const id = installation.ok && typeof installation.value === "object" && installation.value !== null ? field(installation.value, "id") : undefined;
33
+ if (typeof id !== "number")
34
+ return err(installation.ok ? "GitHub: the app is not installed on the repository" : installation.error);
35
+ const access = await call(jwt, "POST", `/app/installations/${String(id)}/access_tokens`);
36
+ const value = access.ok && typeof access.value === "object" && access.value !== null ? field(access.value, "token") : undefined;
37
+ return typeof value === "string" ? ok(value) : err(access.ok ? "GitHub: no installation token" : access.error);
38
+ };
39
+ const raise = async (alert) => {
40
+ if (world.openIssue(alert.key) !== undefined)
41
+ return ok(undefined);
42
+ const minted = await token();
43
+ if (!minted.ok)
44
+ return minted;
45
+ const created = await call(minted.value, "POST", `/repos/${repository}/issues`, { title: alert.title, body: alert.body });
46
+ if (!created.ok)
47
+ return created;
48
+ const number = typeof created.value === "object" && created.value !== null ? field(created.value, "number") : undefined;
49
+ if (typeof number !== "number")
50
+ return err("GitHub: the new issue has no number");
51
+ world.remember(alert.key, number);
52
+ return ok(undefined);
53
+ };
54
+ const resolve = async (key, note) => {
55
+ const issue = world.openIssue(key);
56
+ if (issue === undefined)
57
+ return ok(undefined);
58
+ const minted = await token();
59
+ if (!minted.ok)
60
+ return minted;
61
+ const commented = await call(minted.value, "POST", `/repos/${repository}/issues/${String(issue)}/comments`, { body: note });
62
+ if (!commented.ok)
63
+ return commented;
64
+ const closed = await call(minted.value, "PATCH", `/repos/${repository}/issues/${String(issue)}`, { state: "closed", state_reason: "completed" });
65
+ if (!closed.ok)
66
+ return closed;
67
+ world.remember(key, undefined);
68
+ return ok(undefined);
69
+ };
70
+ return { raise, resolve };
71
+ };
@@ -0,0 +1,21 @@
1
+ import { err, ok } from "../result.js";
2
+ import { header, hmacMatches } from "./webhook.js";
3
+ /** GitHub's REST API; webhooks signed `X-Hub-Signature-256: sha256=<hex>`. */
4
+ export const github = {
5
+ api: (repo, token, fetch) => ({
6
+ postStatus: async (commit, status) => {
7
+ const response = await fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}/statuses/${commit}`, {
8
+ method: "POST",
9
+ headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json", "Content-Type": "application/json" },
10
+ body: JSON.stringify({ state: status.state, context: status.context, description: status.description, target_url: status.target }),
11
+ }).catch(() => undefined);
12
+ if (response === undefined)
13
+ return err("no answer");
14
+ return response.ok ? ok(undefined) : err(`HTTP ${String(response.status)}`);
15
+ },
16
+ }),
17
+ signed: (secret, body, headers) => {
18
+ const hub = header(headers, "x-hub-signature-256");
19
+ return hub?.startsWith("sha256=") === true && hmacMatches(secret, body, hub.slice(7));
20
+ },
21
+ };
@@ -0,0 +1,25 @@
1
+ import { err, ok } from "../result.js";
2
+ import { header, secretMatches } from "./webhook.js";
3
+ /** GitLab knows no "failure" or "error", only "failed". */
4
+ const states = { pending: "pending", success: "success", failure: "failed", error: "failed" };
5
+ /**
6
+ * GitLab's REST API (v4), the project addressed by its URL-encoded path; the
7
+ * context is a status's name. Webhooks carry the secret itself as
8
+ * `X-Gitlab-Token`: GitLab signs nothing.
9
+ */
10
+ export const gitlab = {
11
+ api: (repo, token, fetch) => ({
12
+ postStatus: async (commit, status) => {
13
+ const project = encodeURIComponent(`${repo.owner}/${repo.name}`);
14
+ const response = await fetch(`${repo.origin}/api/v4/projects/${project}/statuses/${commit}`, {
15
+ method: "POST",
16
+ headers: { "PRIVATE-TOKEN": token, "Content-Type": "application/json" },
17
+ body: JSON.stringify({ state: states[status.state], name: status.context, description: status.description, target_url: status.target }),
18
+ }).catch(() => undefined);
19
+ if (response === undefined)
20
+ return err("no answer");
21
+ return response.ok ? ok(undefined) : err(`HTTP ${String(response.status)}`);
22
+ },
23
+ }),
24
+ signed: (secret, _body, headers) => secretMatches(secret, header(headers, "x-gitlab-token")),
25
+ };
@@ -0,0 +1,61 @@
1
+ import { err, ok } from "../result.js";
2
+ import { field } from "./app.js";
3
+ export const silent = {
4
+ raise: () => Promise.resolve(ok(undefined)),
5
+ resolve: () => Promise.resolve(ok(undefined)),
6
+ };
7
+ export const parseNotify = (raw) => {
8
+ if (raw === undefined || raw.trim() === "")
9
+ return ok({ kind: "none" });
10
+ try {
11
+ const json = JSON.parse(raw);
12
+ if (typeof json !== "object" || json === null)
13
+ return err("notifier: not an object");
14
+ const kind = field(json, "kind");
15
+ const repository = field(json, "repository");
16
+ const appId = field(json, "app_id");
17
+ if (kind !== "github-issue")
18
+ return err(`notifier: unknown kind ${String(kind)}`);
19
+ if (typeof repository !== "string" || !/^[\w.-]+\/[\w.-]+$/.test(repository))
20
+ return err(`notifier: repository ${String(repository)}`);
21
+ if (typeof appId !== "string" && typeof appId !== "number")
22
+ return err("notifier: app_id missing");
23
+ return ok({ kind, repository, appId: String(appId) });
24
+ }
25
+ catch {
26
+ return err("notifier: not JSON");
27
+ }
28
+ };
29
+ /**
30
+ * What a finished run means for alerts. A deploy of the app's own ref that
31
+ * failed (a push or the poll) raises; a deploy someone started by hand
32
+ * (kayra app deploy | rollback) only resolves, since whoever started it is
33
+ * watching. A platform job raises when it fails. Any success resolves.
34
+ */
35
+ export const decide = (end) => {
36
+ const { request, server } = end;
37
+ const key = request.owner;
38
+ const commit = end.sha === undefined ? "" : ` at ${end.sha.slice(0, 12)}`;
39
+ if (end.ok)
40
+ return { kind: "resolve", key, note: `Recovered: ${end.page}` };
41
+ if (request.work.kind === "deploy") {
42
+ if (request.work.why !== "push" && request.work.why !== "poll")
43
+ return { kind: "none" };
44
+ return {
45
+ kind: "raise",
46
+ alert: {
47
+ key,
48
+ title: `${server}: ${request.work.app} did not deploy${commit}`,
49
+ body: `${request.work.app}'s ${request.work.ref} failed to build or deploy on ${server}${commit}, after a ${request.work.why}.\n\nThe run: ${end.page}\n\nIt stays open until a deploy of ${request.work.app} on ${server} succeeds.`,
50
+ },
51
+ };
52
+ }
53
+ return {
54
+ kind: "raise",
55
+ alert: {
56
+ key,
57
+ title: `${server}: the ${request.work.job} job failed`,
58
+ body: `The platform's ${request.work.job} job failed on ${server}.\n\nThe run: ${end.page}\n\nIt stays open until the job succeeds on ${server}.`,
59
+ },
60
+ };
61
+ };
@@ -1,22 +1,25 @@
1
- import { createHmac, timingSafeEqual } from "node:crypto";
1
+ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
2
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))
3
+ /** A header's single value; undefined when it is missing or repeated. */
4
+ export const header = (headers, name) => {
5
+ const value = headers[name];
6
+ return typeof value === "string" ? value : undefined;
7
+ };
8
+ /** Whether `given` is the hex HMAC-SHA256 of the raw body under the platform's secret. */
9
+ export const hmacMatches = (secret, body, given) => {
10
+ if (secret === "" || given === undefined || !/^[0-9a-f]{64}$/.test(given))
15
11
  return false;
16
12
  const expected = createHmac("sha256", secret).update(body).digest();
17
13
  return timingSafeEqual(expected, Buffer.from(given, "hex"));
18
14
  };
19
- /** The ref a push event moved (`refs/heads/main`); undefined for any other event, e.g. GitHub's ping. */
15
+ /** Whether `given` is the platform's secret itself, compared in constant time. */
16
+ export const secretMatches = (secret, given) => {
17
+ if (secret === "" || given === undefined)
18
+ return false;
19
+ const digest = (text) => createHash("sha256").update(text).digest();
20
+ return timingSafeEqual(digest(secret), digest(given));
21
+ };
22
+ /** The ref a push event moved (`refs/heads/main`); undefined for any other event, e.g. GitHub's ping. All three forges send it as `ref`. */
20
23
  export const pushedRef = (body) => {
21
24
  try {
22
25
  const event = JSON.parse(body.toString("utf8"));
package/cli/dist/fleet.js CHANGED
@@ -39,8 +39,12 @@ 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: the builder clones it over HTTPS, and the app root stays inside it. */
43
- const app = (value) => {
42
+ /**
43
+ * Apps live in their own repo: the builder clones it over HTTPS, and the app
44
+ * root stays inside it. A multipass server, a test machine by definition, may
45
+ * clone over plain HTTP too: the upgrade lab serves its fixture app that way.
46
+ */
47
+ const app = (target) => (value) => {
44
48
  const entry = mapping(value);
45
49
  if (entry === undefined)
46
50
  return err("an app must be a mapping");
@@ -48,8 +52,9 @@ const app = (value) => {
48
52
  if (!name.ok)
49
53
  return name;
50
54
  const repo = entry["repo"];
51
- if (typeof repo !== "string" || !repo.startsWith("https://"))
52
- return err(`app ${name.value}: repo must be an https:// URL`);
55
+ const schemes = target === "multipass" ? ["https://", "http://"] : ["https://"];
56
+ if (typeof repo !== "string" || !schemes.some((scheme) => repo.startsWith(scheme)))
57
+ return err(`app ${name.value}: repo must be an https:// URL${target === "multipass" ? " (or http:// on multipass)" : ""}`);
53
58
  const path = entry["path"] ?? ".";
54
59
  if (typeof path !== "string" || `/${path}/`.includes("/../"))
55
60
  return err(`app ${name.value}: path must stay inside its repo`);
@@ -98,7 +103,7 @@ export const parseServer = (files) => {
98
103
  return err("apps must be a list");
99
104
  if (kind.value !== "platform" && declaredApps.length > 0)
100
105
  return err("apps run on platform servers only");
101
- const apps = all(declaredApps.map(app));
106
+ const apps = all(declaredApps.map(app(target.value)));
102
107
  if (!apps.ok)
103
108
  return apps;
104
109
  const shape = checkShape("server.yml", descriptor.value, serverSchema);
package/cli/dist/main.js CHANGED
@@ -38,6 +38,8 @@ const onServer = (on, target, command) => {
38
38
  return server.setup(on, target);
39
39
  case "plan":
40
40
  return server.preview(on, target);
41
+ case "verify":
42
+ return server.verify(on, target);
41
43
  case "apps":
42
44
  return server.apps(on, target);
43
45
  case "hosts":
@@ -22,7 +22,7 @@ export const settings = [
22
22
  setting("acme_dns", platform, z.enum(["", "hetzner"]), "Certificates by DNS-01 instead of HTTP-01, for a server the internet never reaches: the DNS provider (hetzner)."),
23
23
  setting("renovate_repositories", platform, texts, "owner/repo of the repos this server's Renovate job keeps up to date; the job runs only where the list is not empty."),
24
24
  setting("renovate_schedule", platform, text, "When the Renovate job runs, as a five-field cron spec in the server's time (default weekly: 0 4 * * 1)."),
25
- setting("renovate_platform", platform, z.enum(["github", "forgejo"]), "Where the repos live: github (default) or forgejo."),
25
+ setting("renovate_platform", platform, z.enum(["github", "forgejo", "gitlab"]), "Where the repos live: github (default), forgejo or gitlab."),
26
26
  setting("renovate_endpoint", platform, text, "With a Forgejo: its API on platform-internal, e.g. http://forgejo:3000/api/v1."),
27
27
  setting("renovate_git_url", platform, z.enum(["default", "ssh", "endpoint"]), "How Renovate clones; endpoint with a Forgejo, so it uses the internal address."),
28
28
  setting("renovate_host_rules", platform, z.array(record({ matchHost: text })), "Renovate hostRules: allowInternal for a Forgejo on platform-internal, credentials for a private registry."),
@@ -64,6 +64,9 @@ export const settings = [
64
64
  // edge
65
65
  setting("wireguard", edge, record({ address: text, port: z.number(), peer_public_key: text, peer_allowed_ips: texts, keepalive: z.number().optional() }), "The tunnel into the private network: the edge's address, port, and the one peer (its key, what it may reach)."),
66
66
  setting("edge_routes", edge, z.array(record({ subdomains: texts, upstream: text, forwarded_headers: z.boolean().optional() })), "Public names the edge serves: subdomains of the domain and the upstream inside the tunnel they go to."),
67
+ setting("notifier", platform, z.discriminatedUnion("kind", [
68
+ z.strictObject({ kind: z.literal("github-issue"), repository: text, app_id: z.union([text, z.number()]) }),
69
+ ]), "Where the deployer raises alerts (a failed deploy of an app's ref, a failed job) and resolves them: kind github-issue, an issue in repository (owner/name) opened by the GitHub App app_id; the key in notifier_github_app_key."),
67
70
  setting("backup_check", edge, record({
68
71
  stale_after_hours: z.number(),
69
72
  github_repository: text,
@@ -71,6 +74,7 @@ export const settings = [
71
74
  repositories: z.array(record({ name: text, url: text, user: text, restore_test_max_days: z.number().optional() })),
72
75
  }), "The hourly check of the private network's backups: when a snapshot is stale, where issues go, which repositories."),
73
76
  // Secrets
77
+ secret("notifier_github_app_key", platform, text, "With notifier kind github-issue: the GitHub App's private key (PEM) the deployer opens issues with."),
74
78
  secret("git_token", platform, text, "Token the builder clones app repos with and the deployer posts commit statuses with."),
75
79
  secret("webhook_secret", platform, text, "HMAC secret the apps' push webhooks (deploy.<domain>/hook/<app>) are signed with."),
76
80
  secret("renovate_token", platform, text, "Token the Renovate job opens PRs with."),
@@ -128,12 +128,18 @@ services:
128
128
  - RENOVATE_GITHUB_COM_TOKEN=${RENOVATE_GITHUB_COM_TOKEN:-}
129
129
  - RENOVATE_HOST_RULES=${RENOVATE_HOST_RULES:-}
130
130
  - RENOVATE_REPOSITORIES=${RENOVATE_REPOSITORIES:-}
131
+ - SERVER=${SERVER:-}
132
+ - NOTIFIER=${NOTIFIER:-}
131
133
  volumes:
132
134
  - /var/run/docker.sock:/var/run/docker.sock
133
135
  - ${APPS_DIR:?}:${APPS_DIR:?}:ro
134
136
  - ./apps:/opt/platform/apps:ro
135
137
  - ./runs:/opt/platform/runs
136
138
  - ./releases:/opt/platform/releases
139
+ # The alerts open now: one file per key, holding where it is open.
140
+ - ./alerts:/opt/platform/alerts
141
+ # The notifier's key; mounted nowhere else.
142
+ - ./deployer-secrets:/run/deployer-secrets:ro
137
143
  # One directory per app, one file per secret. Written by bin/app-apply.
138
144
  - ./app-secrets:/run/app-secrets:ro
139
145
  # GIT_TOKEN (commit statuses) and RENOVATE_TOKEN.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yunuseon/kayra",
3
- "version": "0.9.1",
3
+ "version": "0.11.0",
4
4
  "description": "A self-hosted app platform and the CLI that runs it: Traefik, Authelia, a registry, rootless BuildKit and a small deployer per server, set up with OpenTofu and Ansible.",
5
5
  "license": "MIT",
6
6
  "author": "Yunus Öztürk",
@@ -53,9 +53,10 @@
53
53
  "type": "string",
54
54
  "enum": [
55
55
  "github",
56
- "forgejo"
56
+ "forgejo",
57
+ "gitlab"
57
58
  ],
58
- "description": "Where the repos live: github (default) or forgejo."
59
+ "description": "Where the repos live: github (default), forgejo or gitlab."
59
60
  },
60
61
  "renovate_endpoint": {
61
62
  "type": "string",
@@ -468,6 +469,35 @@
468
469
  },
469
470
  "description": "Public names the edge serves: subdomains of the domain and the upstream inside the tunnel they go to."
470
471
  },
472
+ "notifier": {
473
+ "oneOf": [
474
+ {
475
+ "type": "object",
476
+ "properties": {
477
+ "kind": {
478
+ "type": "string",
479
+ "const": "github-issue"
480
+ },
481
+ "repository": {
482
+ "type": "string"
483
+ },
484
+ "app_id": {
485
+ "type": [
486
+ "string",
487
+ "number"
488
+ ]
489
+ }
490
+ },
491
+ "required": [
492
+ "kind",
493
+ "repository",
494
+ "app_id"
495
+ ],
496
+ "additionalProperties": false
497
+ }
498
+ ],
499
+ "description": "Where the deployer raises alerts (a failed deploy of an app's ref, a failed job) and resolves them: kind github-issue, an issue in repository (owner/name) opened by the GitHub App app_id; the key in notifier_github_app_key."
500
+ },
471
501
  "backup_check": {
472
502
  "type": "object",
473
503
  "properties": {
@@ -53,9 +53,10 @@
53
53
  "type": "string",
54
54
  "enum": [
55
55
  "github",
56
- "forgejo"
56
+ "forgejo",
57
+ "gitlab"
57
58
  ],
58
- "description": "Where the repos live: github (default) or forgejo."
59
+ "description": "Where the repos live: github (default), forgejo or gitlab."
59
60
  },
60
61
  "renovate_endpoint": {
61
62
  "type": "string",
@@ -314,6 +315,35 @@
314
315
  ],
315
316
  "additionalProperties": {},
316
317
  "description": "The server's own compose stacks: their directory on the server and the names under stacks/ to run."
318
+ },
319
+ "notifier": {
320
+ "oneOf": [
321
+ {
322
+ "type": "object",
323
+ "properties": {
324
+ "kind": {
325
+ "type": "string",
326
+ "const": "github-issue"
327
+ },
328
+ "repository": {
329
+ "type": "string"
330
+ },
331
+ "app_id": {
332
+ "type": [
333
+ "string",
334
+ "number"
335
+ ]
336
+ }
337
+ },
338
+ "required": [
339
+ "kind",
340
+ "repository",
341
+ "app_id"
342
+ ],
343
+ "additionalProperties": false
344
+ }
345
+ ],
346
+ "description": "Where the deployer raises alerts (a failed deploy of an app's ref, a failed job) and resolves them: kind github-issue, an issue in repository (owner/name) opened by the GitHub App app_id; the key in notifier_github_app_key."
317
347
  }
318
348
  },
319
349
  "additionalProperties": false,
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env bash
2
+ # =============================================================================
3
+ # verify.sh <server> — is a platform server working, as its users see it?
4
+ # Run by `kayra verify` and at the end of `kayra deploy` / `kayra up`.
5
+ #
6
+ # From here, the way the world reaches it (each name pinned to the server's
7
+ # address, so neither DNS nor /etc/hosts has to be in place yet):
8
+ # - auth.<domain> answers; the platform's own pages (hosts.conf) send a
9
+ # browser to the login, not a 403 from rules Authelia never loaded
10
+ # - every certificate is a real one (Let's Encrypt or mkcert), never
11
+ # Traefik's default
12
+ # - an unsigned webhook is refused; every app hostname answers, not 5xx
13
+ # On the server (one SSH call):
14
+ # - every app in server.yml is registered, and nothing else
15
+ # - every app's containers run, none unhealthy; its last run did not fail
16
+ # - every platform container runs, none unhealthy; no alert is open
17
+ #
18
+ # Certificates and routes can lag a fresh deploy (ACME, DNS-01's minute), so
19
+ # the probes retry for VERIFY_WAIT seconds (default 150) before failing.
20
+ # Written for macOS's bash 3.2 too.
21
+ # =============================================================================
22
+ set -euo pipefail
23
+
24
+ KAYRA_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
25
+ FLEET_ROOT="${FLEET_ROOT:?the directory of the fleet (servers/), set by kayra}"
26
+ name="${1:?Usage: verify.sh <server>}"
27
+ dir="${FLEET_ROOT}/servers/${name}"
28
+ wait="${VERIFY_WAIT:-150}"
29
+ failed=0
30
+
31
+ fail() { echo "✗ $*"; failed=1; }
32
+ pass() { echo "✓ $*"; }
33
+
34
+ domain="$(yq -r '.domain' "${dir}/server.yml")"
35
+ inventory="${dir}/inventory.ini"
36
+ [ -f "$inventory" ] || { fail "no inventory for ${name}: provision it first"; exit 1; }
37
+ field() { awk -v key="$1" '{for (i = 1; i <= NF; i++) if (index($i, key "=") == 1) {print substr($i, length(key) + 2); exit}}' "$inventory"; }
38
+ address="$(field ansible_host)"
39
+ port="$(field ansible_port)"
40
+ user="$(field ansible_user)"
41
+ key="$(field ansible_ssh_private_key_file)"
42
+ registered_wanted="$(yq -r '.apps // [] | .[].name' "${dir}/server.yml")"
43
+
44
+ # ── From outside ────────────────────────────────────────────────────────────
45
+ # "<status> <redirect> <verify result> <issuer CN>" for a host, pinned to the server.
46
+ probe() {
47
+ local host="$1" method="${2:-GET}" result issuer
48
+ result="$(curl -s -o /dev/null -m 10 -X "$method" -H 'Accept: text/html' \
49
+ --resolve "${host}:443:${address}" -w '%{http_code} %{redirect_url} %{ssl_verify_result}' \
50
+ "https://${host}/" 2>/dev/null || echo "000 - -")"
51
+ issuer="$(openssl s_client -connect "${address}:443" -servername "$host" </dev/null 2>/dev/null \
52
+ | openssl x509 -noout -issuer 2>/dev/null | sed -n 's/.*CN *= *//p')"
53
+ echo "${result} ${issuer:-none}"
54
+ }
55
+
56
+ # Probes until `test` accepts the answer or VERIFY_WAIT runs out; prints the last answer.
57
+ until_ok() {
58
+ local host="$1" method="$2" test="$3" deadline answer
59
+ deadline=$(( $(date +%s) + wait ))
60
+ while :; do
61
+ answer="$(probe "$host" "$method")"
62
+ if "$test" "$answer"; then echo "$answer"; return 0; fi
63
+ [ "$(date +%s)" -lt "$deadline" ] || { echo "$answer"; return 1; }
64
+ sleep 10
65
+ done
66
+ }
67
+
68
+ real_cert() { case "$1" in *"TRAEFIK DEFAULT CERT"|*" none") return 1 ;; esac; [ "$(cut -d' ' -f3 <<< "$1")" = 0 ]; }
69
+ answers() { real_cert "$1" && [ "$(cut -d' ' -f1 <<< "$1")" = 200 ]; }
70
+ to_login() { real_cert "$1" && [ "$(cut -d' ' -f1 <<< "$1")" = 302 ] && case "$(cut -d' ' -f2 <<< "$1")" in "https://auth.${domain}/"*) return 0 ;; esac; return 1; }
71
+ not_5xx() { real_cert "$1" && case "$(cut -d' ' -f1 <<< "$1")" in 000|5*) return 1 ;; esac; }
72
+ refused() { real_cert "$1" && [ "$(cut -d' ' -f1 <<< "$1")" = 401 ]; }
73
+
74
+ describe() { echo "HTTP $(cut -d' ' -f1 <<< "$1"), certificate by $(cut -d' ' -f4- <<< "$1") (verify $(cut -d' ' -f3 <<< "$1"))"; }
75
+
76
+ check() {
77
+ local label="$1" host="$2" method="$3" test="$4" answer
78
+ if answer="$(until_ok "$host" "$method" "$test")"; then pass "$label"; else fail "${label}: $(describe "$answer")"; fi
79
+ }
80
+
81
+ check "auth.${domain} answers" "auth.${domain}" GET answers
82
+ while IFS= read -r sub; do
83
+ [ "$sub" = auth ] || check "${sub}.${domain} sends a browser to the login" "${sub}.${domain}" GET to_login
84
+ done < <(awk '!/^[[:space:]]*(#|$)/ {print $1}' "${KAYRA_ROOT}/hosts.conf")
85
+
86
+ first_app="$(head -1 <<< "$registered_wanted")"
87
+ if [ -n "$first_app" ]; then
88
+ hook() {
89
+ curl -s -o /dev/null -m 10 -X POST --resolve "deploy.${domain}:443:${address}" \
90
+ -w '%{http_code}' "https://deploy.${domain}/hook/${first_app}" 2>/dev/null || echo 000
91
+ }
92
+ code="$(hook)"
93
+ [ "$code" = 401 ] && pass "an unsigned webhook is refused" || fail "an unsigned webhook to /hook/${first_app}: HTTP ${code}, not 401"
94
+ while IFS= read -r sub; do
95
+ [ -z "$sub" ] || check "${sub}.${domain} answers" "${sub}.${domain}" GET not_5xx
96
+ done < <(bash "${KAYRA_ROOT}/scripts/local/app-manifests.sh" --subdomains "$name")
97
+ fi
98
+
99
+ # ── On the server ───────────────────────────────────────────────────────────
100
+ facts="$(ssh -p "${port:-22}" -i "$key" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR \
101
+ "${user}@${address}" 'set -e
102
+ cd /opt/platform
103
+ echo "registered $(ls apps 2>/dev/null | sed -n "s/\.json$//p" | sort | tr "\n" " ")"
104
+ echo "alerts $(ls alerts 2>/dev/null | tr "\n" " ")"
105
+ docker compose ps -a --format "{{.Service}}|{{.State}}|{{.Health}}" | sed "s/^/platform /"
106
+ for app in $(ls apps 2>/dev/null | sed -n "s/\.json$//p"); do
107
+ docker ps -a --filter "label=com.docker.compose.project=$app" --format "{{.Names}}|{{.State}}|{{.Status}}" | sed "s/^/container $app /"
108
+ last=$(ls runs/$app 2>/dev/null | sort | tail -1)
109
+ [ -z "$last" ] || echo "run $app $(sed -n "s/.*\"state\":\"\([a-z]*\)\".*/\1/p" runs/$app/$last/meta.json) $last"
110
+ done' 2>&1)" || { fail "the server answers over SSH: ${facts}"; exit 1; }
111
+
112
+ wanted="$(sort <<< "$registered_wanted" | tr '\n' ' ')"
113
+ have="$(sed -n 's/^registered //p' <<< "$facts")"
114
+ wanted="$(xargs <<< "$wanted")"
115
+ have="$(xargs <<< "$have")"
116
+ [ "$wanted" = "$have" ] && pass "registered: ${have:-no apps}" \
117
+ || fail "registered: ${have:-nothing}; server.yml says ${wanted:-no apps} (kayra apps ${name})"
118
+
119
+ alerts="$(sed -n 's/^alerts //p' <<< "$facts" | xargs)"
120
+ [ -z "$alerts" ] && pass "no alert is open" || fail "open alerts: ${alerts}"
121
+
122
+ bad_platform="$(sed -n 's/^platform //p' <<< "$facts" | awk -F'|' '$2 != "running" || $3 == "unhealthy" || $3 == "starting" {print $1 " " $2 " " $3}')"
123
+ [ -z "$bad_platform" ] && pass "every platform container runs" || fail "platform containers: $(paste -sd',' - <<< "$bad_platform")"
124
+
125
+ while IFS= read -r app; do
126
+ [ -n "$app" ] || continue
127
+ containers="$(sed -n "s/^container ${app} //p" <<< "$facts")"
128
+ run="$(sed -n "s/^run ${app} //p" <<< "$facts")"
129
+ # One-shot containers (an init, a migration) exit 0 and stay exited.
130
+ bad="$(awk -F'|' '$2 != "running" && $3 !~ /^Exited \(0\)/ || $3 ~ /unhealthy|health: starting/ {print $1 " " $3}' <<< "$containers")"
131
+ # Registered and never run: waiting for its first push, not broken.
132
+ if [ -z "$containers" ] && [ -z "$run" ]; then pass "${app}: not deployed yet"; continue
133
+ elif [ -z "$containers" ]; then fail "${app}: no containers"
134
+ elif [ -n "$bad" ]; then fail "${app}: $(paste -sd',' - <<< "$bad")"
135
+ else pass "${app}: $(grep -c . <<< "$containers") container(s) up"; fi
136
+ case "$run" in
137
+ failure*) fail "${app}: its last run failed ($(cut -d' ' -f2 <<< "$run"): kayra app logs ${name} ${app})" ;;
138
+ "") pass "${app}: no runs yet" ;;
139
+ *) pass "${app}: last run $(cut -d' ' -f1 <<< "$run")" ;;
140
+ esac
141
+ done <<< "$registered_wanted"
142
+
143
+ exit "$failed"