@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.
- package/ansible/roles/platform/defaults/main.yml +7 -2
- package/ansible/roles/platform/tasks/main.yml +29 -1
- package/ansible/roles/platform/templates/platform.env.j2 +2 -0
- package/cli/dist/deployer/builder.js +7 -5
- package/cli/dist/deployer/deployer.js +49 -6
- package/cli/dist/deployer/forge.js +11 -19
- package/cli/dist/deployer/forgejo.js +22 -0
- package/cli/dist/deployer/github-issues.js +71 -0
- package/cli/dist/deployer/github.js +21 -0
- package/cli/dist/deployer/gitlab.js +25 -0
- package/cli/dist/deployer/notify.js +61 -0
- package/cli/dist/deployer/webhook.js +17 -14
- package/cli/dist/settings.js +5 -1
- package/compose/docker-compose.ci.yml +6 -0
- package/package.json +1 -1
- package/schema/defaults.json +32 -2
- package/schema/vars-platform.json +32 -2
- package/scripts/platform/resolve-images.sh +5 -0
|
@@ -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
|
|
43
|
-
# on platform-internal
|
|
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 {
|
|
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 {
|
|
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
|
|
123
|
-
if (
|
|
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
|
|
128
|
-
if (
|
|
129
|
-
log(`(commit status ${context} not posted: ${
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
/**
|
|
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/settings.js
CHANGED
|
@@ -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
|
|
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.
|
|
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",
|
package/schema/defaults.json
CHANGED
|
@@ -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
|
|
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
|
|
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
|