@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.
Files changed (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +81 -2
  3. package/ansible/.ansible-lint +13 -0
  4. package/ansible/ansible.cfg +12 -0
  5. package/ansible/playbooks/adopt.yml +35 -0
  6. package/ansible/playbooks/app.yml +21 -0
  7. package/ansible/playbooks/apps-prune.yml +30 -0
  8. package/ansible/playbooks/edge.yml +41 -0
  9. package/ansible/playbooks/nas.yml +47 -0
  10. package/ansible/playbooks/platform.yml +62 -0
  11. package/ansible/requirements.yml +7 -0
  12. package/ansible/roles/app/defaults/main.yml +9 -0
  13. package/ansible/roles/app/tasks/main.yml +107 -0
  14. package/ansible/roles/app/templates/app.env.j2 +9 -0
  15. package/ansible/roles/app/templates/casc-app.yaml.j2 +68 -0
  16. package/ansible/roles/backup-check/files/backup-check.service +9 -0
  17. package/ansible/roles/backup-check/files/backup-check.timer +10 -0
  18. package/ansible/roles/backup-check/tasks/main.yml +59 -0
  19. package/ansible/roles/backup-check/templates/backup-check.sh.j2 +117 -0
  20. package/ansible/roles/common/handlers/main.yml +16 -0
  21. package/ansible/roles/common/tasks/main.yml +100 -0
  22. package/ansible/roles/compose-stacks/tasks/main.yml +102 -0
  23. package/ansible/roles/docker/defaults/main.yml +3 -0
  24. package/ansible/roles/docker/files/buildkit-rootless +23 -0
  25. package/ansible/roles/docker/handlers/main.yml +12 -0
  26. package/ansible/roles/docker/tasks/main.yml +82 -0
  27. package/ansible/roles/edge-proxy/files/docker-compose.yml +13 -0
  28. package/ansible/roles/edge-proxy/handlers/main.yml +6 -0
  29. package/ansible/roles/edge-proxy/tasks/main.yml +34 -0
  30. package/ansible/roles/edge-proxy/templates/Caddyfile.j2 +25 -0
  31. package/ansible/roles/forgejo-config/tasks/main.yml +333 -0
  32. package/ansible/roles/platform/defaults/main.yml +76 -0
  33. package/ansible/roles/platform/tasks/main.yml +298 -0
  34. package/ansible/roles/platform/templates/authelia-users.yml.j2 +17 -0
  35. package/ansible/roles/platform/templates/platform.env.j2 +41 -0
  36. package/ansible/roles/restic-backup/files/docker-compose.postgres.yml +27 -0
  37. package/ansible/roles/restic-backup/files/docker-compose.yml +12 -0
  38. package/ansible/roles/restic-backup/handlers/main.yml +7 -0
  39. package/ansible/roles/restic-backup/tasks/main.yml +121 -0
  40. package/ansible/roles/restic-backup/templates/backup.env.j2 +9 -0
  41. package/ansible/roles/restic-backup/templates/backup.sh.j2 +28 -0
  42. package/ansible/roles/restic-backup/templates/docker-compose.override.yml.j2 +26 -0
  43. package/ansible/roles/restic-backup/templates/follow-restore-test.sh.j2 +28 -0
  44. package/ansible/roles/restic-backup/templates/restore-test.sh.j2 +51 -0
  45. package/ansible/roles/restic-backup/templates/root.crontab.j2 +6 -0
  46. package/ansible/roles/restic-backup/templates/ssh_config.j2 +12 -0
  47. package/ansible/roles/wireguard/handlers/main.yml +9 -0
  48. package/ansible/roles/wireguard/tasks/main.yml +33 -0
  49. package/ansible/roles/wireguard/templates/wg0.conf.j2 +12 -0
  50. package/bin/kayra.js +29 -0
  51. package/cli/dist/brand.js +41 -0
  52. package/cli/dist/cli.js +21 -0
  53. package/cli/dist/commands/doctor.js +79 -0
  54. package/cli/dist/commands/server.js +215 -0
  55. package/cli/dist/commands/status.js +43 -0
  56. package/cli/dist/fleet.js +134 -0
  57. package/cli/dist/interpret.js +98 -0
  58. package/cli/dist/load.js +49 -0
  59. package/cli/dist/main.js +94 -0
  60. package/cli/dist/plan.js +31 -0
  61. package/cli/dist/result.js +5 -0
  62. package/cloud-init.yml.tpl +14 -0
  63. package/compose/authelia/configuration.yml +138 -0
  64. package/compose/buildkit/buildkitd.toml +14 -0
  65. package/compose/docker-compose.acme-dns.yml +12 -0
  66. package/compose/docker-compose.ci.yml +156 -0
  67. package/compose/docker-compose.yml +83 -0
  68. package/compose/jenkins/Dockerfile +40 -0
  69. package/compose/jenkins/casc/00-platform.yaml +158 -0
  70. package/compose/jenkins/plugins.txt +97 -0
  71. package/compose/jenkins-agent/Dockerfile +24 -0
  72. package/compose/traefik/dynamic/authelia.yml +18 -0
  73. package/compose/traefik/dynamic/middlewares.yml +14 -0
  74. package/compose/traefik/dynamic/ratelimit.yml +21 -0
  75. package/compose/traefik/dynamic/tls.yml +9 -0
  76. package/compose/traefik/traefik.yml +46 -0
  77. package/hosts.conf +6 -0
  78. package/package.json +73 -4
  79. package/scripts/local/app-manifests.sh +26 -0
  80. package/scripts/local/authelia-dev-init.sh +58 -0
  81. package/scripts/local/certs.sh +55 -0
  82. package/scripts/local/home-image.sh +55 -0
  83. package/scripts/local/home-reach.sh +41 -0
  84. package/scripts/local/require-main.sh +38 -0
  85. package/scripts/local/setup-hosts.sh +30 -0
  86. package/scripts/server/app-purge +26 -0
  87. package/scripts/server/app-remove +30 -0
  88. package/scripts/server/jenkins-api +28 -0
  89. package/scripts/server/jenkins-app-apply +36 -0
  90. package/scripts/server/jenkins-reload +12 -0
  91. package/terraform/modules/hcloud-firewall/main.tf +54 -0
  92. package/terraform/modules/hcloud-firewall/outputs.tf +9 -0
  93. package/terraform/modules/hcloud-firewall/variables.tf +22 -0
  94. package/terraform/modules/hcloud-server/main.tf +62 -0
  95. package/terraform/modules/hcloud-server/outputs.tf +19 -0
  96. package/terraform/modules/hcloud-server/templates/inventory.ini.tpl +5 -0
  97. package/terraform/modules/hcloud-server/variables.tf +50 -0
  98. package/terraform/modules/multipass-instance/main.tf +67 -0
  99. package/terraform/modules/multipass-instance/outputs.tf +19 -0
  100. package/terraform/modules/multipass-instance/templates/inventory.ini.tpl +5 -0
  101. 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
+ };
@@ -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 }));
@@ -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
+ }
@@ -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: