@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.
- 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/cli.js +1 -1
- package/cli/dist/commands/server.js +7 -1
- 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/fleet.js +10 -5
- package/cli/dist/main.js +2 -0
- 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/local/verify.sh +143 -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:
|
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
|
-
|
|
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 {
|
|
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/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
|
-
/**
|
|
43
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
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.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",
|
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,
|
|
@@ -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"
|