@yunuseon/kayra 0.0.0-stage → 0.5.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/LICENSE +21 -0
- package/README.md +81 -2
- package/ansible/.ansible-lint +13 -0
- package/ansible/ansible.cfg +12 -0
- package/ansible/playbooks/adopt.yml +35 -0
- package/ansible/playbooks/app.yml +21 -0
- package/ansible/playbooks/apps-prune.yml +30 -0
- package/ansible/playbooks/edge.yml +41 -0
- package/ansible/playbooks/nas.yml +47 -0
- package/ansible/playbooks/platform.yml +62 -0
- package/ansible/requirements.yml +7 -0
- package/ansible/roles/app/defaults/main.yml +9 -0
- package/ansible/roles/app/tasks/main.yml +107 -0
- package/ansible/roles/app/templates/app.env.j2 +9 -0
- package/ansible/roles/app/templates/casc-app.yaml.j2 +68 -0
- package/ansible/roles/backup-check/files/backup-check.service +9 -0
- package/ansible/roles/backup-check/files/backup-check.timer +10 -0
- package/ansible/roles/backup-check/tasks/main.yml +59 -0
- package/ansible/roles/backup-check/templates/backup-check.sh.j2 +117 -0
- package/ansible/roles/common/handlers/main.yml +16 -0
- package/ansible/roles/common/tasks/main.yml +100 -0
- package/ansible/roles/compose-stacks/tasks/main.yml +102 -0
- package/ansible/roles/docker/defaults/main.yml +3 -0
- package/ansible/roles/docker/files/buildkit-rootless +23 -0
- package/ansible/roles/docker/handlers/main.yml +12 -0
- package/ansible/roles/docker/tasks/main.yml +82 -0
- package/ansible/roles/edge-proxy/files/docker-compose.yml +13 -0
- package/ansible/roles/edge-proxy/handlers/main.yml +6 -0
- package/ansible/roles/edge-proxy/tasks/main.yml +34 -0
- package/ansible/roles/edge-proxy/templates/Caddyfile.j2 +25 -0
- package/ansible/roles/forgejo-config/tasks/main.yml +333 -0
- package/ansible/roles/platform/defaults/main.yml +76 -0
- package/ansible/roles/platform/tasks/main.yml +298 -0
- package/ansible/roles/platform/templates/authelia-users.yml.j2 +17 -0
- package/ansible/roles/platform/templates/platform.env.j2 +41 -0
- package/ansible/roles/restic-backup/files/docker-compose.postgres.yml +27 -0
- package/ansible/roles/restic-backup/files/docker-compose.yml +12 -0
- package/ansible/roles/restic-backup/handlers/main.yml +7 -0
- package/ansible/roles/restic-backup/tasks/main.yml +121 -0
- package/ansible/roles/restic-backup/templates/backup.env.j2 +9 -0
- package/ansible/roles/restic-backup/templates/backup.sh.j2 +28 -0
- package/ansible/roles/restic-backup/templates/docker-compose.override.yml.j2 +26 -0
- package/ansible/roles/restic-backup/templates/follow-restore-test.sh.j2 +28 -0
- package/ansible/roles/restic-backup/templates/restore-test.sh.j2 +51 -0
- package/ansible/roles/restic-backup/templates/root.crontab.j2 +6 -0
- package/ansible/roles/restic-backup/templates/ssh_config.j2 +12 -0
- package/ansible/roles/wireguard/handlers/main.yml +9 -0
- package/ansible/roles/wireguard/tasks/main.yml +33 -0
- package/ansible/roles/wireguard/templates/wg0.conf.j2 +12 -0
- package/bin/kayra.js +29 -0
- package/cli/dist/brand.js +41 -0
- package/cli/dist/cli.js +21 -0
- package/cli/dist/commands/doctor.js +79 -0
- package/cli/dist/commands/server.js +215 -0
- package/cli/dist/commands/status.js +43 -0
- package/cli/dist/fleet.js +134 -0
- package/cli/dist/interpret.js +98 -0
- package/cli/dist/load.js +49 -0
- package/cli/dist/main.js +94 -0
- package/cli/dist/plan.js +31 -0
- package/cli/dist/result.js +5 -0
- package/cloud-init.yml.tpl +14 -0
- package/compose/authelia/configuration.yml +138 -0
- package/compose/buildkit/buildkitd.toml +14 -0
- package/compose/docker-compose.acme-dns.yml +12 -0
- package/compose/docker-compose.ci.yml +156 -0
- package/compose/docker-compose.yml +83 -0
- package/compose/jenkins/Dockerfile +40 -0
- package/compose/jenkins/casc/00-platform.yaml +158 -0
- package/compose/jenkins/plugins.txt +97 -0
- package/compose/jenkins-agent/Dockerfile +24 -0
- package/compose/traefik/dynamic/authelia.yml +18 -0
- package/compose/traefik/dynamic/middlewares.yml +14 -0
- package/compose/traefik/dynamic/ratelimit.yml +21 -0
- package/compose/traefik/dynamic/tls.yml +9 -0
- package/compose/traefik/traefik.yml +46 -0
- package/hosts.conf +6 -0
- package/package.json +73 -4
- package/scripts/local/app-manifests.sh +26 -0
- package/scripts/local/authelia-dev-init.sh +58 -0
- package/scripts/local/certs.sh +55 -0
- package/scripts/local/home-image.sh +55 -0
- package/scripts/local/home-reach.sh +41 -0
- package/scripts/local/require-main.sh +38 -0
- package/scripts/local/setup-hosts.sh +30 -0
- package/scripts/server/app-purge +26 -0
- package/scripts/server/app-remove +30 -0
- package/scripts/server/jenkins-api +28 -0
- package/scripts/server/jenkins-app-apply +36 -0
- package/scripts/server/jenkins-reload +12 -0
- package/terraform/modules/hcloud-firewall/main.tf +54 -0
- package/terraform/modules/hcloud-firewall/outputs.tf +9 -0
- package/terraform/modules/hcloud-firewall/variables.tf +22 -0
- package/terraform/modules/hcloud-server/main.tf +62 -0
- package/terraform/modules/hcloud-server/outputs.tf +19 -0
- package/terraform/modules/hcloud-server/templates/inventory.ini.tpl +5 -0
- package/terraform/modules/hcloud-server/variables.tf +50 -0
- package/terraform/modules/multipass-instance/main.tf +67 -0
- package/terraform/modules/multipass-instance/outputs.tf +19 -0
- package/terraform/modules/multipass-instance/templates/inventory.ini.tpl +5 -0
- package/terraform/modules/multipass-instance/variables.tf +39 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { text } from "node:stream/consumers";
|
|
3
|
+
const spawned = async (argv, streams, env = {}) => {
|
|
4
|
+
const [program, ...args] = argv;
|
|
5
|
+
const child = spawn(program, args, {
|
|
6
|
+
env: { ...process.env, ...env },
|
|
7
|
+
stdio: [
|
|
8
|
+
"inherit",
|
|
9
|
+
streams.stdout === "show" ? "inherit" : streams.stdout === "capture" ? "pipe" : "ignore",
|
|
10
|
+
streams.stderr === "show" ? "inherit" : "pipe",
|
|
11
|
+
],
|
|
12
|
+
});
|
|
13
|
+
const stdout = child.stdout === null ? Promise.resolve("") : text(child.stdout);
|
|
14
|
+
const stderr = child.stderr === null ? Promise.resolve("") : text(child.stderr);
|
|
15
|
+
const exit = await new Promise((resolve) => {
|
|
16
|
+
child.on("error", resolve);
|
|
17
|
+
child.on("close", (code) => {
|
|
18
|
+
resolve(code ?? 1);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
return exit instanceof Error
|
|
22
|
+
? { code: 127, stdout: "", stderr: exit.message }
|
|
23
|
+
: { code: exit, stdout: await stdout, stderr: await stderr };
|
|
24
|
+
};
|
|
25
|
+
/** A command's stdout, for kayra to read; its stderr reaches the operator unless captured too. */
|
|
26
|
+
export const capture = (argv, stderr = "show", env = {}) => spawned(argv, { stdout: "capture", stderr }, env);
|
|
27
|
+
const answer = (probe) => spawned(probe.kind === "on-path" ? ["sh", "-c", 'command -v "$1"', "sh", probe.program] : probe.argv, { stdout: "discard", stderr: "capture" }, probe.kind === "exits-zero" ? probe.env : {});
|
|
28
|
+
/** Each query's answer in place of it, as `$(…)` would: one word, or split into many. Undefined if one failed. */
|
|
29
|
+
const resolve = async (argv, io) => {
|
|
30
|
+
const words = await Promise.all(argv.map(async (value) => {
|
|
31
|
+
if (typeof value === "string")
|
|
32
|
+
return [value];
|
|
33
|
+
const exit = await capture(value.argv, "show", value.env);
|
|
34
|
+
if (exit.code !== 0) {
|
|
35
|
+
io.err(`kayra: ${value.argv.join(" ")} failed (exit ${String(exit.code)})`);
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
const output = exit.stdout.replace(/\n+$/, "");
|
|
39
|
+
return value.words === "one" ? [output] : output.split(/\s+/).filter((word) => word !== "");
|
|
40
|
+
}));
|
|
41
|
+
const [program, ...rest] = words.flatMap((word) => word ?? []);
|
|
42
|
+
return words.includes(undefined) || program === undefined ? undefined : [program, ...rest];
|
|
43
|
+
};
|
|
44
|
+
const lines = (text) => text
|
|
45
|
+
.trimEnd()
|
|
46
|
+
.split("\n")
|
|
47
|
+
.filter((line) => line !== "");
|
|
48
|
+
const goOn = { failed: false, stopped: false };
|
|
49
|
+
const check = async (step, io) => {
|
|
50
|
+
const exit = await answer(step.probe);
|
|
51
|
+
const hint = step.hint === undefined ? [] : [` → ${step.hint}`];
|
|
52
|
+
if (step.onFailure === "stop") {
|
|
53
|
+
if (exit.code === 0)
|
|
54
|
+
return goOn;
|
|
55
|
+
[...lines(exit.stderr), ...hint].forEach(io.err);
|
|
56
|
+
return { failed: true, stopped: true };
|
|
57
|
+
}
|
|
58
|
+
if (exit.code === 0) {
|
|
59
|
+
io.out(`ok ${step.label}`);
|
|
60
|
+
return goOn;
|
|
61
|
+
}
|
|
62
|
+
[`FAIL ${step.label}`, ...lines(exit.stderr).map((line) => ` ${line}`), ...hint].forEach(io.out);
|
|
63
|
+
return { failed: true, stopped: false };
|
|
64
|
+
};
|
|
65
|
+
const perform = async (step, io) => {
|
|
66
|
+
switch (step.kind) {
|
|
67
|
+
case "run": {
|
|
68
|
+
const argv = await resolve(step.argv, io);
|
|
69
|
+
if (argv === undefined)
|
|
70
|
+
return { failed: true, stopped: true };
|
|
71
|
+
const exit = await spawned(argv, { stdout: step.stdout, stderr: "show" }, step.env);
|
|
72
|
+
return exit.code === 0 ? goOn : { failed: true, stopped: true };
|
|
73
|
+
}
|
|
74
|
+
case "check":
|
|
75
|
+
return check(step, io);
|
|
76
|
+
case "confirm": {
|
|
77
|
+
const typed = await io.ask(step.prompt);
|
|
78
|
+
if (typed === step.expect)
|
|
79
|
+
return goOn;
|
|
80
|
+
io.out("Aborted.");
|
|
81
|
+
return { failed: false, stopped: true };
|
|
82
|
+
}
|
|
83
|
+
case "note":
|
|
84
|
+
io.out(step.text);
|
|
85
|
+
return goOn;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
/** Runs a plan step by step, until it ends or a step stops it. True if nothing failed. */
|
|
89
|
+
export const interpret = async (plan, io) => {
|
|
90
|
+
const outcome = await plan.reduce(async (previous, step) => {
|
|
91
|
+
const so = await previous;
|
|
92
|
+
if (so.stopped)
|
|
93
|
+
return so;
|
|
94
|
+
const next = await perform(step, io);
|
|
95
|
+
return { failures: so.failures + (next.failed ? 1 : 0), stopped: next.stopped };
|
|
96
|
+
}, Promise.resolve({ failures: 0, stopped: false }));
|
|
97
|
+
return outcome.failures === 0;
|
|
98
|
+
};
|
package/cli/dist/load.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { fleetRoot, serverDir, serverName, within } from "./brand.js";
|
|
4
|
+
import { parseServer } from "./fleet.js";
|
|
5
|
+
import { all, err, map, ok } from "./result.js";
|
|
6
|
+
/** The fleet is the nearest directory, from `from` upwards, with a servers/defaults.yml. */
|
|
7
|
+
export const findFleetRoot = (from) => {
|
|
8
|
+
if (existsSync(join(from, "servers", "defaults.yml")))
|
|
9
|
+
return ok(fleetRoot(from));
|
|
10
|
+
const parent = within(from, "..");
|
|
11
|
+
return parent === from
|
|
12
|
+
? err("no fleet here: no servers/defaults.yml in this directory or above it")
|
|
13
|
+
: findFleetRoot(parent);
|
|
14
|
+
};
|
|
15
|
+
const text = (path) => (existsSync(path) ? readFileSync(path, "utf8") : undefined);
|
|
16
|
+
const stacks = (dir) => existsSync(join(dir, "stacks"))
|
|
17
|
+
? readdirSync(join(dir, "stacks"))
|
|
18
|
+
.toSorted()
|
|
19
|
+
.flatMap((stack) => text(join(dir, "stacks", stack, "compose.yaml")) ?? [])
|
|
20
|
+
: [];
|
|
21
|
+
const appValues = (dir) => existsSync(join(dir, "apps"))
|
|
22
|
+
? readdirSync(join(dir, "apps"))
|
|
23
|
+
.filter((file) => file.endsWith(".yml"))
|
|
24
|
+
.toSorted()
|
|
25
|
+
.map((file) => ({ file, text: readFileSync(join(dir, "apps", file), "utf8") }))
|
|
26
|
+
: [];
|
|
27
|
+
const readServer = (root, entry) => {
|
|
28
|
+
const name = serverName(entry);
|
|
29
|
+
if (!name.ok)
|
|
30
|
+
return err(`servers/${entry}: ${name.error}`);
|
|
31
|
+
const dir = serverDir(root, name.value);
|
|
32
|
+
const server = parseServer({
|
|
33
|
+
name: name.value,
|
|
34
|
+
dir,
|
|
35
|
+
descriptor: readFileSync(join(dir, "server.yml"), "utf8"),
|
|
36
|
+
vars: text(join(dir, "vars.yml")),
|
|
37
|
+
secrets: text(join(dir, "secrets.yml")),
|
|
38
|
+
inventory: text(join(dir, "inventory.ini")),
|
|
39
|
+
terraform: existsSync(join(dir, "terraform")),
|
|
40
|
+
stacks: stacks(dir),
|
|
41
|
+
appValues: appValues(dir),
|
|
42
|
+
});
|
|
43
|
+
return server.ok ? server : err(`servers/${entry}/server.yml: ${server.error}`);
|
|
44
|
+
};
|
|
45
|
+
export const loadFleet = (root) => map(all(readdirSync(join(root, "servers"), { withFileTypes: true })
|
|
46
|
+
.filter((entry) => entry.isDirectory() && existsSync(join(root, "servers", entry.name, "server.yml")))
|
|
47
|
+
.map((entry) => entry.name)
|
|
48
|
+
.toSorted()
|
|
49
|
+
.map((entry) => readServer(root, entry))), (servers) => ({ root, servers }));
|
package/cli/dist/main.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { run } from "@optique/run";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import { absolutePath, kayraRoot, kayraVersion, within } from "./brand.js";
|
|
6
|
+
import { parser } from "./cli.js";
|
|
7
|
+
import { doctor } from "./commands/doctor.js";
|
|
8
|
+
import * as server from "./commands/server.js";
|
|
9
|
+
import { status } from "./commands/status.js";
|
|
10
|
+
import { serverNamed } from "./fleet.js";
|
|
11
|
+
import { interpret } from "./interpret.js";
|
|
12
|
+
import { findFleetRoot, loadFleet } from "./load.js";
|
|
13
|
+
import { render } from "./plan.js";
|
|
14
|
+
import { flatMap, ok } from "./result.js";
|
|
15
|
+
const here = absolutePath(process.cwd());
|
|
16
|
+
const engine = absolutePath(import.meta.dirname);
|
|
17
|
+
if (!here.ok || !engine.ok)
|
|
18
|
+
throw new Error("node reported a relative path");
|
|
19
|
+
const kayra = kayraRoot(within(engine.value, "..", ".."));
|
|
20
|
+
const manifest = JSON.parse(readFileSync(within(kayra, "package.json"), "utf8"));
|
|
21
|
+
const version = kayraVersion(typeof manifest === "object" && manifest !== null && "version" in manifest ? manifest.version : undefined);
|
|
22
|
+
if (!version.ok)
|
|
23
|
+
throw new Error(`kayra's package.json: ${version.error}`);
|
|
24
|
+
const onServer = (on, target, command) => {
|
|
25
|
+
switch (command.name) {
|
|
26
|
+
case "up":
|
|
27
|
+
return server.up(on, target);
|
|
28
|
+
case "provision":
|
|
29
|
+
return server.provision(on, target);
|
|
30
|
+
case "deploy":
|
|
31
|
+
return server.deploy(on, target);
|
|
32
|
+
case "setup":
|
|
33
|
+
return server.setup(on, target);
|
|
34
|
+
case "apps":
|
|
35
|
+
return server.apps(on, target);
|
|
36
|
+
case "hosts":
|
|
37
|
+
return server.hosts(on, target);
|
|
38
|
+
case "ssh":
|
|
39
|
+
return server.ssh(target);
|
|
40
|
+
case "down":
|
|
41
|
+
return server.down(target);
|
|
42
|
+
case "adopt":
|
|
43
|
+
return server.adopt(on, target);
|
|
44
|
+
case "backup test":
|
|
45
|
+
return server.backupTest(on, target);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
const plan = (fleet, command) => {
|
|
49
|
+
const on = { kayra, version: version.value, fleet: fleet.root };
|
|
50
|
+
if (command.name === "doctor")
|
|
51
|
+
return ok(doctor(fleet, kayra));
|
|
52
|
+
if (command.name === "certs")
|
|
53
|
+
return server.certsFor(on, command.domain, command.subdomains);
|
|
54
|
+
if (command.name === "app purge")
|
|
55
|
+
return flatMap(serverNamed(fleet, command.server), (target) => server.appPurge(on, target, command.app));
|
|
56
|
+
return flatMap(serverNamed(fleet, command.server), (target) => onServer(on, target, command));
|
|
57
|
+
};
|
|
58
|
+
const io = {
|
|
59
|
+
out: (line) => {
|
|
60
|
+
console.log(line);
|
|
61
|
+
},
|
|
62
|
+
err: (line) => {
|
|
63
|
+
console.error(line);
|
|
64
|
+
},
|
|
65
|
+
ask: async (prompt) => {
|
|
66
|
+
const terminal = createInterface({ input: process.stdin, output: process.stderr });
|
|
67
|
+
const answer = await terminal.question(prompt);
|
|
68
|
+
terminal.close();
|
|
69
|
+
return answer;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
const fail = (message) => {
|
|
73
|
+
console.error(`kayra: ${message}`);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
};
|
|
76
|
+
const fleet = flatMap(findFleetRoot(here.value), loadFleet);
|
|
77
|
+
if (!fleet.ok)
|
|
78
|
+
fail(fleet.error);
|
|
79
|
+
else {
|
|
80
|
+
const args = run(parser(fleet.value), { programName: "kayra", help: "both", completion: "both" });
|
|
81
|
+
if (args.command.name === "status") {
|
|
82
|
+
(await status(fleet.value)).forEach(io.out);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
const steps = plan(fleet.value, args.command);
|
|
86
|
+
if (!steps.ok)
|
|
87
|
+
fail(steps.error);
|
|
88
|
+
else if (args.dryRun)
|
|
89
|
+
render(steps.value).forEach(io.out);
|
|
90
|
+
// eslint-disable-next-line functional/immutable-data -- the exit status; process.exit() could cut piped output short
|
|
91
|
+
else
|
|
92
|
+
process.exitCode = (await interpret(steps.value, io)) ? 0 : 1;
|
|
93
|
+
}
|
|
94
|
+
}
|
package/cli/dist/plan.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export const run = (argv, env = {}) => ({
|
|
2
|
+
kind: "run",
|
|
3
|
+
argv,
|
|
4
|
+
env,
|
|
5
|
+
stdout: "show",
|
|
6
|
+
});
|
|
7
|
+
const quote = (word) => (/^[\w@%+=:,./~-]+$/.test(word) ? word : `'${word.replaceAll("'", `'\\''`)}'`);
|
|
8
|
+
export const shell = (argv) => argv.map(quote).join(" ");
|
|
9
|
+
const assignments = (env = {}) => Object.entries(env)
|
|
10
|
+
.map(([key, value]) => `${key}=${quote(value)} `)
|
|
11
|
+
.join("");
|
|
12
|
+
const arg = (value) => {
|
|
13
|
+
if (typeof value === "string")
|
|
14
|
+
return quote(value);
|
|
15
|
+
const command = `$(${assignments(value.env)}${shell(value.argv)})`;
|
|
16
|
+
return value.words === "one" ? `"${command}"` : command;
|
|
17
|
+
};
|
|
18
|
+
const describeProbe = (probe) => probe.kind === "on-path" ? `command -v ${quote(probe.program)}` : assignments(probe.env) + shell(probe.argv);
|
|
19
|
+
const describe = (step) => {
|
|
20
|
+
switch (step.kind) {
|
|
21
|
+
case "run":
|
|
22
|
+
return `run ${assignments(step.env)}${step.argv.map(arg).join(" ")}${step.stdout === "discard" ? " >/dev/null" : ""}`;
|
|
23
|
+
case "check":
|
|
24
|
+
return `${step.onFailure === "stop" ? "guard " : "check "} ${step.label}: ${describeProbe(step.probe)}`;
|
|
25
|
+
case "confirm":
|
|
26
|
+
return `confirm ${step.prompt}(${step.expect})`;
|
|
27
|
+
case "note":
|
|
28
|
+
return `note ${step.text}`;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
export const render = (plan) => plan.map(describe);
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export const ok = (value) => ({ ok: true, value });
|
|
2
|
+
export const err = (error) => ({ ok: false, error });
|
|
3
|
+
export const map = (result, f) => result.ok ? ok(f(result.value)) : result;
|
|
4
|
+
export const flatMap = (result, f) => (result.ok ? f(result.value) : result);
|
|
5
|
+
export const all = (results) => results.reduce((acc, result) => flatMap(acc, (values) => map(result, (value) => [...values, value])), ok([]));
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#cloud-config
|
|
2
|
+
# Only what Ansible needs to get in. Everything else (packages, Docker, swap,
|
|
3
|
+
# SSH hardening) is the common and docker roles' job: they run on every
|
|
4
|
+
# deploy, while this runs once on first boot and can never be re-applied.
|
|
5
|
+
|
|
6
|
+
hostname: ${hostname}
|
|
7
|
+
|
|
8
|
+
users:
|
|
9
|
+
- name: deploy
|
|
10
|
+
groups: sudo
|
|
11
|
+
shell: /bin/bash
|
|
12
|
+
sudo: ALL=(ALL) NOPASSWD:ALL
|
|
13
|
+
ssh_authorized_keys:
|
|
14
|
+
- ${ssh_pubkey}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# =============================================================================
|
|
2
|
+
# Authelia — login for the platform's own pages (Jenkins, Traefik's
|
|
3
|
+
# dashboard). Apps bring their own login; their users never live here.
|
|
4
|
+
#
|
|
5
|
+
# Authelia does NOT expand ${VAR} the way docker compose does. Interpolation here
|
|
6
|
+
# comes from the `template` config filter (X_AUTHELIA_CONFIG_FILTERS=template),
|
|
7
|
+
# which is Go templating: {{ env "DOMAIN" }}.
|
|
8
|
+
#
|
|
9
|
+
# Secrets are never in this file — they arrive as AUTHELIA_*_FILE env vars.
|
|
10
|
+
# =============================================================================
|
|
11
|
+
|
|
12
|
+
theme: 'auto'
|
|
13
|
+
|
|
14
|
+
server:
|
|
15
|
+
address: 'tcp://0.0.0.0:9091/'
|
|
16
|
+
|
|
17
|
+
log:
|
|
18
|
+
level: 'info'
|
|
19
|
+
format: 'text'
|
|
20
|
+
|
|
21
|
+
# Passkeys are the point of this setup; TOTP and Duo would only be extra
|
|
22
|
+
# surface to configure and explain.
|
|
23
|
+
totp:
|
|
24
|
+
disable: true
|
|
25
|
+
duo_api:
|
|
26
|
+
disable: true
|
|
27
|
+
|
|
28
|
+
webauthn:
|
|
29
|
+
disable: false
|
|
30
|
+
enable_passkey_login: true
|
|
31
|
+
# A passkey assertion carrying the user-verification flag satisfies the
|
|
32
|
+
# two_factor policy on its own, so login is one Touch ID prompt rather than
|
|
33
|
+
# password-then-passkey. A stolen bootstrap password alone reaches nothing.
|
|
34
|
+
experimental_enable_passkey_uv_two_factors: true
|
|
35
|
+
display_name: '{{ env "DOMAIN" }}'
|
|
36
|
+
attestation_conveyance_preference: 'indirect'
|
|
37
|
+
timeout: '60 seconds'
|
|
38
|
+
selection_criteria:
|
|
39
|
+
attachment: ''
|
|
40
|
+
discoverability: 'required'
|
|
41
|
+
# 'required', not the default 'preferred': with 'preferred' an authenticator
|
|
42
|
+
# may omit the user-verification flag, and the user is then stuck unable to
|
|
43
|
+
# satisfy a two_factor policy.
|
|
44
|
+
user_verification: 'required'
|
|
45
|
+
|
|
46
|
+
identity_validation:
|
|
47
|
+
elevated_session:
|
|
48
|
+
code_lifespan: '5 minutes'
|
|
49
|
+
elevation_lifespan: '10 minutes'
|
|
50
|
+
characters: 8
|
|
51
|
+
require_second_factor: false
|
|
52
|
+
# Once someone has one passkey, registering a second skips the one-time
|
|
53
|
+
# code entirely.
|
|
54
|
+
skip_second_factor: true
|
|
55
|
+
|
|
56
|
+
authentication_backend:
|
|
57
|
+
refresh_interval: 'always'
|
|
58
|
+
# Both disabled so Authelia never writes back to users_database.yml. That
|
|
59
|
+
# file is rendered from vault on every platform run and would otherwise be
|
|
60
|
+
# silently reverted.
|
|
61
|
+
password_reset:
|
|
62
|
+
disable: true
|
|
63
|
+
password_change:
|
|
64
|
+
disable: true
|
|
65
|
+
file:
|
|
66
|
+
path: '/config/users_database.yml'
|
|
67
|
+
watch: true
|
|
68
|
+
search:
|
|
69
|
+
email: true
|
|
70
|
+
case_insensitive: true
|
|
71
|
+
password:
|
|
72
|
+
algorithm: 'argon2'
|
|
73
|
+
argon2:
|
|
74
|
+
variant: 'argon2id'
|
|
75
|
+
iterations: 3
|
|
76
|
+
# KiB, allocated per concurrent hash. The default 64 MiB is too fat for
|
|
77
|
+
# a 4 GB box with no headroom; 32 MiB is still strong for a credential
|
|
78
|
+
# used once per person, at enrolment.
|
|
79
|
+
memory: 32768
|
|
80
|
+
parallelism: 2
|
|
81
|
+
key_length: 32
|
|
82
|
+
salt_length: 16
|
|
83
|
+
|
|
84
|
+
# AUTH_POLICY is always set by the platform role (default two_factor); test
|
|
85
|
+
# lowers it to one_factor so staging does not need passkey enrolment.
|
|
86
|
+
access_control:
|
|
87
|
+
default_policy: 'deny'
|
|
88
|
+
rules:
|
|
89
|
+
- domain: 'jenkins.{{ env "DOMAIN" }}'
|
|
90
|
+
policy: '{{ env "AUTH_POLICY" }}'
|
|
91
|
+
subject:
|
|
92
|
+
- 'group:admin'
|
|
93
|
+
- domain: 'traefik.{{ env "DOMAIN" }}'
|
|
94
|
+
policy: '{{ env "AUTH_POLICY" }}'
|
|
95
|
+
subject:
|
|
96
|
+
- 'group:admin'
|
|
97
|
+
|
|
98
|
+
session:
|
|
99
|
+
name: 'authelia_session'
|
|
100
|
+
same_site: 'lax'
|
|
101
|
+
# A working day of idle time. Shorter is tempting, but a session that dies
|
|
102
|
+
# mid-form turns the next POST into a 302 to the portal and the body is lost.
|
|
103
|
+
inactivity: '8 hours'
|
|
104
|
+
expiration: '1 day'
|
|
105
|
+
remember_me: '1 week'
|
|
106
|
+
cookies:
|
|
107
|
+
# The apex, not auth.<DOMAIN> — the cookie has to reach jenkins.<DOMAIN>
|
|
108
|
+
# and traefik.<DOMAIN> too. Authelia requires this to be a suffix of (or equal to) authelia_url.
|
|
109
|
+
- domain: '{{ env "DOMAIN" }}'
|
|
110
|
+
authelia_url: 'https://auth.{{ env "DOMAIN" }}'
|
|
111
|
+
|
|
112
|
+
regulation:
|
|
113
|
+
max_retries: 3
|
|
114
|
+
find_time: '2 minutes'
|
|
115
|
+
ban_time: '5 minutes'
|
|
116
|
+
|
|
117
|
+
storage:
|
|
118
|
+
local:
|
|
119
|
+
path: '/data/db.sqlite3'
|
|
120
|
+
|
|
121
|
+
# SMTP when it is configured, otherwise write to a file. The notifier is only
|
|
122
|
+
# used for the one-time code at passkey enrolment, so the file fallback keeps
|
|
123
|
+
# dev working without an external dependency.
|
|
124
|
+
notifier:
|
|
125
|
+
disable_startup_check: false
|
|
126
|
+
{{- if env "SMTP_ADDRESS" }}
|
|
127
|
+
smtp:
|
|
128
|
+
address: '{{ env "SMTP_ADDRESS" }}'
|
|
129
|
+
username: '{{ env "SMTP_USERNAME" }}'
|
|
130
|
+
sender: '{{ env "SMTP_SENDER" }}'
|
|
131
|
+
# In the template rather than an env var on purpose: an empty
|
|
132
|
+
# AUTHELIA_NOTIFIER_SMTP_PASSWORD still creates notifier.smtp, and Authelia
|
|
133
|
+
# then refuses to start with both notifiers configured.
|
|
134
|
+
password: {{ secret "/config/secrets/smtp_password" | msquote }}
|
|
135
|
+
{{- else }}
|
|
136
|
+
filesystem:
|
|
137
|
+
filename: '/data/notification.txt'
|
|
138
|
+
{{- end }}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# BuildKit daemon config.
|
|
2
|
+
#
|
|
3
|
+
# The registry is plain HTTP on the internal docker network and has no TLS
|
|
4
|
+
# anywhere. BuildKit has to be told that explicitly or it tries HTTPS.
|
|
5
|
+
|
|
6
|
+
[worker.oci]
|
|
7
|
+
enabled = true
|
|
8
|
+
# Rootless: no process sandbox, and the rootless worker cannot use overlayfs
|
|
9
|
+
# on every kernel, so let BuildKit pick a snapshotter it can actually run.
|
|
10
|
+
max-parallelism = 2
|
|
11
|
+
|
|
12
|
+
[registry."registry:5000"]
|
|
13
|
+
http = true
|
|
14
|
+
insecure = true
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# =============================================================================
|
|
2
|
+
# Overlay for a server whose certificates come by DNS-01 (acme_dns in its
|
|
3
|
+
# vars.yml): lego, inside Traefik, reads the zone's token from a file. Added to
|
|
4
|
+
# COMPOSE_FILE only there, so other servers' Traefik stays as it is.
|
|
5
|
+
# =============================================================================
|
|
6
|
+
|
|
7
|
+
services:
|
|
8
|
+
traefik:
|
|
9
|
+
environment:
|
|
10
|
+
HETZNER_API_TOKEN_FILE: /run/secrets/acme-dns-token
|
|
11
|
+
volumes:
|
|
12
|
+
- ./traefik/secrets:/run/secrets:ro
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# =============================================================================
|
|
2
|
+
# Platform CI overlay — Docker Registry + Jenkins (controller + agent)
|
|
3
|
+
# App-agnostic: provides CI/CD infrastructure for any application.
|
|
4
|
+
# Usage: docker compose -f docker-compose.yml -f docker-compose.ci.yml up
|
|
5
|
+
# =============================================================================
|
|
6
|
+
|
|
7
|
+
services:
|
|
8
|
+
registry:
|
|
9
|
+
image: registry:2.8.3
|
|
10
|
+
restart: unless-stopped
|
|
11
|
+
environment:
|
|
12
|
+
# registry-gc.sh deletes manifests nothing references any more.
|
|
13
|
+
- REGISTRY_STORAGE_DELETE_ENABLED=true
|
|
14
|
+
# No Traefik router: builds push to registry:5000 and the host daemon pulls
|
|
15
|
+
# from localhost:5000, so nothing outside the server needs it.
|
|
16
|
+
ports:
|
|
17
|
+
- "127.0.0.1:5000:5000"
|
|
18
|
+
volumes:
|
|
19
|
+
- registry-data:/var/lib/registry
|
|
20
|
+
networks:
|
|
21
|
+
- internal
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# BuildKit — builds images without anyone holding the host's docker socket.
|
|
25
|
+
#
|
|
26
|
+
# The build agent used to mount /var/run/docker.sock read-write while running
|
|
27
|
+
# pipeline code checked out from arbitrary branches, which is host root by
|
|
28
|
+
# design rather than by exploit. Builds now go here instead: rootless, its own
|
|
29
|
+
# daemon, pushing straight to the registry. Compromising it gets you a
|
|
30
|
+
# container, not the box.
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
buildkitd:
|
|
33
|
+
image: moby/buildkit:v0.34.0-rootless
|
|
34
|
+
restart: unless-stopped
|
|
35
|
+
# Flags go in the command: this image does not read BUILDKITD_FLAGS (that
|
|
36
|
+
# is a buildx convention), and without them it listens on a unix socket
|
|
37
|
+
# the agent cannot reach.
|
|
38
|
+
command:
|
|
39
|
+
- --oci-worker-no-process-sandbox
|
|
40
|
+
- --addr
|
|
41
|
+
- tcp://0.0.0.0:1234
|
|
42
|
+
# rootlesskit needs an unprivileged user namespace, which Ubuntu 23.10+
|
|
43
|
+
# denies to unconfined processes. The named profile (installed by the docker
|
|
44
|
+
# role) grants userns to this container alone, so the host keeps the
|
|
45
|
+
# restriction.
|
|
46
|
+
security_opt:
|
|
47
|
+
- seccomp=unconfined
|
|
48
|
+
- apparmor=buildkit-rootless
|
|
49
|
+
volumes:
|
|
50
|
+
- buildkit-cache:/home/user/.local/share/buildkit
|
|
51
|
+
- ./buildkit/buildkitd.toml:/home/user/.config/buildkit/buildkitd.toml:ro
|
|
52
|
+
mem_limit: 1g
|
|
53
|
+
networks:
|
|
54
|
+
- internal
|
|
55
|
+
|
|
56
|
+
jenkins-controller:
|
|
57
|
+
build:
|
|
58
|
+
context: ./jenkins
|
|
59
|
+
dockerfile: Dockerfile
|
|
60
|
+
# The controller runs as jenkins (uid/gid 1000) and reaches the socket via
|
|
61
|
+
# the host's docker group, whose GID differs per host.
|
|
62
|
+
group_add:
|
|
63
|
+
- ${DOCKER_GID:?}
|
|
64
|
+
restart: unless-stopped
|
|
65
|
+
# No COMPOSE_PROJECT_NAME here on purpose: every app's deploy runs
|
|
66
|
+
# `docker compose` from this container, and an exported project name would
|
|
67
|
+
# beat the one the deploy passes. APPS_DIR is where app .env files live.
|
|
68
|
+
environment:
|
|
69
|
+
- JENKINS_ADMIN_USER=${JENKINS_ADMIN_USER:-admin}
|
|
70
|
+
- JENKINS_ADMIN_PASSWORD=${JENKINS_ADMIN_PASSWORD:-admin}
|
|
71
|
+
- JENKINS_GIT_REPO=${GIT_REMOTE_URL:?}
|
|
72
|
+
- GITHUB_WEBHOOK_SECRET=${GITHUB_WEBHOOK_SECRET:-}
|
|
73
|
+
- DOMAIN=${DOMAIN:-localhost}
|
|
74
|
+
- APPS_DIR=${APPS_DIR:?}
|
|
75
|
+
- PLATFORM_REF=${PLATFORM_REF:?}
|
|
76
|
+
- PLATFORM_RENOVATE=${PLATFORM_RENOVATE:-false}
|
|
77
|
+
- RENOVATE_SCHEDULE=${RENOVATE_SCHEDULE:-H 4 * * 1}
|
|
78
|
+
- RENOVATE_PLATFORM=${RENOVATE_PLATFORM:-github}
|
|
79
|
+
- RENOVATE_ENDPOINT=${RENOVATE_ENDPOINT:-}
|
|
80
|
+
- RENOVATE_GIT_URL=${RENOVATE_GIT_URL:-}
|
|
81
|
+
- RENOVATE_GITHUB_COM_TOKEN=${RENOVATE_GITHUB_COM_TOKEN:-}
|
|
82
|
+
- RENOVATE_HOST_RULES=${RENOVATE_HOST_RULES:-}
|
|
83
|
+
- RENOVATE_REPOSITORIES=${RENOVATE_REPOSITORIES:-}
|
|
84
|
+
# Tell JCasC to merge every YAML in this directory at startup.
|
|
85
|
+
- CASC_JENKINS_CONFIG=/var/jenkins_home/casc
|
|
86
|
+
volumes:
|
|
87
|
+
- jenkins-home:/var/jenkins_home
|
|
88
|
+
# The controller is the only node with the docker socket, and it runs
|
|
89
|
+
# the deploy stage, so it needs the app's compose directory too.
|
|
90
|
+
- /var/run/docker.sock:/var/run/docker.sock
|
|
91
|
+
- ${APPS_DIR:?}:${APPS_DIR:?}:ro
|
|
92
|
+
- ./jenkins/casc:/var/jenkins_home/casc:ro
|
|
93
|
+
# One directory per app, one file per secret; JCasC reads them with
|
|
94
|
+
# ${readFile:...}. Written by bin/jenkins-app-apply.
|
|
95
|
+
- ./jenkins-secrets:/run/app-secrets:ro
|
|
96
|
+
# GIT_TOKEN and RENOVATE_TOKEN, likewise: files, so a new one needs a
|
|
97
|
+
# reload, not a restart. Written by the platform role (or forgejo-config).
|
|
98
|
+
- ./platform-secrets:/run/platform-secrets:ro
|
|
99
|
+
labels:
|
|
100
|
+
- "traefik.enable=true"
|
|
101
|
+
- "traefik.docker.network=platform-internal"
|
|
102
|
+
- "traefik.http.routers.jenkins.rule=Host(`jenkins.${DOMAIN:-localhost}`)"
|
|
103
|
+
- "traefik.http.routers.jenkins.entrypoints=websecure"
|
|
104
|
+
- "traefik.http.routers.jenkins.tls.certresolver=le"
|
|
105
|
+
- "traefik.http.routers.jenkins.middlewares=authelia@file"
|
|
106
|
+
- "traefik.http.routers.jenkins.service=jenkins"
|
|
107
|
+
# GitHub cannot log in; the plugin checks the payload's HMAC instead.
|
|
108
|
+
- "traefik.http.routers.jenkins-webhook.rule=Host(`jenkins.${DOMAIN:-localhost}`) && Path(`/github-webhook/`)"
|
|
109
|
+
- "traefik.http.routers.jenkins-webhook.entrypoints=websecure"
|
|
110
|
+
- "traefik.http.routers.jenkins-webhook.tls.certresolver=le"
|
|
111
|
+
- "traefik.http.routers.jenkins-webhook.priority=100"
|
|
112
|
+
- "traefik.http.routers.jenkins-webhook.service=jenkins"
|
|
113
|
+
- "traefik.http.services.jenkins.loadbalancer.server.port=8080"
|
|
114
|
+
networks:
|
|
115
|
+
- internal
|
|
116
|
+
healthcheck:
|
|
117
|
+
test: ["CMD", "curl", "-sf", "http://127.0.0.1:8080/login"]
|
|
118
|
+
interval: 15s
|
|
119
|
+
timeout: 5s
|
|
120
|
+
retries: 10
|
|
121
|
+
start_period: 60s
|
|
122
|
+
|
|
123
|
+
jenkins-agent:
|
|
124
|
+
build:
|
|
125
|
+
context: ./jenkins-agent
|
|
126
|
+
dockerfile: Dockerfile
|
|
127
|
+
restart: unless-stopped
|
|
128
|
+
# Written by the platform role once Jenkins is up; absent on first start,
|
|
129
|
+
# when the agent crash-loops until the role recreates it with the secret.
|
|
130
|
+
env_file:
|
|
131
|
+
- path: ./jenkins-agent.env
|
|
132
|
+
required: false
|
|
133
|
+
environment:
|
|
134
|
+
- JENKINS_URL=http://jenkins-controller:8080
|
|
135
|
+
- JENKINS_AGENT_NAME=builder
|
|
136
|
+
- JENKINS_AGENT_WORKDIR=/home/jenkins/agent
|
|
137
|
+
- DOMAIN=${DOMAIN:-localhost}
|
|
138
|
+
# Builds go to buildkitd, not to a docker daemon. This agent runs pipeline
|
|
139
|
+
# code from untrusted branches and deliberately has no docker socket.
|
|
140
|
+
- BUILDKIT_HOST=tcp://buildkitd:1234
|
|
141
|
+
- REGISTRY_PUSH_HOST=registry:5000
|
|
142
|
+
volumes:
|
|
143
|
+
- jenkins-agent-workdir:/home/jenkins/agent
|
|
144
|
+
networks:
|
|
145
|
+
- internal
|
|
146
|
+
depends_on:
|
|
147
|
+
jenkins-controller:
|
|
148
|
+
condition: service_healthy
|
|
149
|
+
buildkitd:
|
|
150
|
+
condition: service_started
|
|
151
|
+
|
|
152
|
+
volumes:
|
|
153
|
+
registry-data:
|
|
154
|
+
buildkit-cache:
|
|
155
|
+
jenkins-home:
|
|
156
|
+
jenkins-agent-workdir:
|