@yunuseon/kayra 0.9.0 → 0.10.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:
@@ -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"));
@@ -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.0",
3
+ "version": "0.10.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,
@@ -79,9 +79,14 @@ ensure() {
79
79
  [ -n "$manifest" ] || { echo "ERROR: no artifact $1 under ${root}" >&2; return 1; }
80
80
  read_manifest "$manifest"
81
81
 
82
+ # Every manifest type a push may leave, as registry-gc.sh asks: BuildKit
83
+ # pushes OCI manifests, and registry:2 answers 404 to a request that does
84
+ # not accept the type it holds — every image would look missing.
82
85
  local status
83
86
  status=$(curl -s -o /dev/null -w '%{http_code}' \
84
87
  -H 'Accept: application/vnd.oci.image.index.v1+json' \
88
+ -H 'Accept: application/vnd.oci.image.manifest.v1+json' \
89
+ -H 'Accept: application/vnd.docker.distribution.manifest.list.v2+json' \
85
90
  -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' \
86
91
  "http://${REGISTRY_PUSH_HOST}/v2/${image}/manifests/${tag}" || echo 000)
87
92
  if [ "$status" = "200" ]; then