@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,6 @@
1
+ # Generated by Ansible (restic-backup role). Output goes to the container log;
2
+ # a restore test writes its own to log/restore-test.log.
3
+ {{ restic_backup.schedule }} /etc/backup/backup.sh >/proc/1/fd/1 2>&1
4
+ {% if restic_backup.restore_test_schedule is defined %}
5
+ {{ restic_backup.restore_test_schedule }} /etc/backup/restore-test.sh >/proc/1/fd/1 2>&1
6
+ {% endif %}
@@ -0,0 +1,12 @@
1
+ # Generated by Ansible (restic-backup role): the SFTP target, as the
2
+ # repository's host alias `backup-target`.
3
+ Host backup-target
4
+ HostName {{ restic_backup.ssh.host }}
5
+ User {{ restic_backup.ssh.user }}
6
+ Port {{ restic_backup.ssh.port | default(22) }}
7
+ IdentityFile /root/.ssh/id_ed25519
8
+ IdentitiesOnly yes
9
+ # The host key is pinned in known_hosts; nothing else is trusted.
10
+ StrictHostKeyChecking yes
11
+ UserKnownHostsFile /root/.ssh/known_hosts
12
+ ServerAliveInterval 60
@@ -0,0 +1,9 @@
1
+ ---
2
+ - name: Apply sysctl
3
+ ansible.builtin.command: sysctl --system
4
+ changed_when: true
5
+
6
+ - name: Restart WireGuard
7
+ ansible.builtin.systemd:
8
+ name: wg-quick@wg0
9
+ state: restarted
@@ -0,0 +1,33 @@
1
+ ---
2
+ # =============================================================================
3
+ # WireGuard role — this server is the tunnel's listening end; the peer (a
4
+ # Fritzbox) dials in. wg-quick routes the peer's AllowedIPs through wg0, which
5
+ # is how containers here reach hosts on the peer's LAN.
6
+ # =============================================================================
7
+
8
+ - name: Install WireGuard tools
9
+ ansible.builtin.apt:
10
+ name: wireguard-tools
11
+
12
+ - name: Forward IPv4 between the tunnel and Docker's bridge
13
+ ansible.builtin.copy:
14
+ content: "net.ipv4.ip_forward=1\n"
15
+ dest: /etc/sysctl.d/99-ip-forward.conf
16
+ mode: "0644"
17
+ notify: Apply sysctl
18
+
19
+ - name: Render wg0.conf
20
+ ansible.builtin.template:
21
+ src: wg0.conf.j2
22
+ dest: /etc/wireguard/wg0.conf
23
+ owner: root
24
+ group: root
25
+ mode: "0600"
26
+ no_log: true
27
+ notify: Restart WireGuard
28
+
29
+ - name: Enable WireGuard
30
+ ansible.builtin.systemd:
31
+ name: wg-quick@wg0
32
+ enabled: true
33
+ state: started
@@ -0,0 +1,12 @@
1
+ # Generated by Ansible from servers/<server>/vars.yml (network) and secrets.yml
2
+ # (keys). Do not edit here.
3
+ [Interface]
4
+ Address = {{ wireguard.address }}
5
+ ListenPort = {{ wireguard.port }}
6
+ PrivateKey = {{ wireguard_private_key }}
7
+
8
+ [Peer]
9
+ PublicKey = {{ wireguard.peer_public_key }}
10
+ PresharedKey = {{ wireguard_peer_psk }}
11
+ AllowedIPs = {{ wireguard.peer_allowed_ips | join(', ') }}
12
+ PersistentKeepalive = {{ wireguard.keepalive }}
package/bin/kayra.js ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ // Runs the kayra the fleet pins. Whichever `kayra` is on PATH (a global
3
+ // install, a linked checkout) hands over to node_modules/@yunuseon/kayra of the
4
+ // fleet it is run in (the nearest directory with servers/defaults.yml), as
5
+ // turbo or nx do; without one, it runs itself. `npm link @yunuseon/kayra` in a
6
+ // fleet makes that pinned copy a checkout, for working on kayra itself.
7
+ //
8
+ // A checkout runs its TypeScript directly, never a build lying around; the
9
+ // published package has no cli/src and runs the compiled CLI (Node strips no
10
+ // types inside node_modules).
11
+ import { existsSync, realpathSync } from "node:fs";
12
+ import { dirname, join } from "node:path";
13
+ import { fileURLToPath, pathToFileURL } from "node:url";
14
+
15
+ const self = realpathSync(join(dirname(fileURLToPath(import.meta.url)), ".."));
16
+
17
+ const fleetOf = (dir) =>
18
+ existsSync(join(dir, "servers", "defaults.yml")) ? dir : dirname(dir) === dir ? undefined : fleetOf(dirname(dir));
19
+
20
+ const fleet = fleetOf(process.cwd());
21
+ const pinnedDir = fleet === undefined ? undefined : join(fleet, "node_modules", "@yunuseon", "kayra");
22
+ const pinned = pinnedDir !== undefined && existsSync(pinnedDir) ? realpathSync(pinnedDir) : self;
23
+
24
+ if (pinned !== self) {
25
+ await import(pathToFileURL(join(pinned, "bin", "kayra.js")).href);
26
+ } else {
27
+ const source = join(self, "cli", "src", "main.ts");
28
+ await import(pathToFileURL(existsSync(source) ? source : join(self, "cli", "dist", "main.js")).href);
29
+ }
@@ -0,0 +1,41 @@
1
+ import { text } from "@optique/core/message";
2
+ import { homedir } from "node:os";
3
+ import { isAbsolute, join, normalize } from "node:path";
4
+ import { err, ok } from "./result.js";
5
+ export const serverName = (value) => /^[a-z0-9][a-z0-9-]*$/.test(value)
6
+ ? ok(value)
7
+ : err(`'${value}' is not a server name (lowercase letters, digits, dashes)`);
8
+ export const appName = (value) => /^[a-z][a-z0-9-]*$/.test(value)
9
+ ? ok(value)
10
+ : err(`'${value}' is not an app name (lowercase letters, digits, dashes)`);
11
+ export const subdomain = (value) => /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(value) ? ok(value) : err(`'${value}' is not a subdomain`);
12
+ export const domain = (value) => /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z][a-z0-9-]*$/.test(value)
13
+ ? ok(value)
14
+ : err(`'${value}' is not a domain`);
15
+ /** `~/…` is the operator's home, as Ansible reads it in vault_password_file. */
16
+ export const absolutePath = (value) => {
17
+ const expanded = value === "~" || value.startsWith("~/") ? join(homedir(), value.slice(1)) : value;
18
+ return isAbsolute(expanded) ? ok(normalize(expanded)) : err(`'${value}' is not an absolute path`);
19
+ };
20
+ export const kayraRoot = (path) => path;
21
+ export const fleetRoot = (path) => path;
22
+ export const kayraVersion = (value) => typeof value === "string" && /^\d+\.\d+\.\d+$/.test(value)
23
+ ? ok(value)
24
+ : err(`'${String(value)}' is not a version MAJOR.MINOR.PATCH`);
25
+ export const serverDir = (root, name) => join(root, "servers", name);
26
+ export const vaultPasswordFile = (path) => path;
27
+ export const within = (base, ...segments) => join(base, ...segments);
28
+ /** An Optique value parser that mints a brand: arguments arrive already validated. */
29
+ const minting = (metavar, mint, placeholder) => ({
30
+ mode: "sync",
31
+ metavar,
32
+ placeholder,
33
+ parse: (input) => {
34
+ const minted = mint(input);
35
+ return minted.ok ? { success: true, value: minted.value } : { success: false, error: [text(minted.error)] };
36
+ },
37
+ format: (value) => value,
38
+ });
39
+ export const appNameParser = minting("APP", appName, "app");
40
+ export const domainParser = minting("DOMAIN", domain, "example.com");
41
+ export const subdomainParser = minting("SUBDOMAIN", subdomain, "www");
@@ -0,0 +1,21 @@
1
+ import { command, constant, multiple, object, option, or } from "@optique/core";
2
+ import { message } from "@optique/core/message";
3
+ import { argument } from "@optique/core/primitives";
4
+ import { choice } from "@optique/core/valueparser";
5
+ import { appNameParser, domainParser, subdomainParser } from "./brand.js";
6
+ /** The arguments kayra takes; server names are the fleet's, so they complete and are checked while parsing. */
7
+ export const parser = (fleet) => {
8
+ const server = argument(choice(fleet.servers.map((each) => each.name), { metavar: "SERVER" }));
9
+ // eslint-disable-next-line functional/prefer-immutable-types -- Optique's Message is readonly only at the top
10
+ const onServer = (name, brief) => command(name, object({ name: constant(name), server }), { brief });
11
+ return object({
12
+ dryRun: option("--dry-run", { description: message `Print the plan instead of running it.` }),
13
+ command: or(command("status", object({ name: constant("status") }), {
14
+ brief: message `Every server and whether a machine exists for it.`,
15
+ }), 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("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).`), onServer("ssh", message `SSH into the server.`), 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 })), {
16
+ brief: message `Restore the latest backup to a scratch dir, check it, delete it.`,
17
+ }), command("app", command("purge", object({ name: constant("app purge"), server, app: argument(appNameParser) })), { brief: message `Delete a removed app's volumes: its data, irreversibly.` }), command("certs", object({ name: constant("certs"), domain: argument(domainParser), subdomains: multiple(argument(subdomainParser)) }), { brief: message `mkcert certs for a domain and its subdomains (local dev, multipass).` }), command("doctor", object({ name: constant("doctor") }), {
18
+ brief: message `Check that this Mac can run every command on every server.`,
19
+ })),
20
+ });
21
+ };
@@ -0,0 +1,79 @@
1
+ import { within } from "../brand.js";
2
+ const everyServer = () => true;
3
+ const tools = [
4
+ { program: "git", install: "brew install git", neededBy: everyServer },
5
+ { program: "ssh", install: "macOS ships it", neededBy: everyServer },
6
+ { program: "yq", install: "brew install yq", neededBy: everyServer },
7
+ { program: "jq", install: "brew install jq", neededBy: everyServer },
8
+ { program: "ansible-playbook", install: "brew install ansible", neededBy: everyServer },
9
+ { program: "ansible-galaxy", install: "brew install ansible", neededBy: everyServer },
10
+ { program: "ansible-vault", install: "brew install ansible", neededBy: (server) => server.vault.kind !== "none" },
11
+ { program: "tofu", install: "brew install opentofu", neededBy: (server) => server.terraform },
12
+ {
13
+ program: "mkcert",
14
+ install: "brew install mkcert && mkcert -install",
15
+ neededBy: (server) => server.kind === "platform" && server.target !== "hetzner",
16
+ },
17
+ {
18
+ program: "multipass",
19
+ install: "brew install --cask multipass",
20
+ neededBy: (server) => server.target === "multipass",
21
+ },
22
+ { program: "qemu-img", install: "brew install qemu", neededBy: (server) => server.vm },
23
+ ];
24
+ const toolChecks = (fleet) => tools.flatMap((tool) => {
25
+ const users = fleet.servers.filter(tool.neededBy).map((server) => server.name);
26
+ return users.length === 0
27
+ ? []
28
+ : [
29
+ {
30
+ kind: "check",
31
+ label: `${tool.program} (${users.length === fleet.servers.length ? "every server" : users.join(", ")})`,
32
+ probe: { kind: "on-path", program: tool.program },
33
+ hint: tool.install,
34
+ onFailure: "continue",
35
+ },
36
+ ];
37
+ });
38
+ // ansible-vault opens whole-file vaults only; values vaulted inline are opened by setup.
39
+ const vaultChecks = (fleet) => fleet.servers.flatMap((server) => server.vault.kind === "file" && server.secretsVaulted
40
+ ? [
41
+ {
42
+ kind: "check",
43
+ label: `vault password opens ${server.name}'s secrets.yml`,
44
+ probe: {
45
+ kind: "exits-zero",
46
+ argv: ["ansible-vault", "view", "--vault-password-file", server.vault.file, within(server.dir, "secrets.yml")],
47
+ },
48
+ hint: `the password file is ${server.vault.file}`,
49
+ onFailure: "continue",
50
+ },
51
+ ]
52
+ : []);
53
+ /** The apps' repositories are cloned on this Mac with the operator's own git credentials. */
54
+ const repoChecks = (fleet) => [...new Set(fleet.servers.flatMap((server) => server.apps.map((app) => app.repo)))].map((repo) => ({
55
+ kind: "check",
56
+ label: `git can read ${repo}`,
57
+ probe: { kind: "exits-zero", argv: ["sh", "-c", 'GIT_TERMINAL_PROMPT=0 git ls-remote "$1" HEAD', "sh", repo] },
58
+ hint: "gh auth login && gh auth setup-git (or git credentials of your own for that host)",
59
+ onFailure: "continue",
60
+ }));
61
+ const reachChecks = (fleet, kayra) => fleet.servers
62
+ .filter((server) => server.target === "home")
63
+ .map((server) => ({
64
+ kind: "check",
65
+ label: `${server.name} answers over SSH`,
66
+ probe: {
67
+ kind: "exits-zero",
68
+ argv: ["bash", within(kayra, "scripts", "local", "home-reach.sh"), server.name, server.kind],
69
+ env: { FLEET_ROOT: fleet.root },
70
+ },
71
+ onFailure: "continue",
72
+ }));
73
+ /** Is this Mac ready to run every command on every server? Each check reports; none stops the others. */
74
+ export const doctor = (fleet, kayra) => [
75
+ ...toolChecks(fleet),
76
+ ...vaultChecks(fleet),
77
+ ...repoChecks(fleet),
78
+ ...reachChecks(fleet, kayra),
79
+ ];
@@ -0,0 +1,215 @@
1
+ import { within, } from "../brand.js";
2
+ import { run } from "../plan.js";
3
+ import { all, err, map, ok } from "../result.js";
4
+ /** The local scripts that read the fleet's files find them here, not beside themselves. */
5
+ const fleetEnv = ({ fleet }) => ({ FLEET_ROOT: fleet });
6
+ const script = ({ kayra }, name) => within(kayra, "scripts", "local", name);
7
+ const ansiblePath = ({ kayra }, ...segments) => within(kayra, "ansible", ...segments);
8
+ const inventory = (server) => within(server.dir, "inventory.ini");
9
+ const terraform = (server) => `-chdir=${within(server.dir, "terraform")}`;
10
+ /** How Ansible gets the vault password: from a file, or by asking. */
11
+ const vaultEnv = (server) => {
12
+ switch (server.vault.kind) {
13
+ case "file":
14
+ return { ANSIBLE_VAULT_PASSWORD_FILE: server.vault.file };
15
+ case "ask":
16
+ return { ANSIBLE_ASK_VAULT_PASS: "True" };
17
+ case "none":
18
+ return {};
19
+ }
20
+ };
21
+ const ansibleEnv = (engine, env) => ({ ...env, ANSIBLE_CONFIG: ansiblePath(engine, "ansible.cfg") });
22
+ /** Real servers change only from main as merged; a multipass one from anything. */
23
+ const fromMain = (engine, server) => ({
24
+ kind: "check",
25
+ label: `${server.name} may be changed from this checkout`,
26
+ probe: { kind: "exits-zero", argv: ["bash", script(engine, "require-main.sh"), server.name], env: fleetEnv(engine) },
27
+ onFailure: "stop",
28
+ });
29
+ /** Before Ansible's UNREACHABLE: why a home machine cannot be reached. */
30
+ const reachable = (engine, server) => server.target === "home"
31
+ ? [
32
+ {
33
+ kind: "check",
34
+ label: `${server.name} answers over SSH`,
35
+ probe: {
36
+ kind: "exits-zero",
37
+ argv: ["bash", script(engine, "home-reach.sh"), server.name, server.kind],
38
+ env: fleetEnv(engine),
39
+ },
40
+ onFailure: "stop",
41
+ },
42
+ ]
43
+ : [];
44
+ /** The pinned Ansible collections; a no-op once installed. */
45
+ const collections = (engine, env) => ({
46
+ kind: "run",
47
+ argv: ["ansible-galaxy", "collection", "install", "-r", ansiblePath(engine, "requirements.yml")],
48
+ env,
49
+ stdout: "discard",
50
+ });
51
+ const appSubdomains = (engine, server) => ({
52
+ kind: "query",
53
+ argv: ["bash", script(engine, "app-manifests.sh"), "--subdomains", server.name],
54
+ words: "many",
55
+ env: fleetEnv(engine),
56
+ });
57
+ const certs = (engine, domain, subdomains) => run(["bash", script(engine, "certs.sh"), domain, within(engine.fleet, ".cache", "certs"), ...subdomains]);
58
+ export const provision = (engine, server) => ok([
59
+ fromMain(engine, server),
60
+ ...(server.vm ? [run(["bash", script(engine, "home-image.sh"), server.name], fleetEnv(engine))] : []),
61
+ ...(server.terraform
62
+ ? [run(["tofu", terraform(server), "init", "-input=false"]), run(["tofu", terraform(server), "apply", "-auto-approve"])]
63
+ : []),
64
+ ]);
65
+ /** The host layer: the playbook for the server's kind. */
66
+ export const setup = (engine, server) => ok([
67
+ fromMain(engine, server),
68
+ // mkcert where Let's Encrypt cannot reach: test VMs, and home servers without DNS-01.
69
+ ...(server.kind === "platform" && server.target !== "hetzner" && !server.acmeDns
70
+ ? [certs(engine, server.domain, [appSubdomains(engine, server), ...server.stackHosts])]
71
+ : []),
72
+ ...reachable(engine, server),
73
+ collections(engine, vaultEnv(server)),
74
+ run([
75
+ "ansible-playbook",
76
+ "-i",
77
+ inventory(server),
78
+ ansiblePath(engine, "playbooks", `${server.kind}.yml`),
79
+ "-e",
80
+ `server=${server.name}`,
81
+ "-e",
82
+ `server_dir=${server.dir}`,
83
+ "-e",
84
+ `domain=${server.domain}`,
85
+ "-e",
86
+ `target=${server.target}`,
87
+ "-e",
88
+ `kayra_version=${engine.version}`,
89
+ ], ansibleEnv(engine, vaultEnv(server))),
90
+ ]);
91
+ /** Register every app in server.yml, then remove the rest (their data kept). */
92
+ export const apps = (engine, server) => {
93
+ const env = vaultEnv(server);
94
+ const playbook = (name, ...extra) => run(["ansible-playbook", "-i", inventory(server), ansiblePath(engine, "playbooks", name), ...extra], ansibleEnv(engine, env));
95
+ return ok([
96
+ fromMain(engine, server),
97
+ ...(server.kind === "platform"
98
+ ? [
99
+ ...server.apps.flatMap((app) => [
100
+ collections(engine, env),
101
+ playbook("app.yml", "-e", `server=${server.name}`, "-e", `server_dir=${server.dir}`, "-e", `domain=${server.domain}`, "-e", `{"app": ${app.entry}}`),
102
+ ]),
103
+ collections(engine, env),
104
+ playbook("apps-prune.yml", "-e", `{"keep": ${JSON.stringify(server.apps.map((app) => app.name))}}`),
105
+ ]
106
+ : []),
107
+ ]);
108
+ };
109
+ export const hosts = (engine, server) => ok(server.target === "multipass"
110
+ ? [
111
+ run([
112
+ "bash",
113
+ script(engine, "setup-hosts.sh"),
114
+ server.domain,
115
+ { kind: "query", argv: ["tofu", terraform(server), "output", "-raw", "server_ip"], words: "one" },
116
+ appSubdomains(engine, server),
117
+ ]),
118
+ ]
119
+ : [{ kind: "note", text: `hosts: ${server.target} uses real DNS, nothing to do.` }]);
120
+ const sequence = (plans) => map(all(plans), (each) => each.flat());
121
+ export const deploy = (engine, server) => sequence([setup(engine, server), apps(engine, server)]);
122
+ export const up = (engine, server) => sequence([provision(engine, server), deploy(engine, server), hosts(engine, server)]);
123
+ export const down = (server) => server.terraform
124
+ ? ok([
125
+ ...(server.target === "hetzner"
126
+ ? [
127
+ {
128
+ kind: "confirm",
129
+ prompt: `This destroys the Hetzner server '${server.name}' (${server.domain}). Type the name to confirm: `,
130
+ expect: server.name,
131
+ },
132
+ ]
133
+ : []),
134
+ run(["tofu", terraform(server), "destroy", "-auto-approve"]),
135
+ ])
136
+ : err(`${server.name} has no terraform/: nothing here creates it, so nothing destroys it`);
137
+ /** Once, for a server we did not create: the deploy user, added as root. */
138
+ export const adopt = (engine, server) => ok([
139
+ fromMain(engine, server),
140
+ collections(engine, {}),
141
+ run(["ansible-playbook", "-i", inventory(server), ansiblePath(engine, "playbooks", "adopt.yml")], ansibleEnv(engine, {})),
142
+ ]);
143
+ /** A removed app's volumes: its data, irreversibly. */
144
+ export const appPurge = (engine, server, app) => ok([
145
+ fromMain(engine, server),
146
+ run([
147
+ "ansible",
148
+ "servers",
149
+ "-i",
150
+ inventory(server),
151
+ "--become",
152
+ "-m",
153
+ "ansible.builtin.command",
154
+ "-a",
155
+ `/opt/platform/bin/app-purge ${app}`,
156
+ ], ansibleEnv(engine, {})),
157
+ ]);
158
+ /**
159
+ * The restic-backup role's restore test: the latest backup, restored to a scratch dir and checked.
160
+ * It runs on the server, detached; kayra follows its log over SSH. Stopping kayra stops only the
161
+ * following, and running it again while a test runs follows that one instead of starting another.
162
+ */
163
+ export const backupTest = (engine, server) => {
164
+ if (server.backupDir === undefined)
165
+ return err(`${server.name} has no restic_backup.dir in its vars.yml`);
166
+ if (server.inventory === undefined)
167
+ return err(`no inventory for '${server.name}' — provision it first.`);
168
+ return ok([
169
+ ...reachable(engine, server),
170
+ run([
171
+ "ansible",
172
+ "servers",
173
+ "-i",
174
+ inventory(server),
175
+ "--become",
176
+ "-e",
177
+ `@${within(server.dir, "secrets.yml")}`,
178
+ "-m",
179
+ "ansible.builtin.command",
180
+ "-a",
181
+ `docker compose -f ${server.backupDir}/docker-compose.yml exec -d -T backup /etc/backup/restore-test.sh`,
182
+ ], ansibleEnv(engine, vaultEnv(server))),
183
+ { kind: "note", text: "Following the restore test. Ctrl-C stops following, not the test." },
184
+ run([
185
+ "ssh",
186
+ "-p",
187
+ server.inventory.port,
188
+ "-i",
189
+ server.inventory.key,
190
+ "-o",
191
+ "StrictHostKeyChecking=accept-new",
192
+ `${server.inventory.user}@${server.inventory.host}`,
193
+ "sh",
194
+ `${server.backupDir}/follow-restore-test.sh`,
195
+ ]),
196
+ ]);
197
+ };
198
+ export const ssh = (server) => server.inventory === undefined
199
+ ? err(`no inventory for '${server.name}' — provision it first.`)
200
+ : ok([
201
+ run([
202
+ "ssh",
203
+ "-p",
204
+ server.inventory.port,
205
+ "-i",
206
+ server.inventory.key,
207
+ "-o",
208
+ "StrictHostKeyChecking=no",
209
+ "-o",
210
+ "UserKnownHostsFile=/dev/null",
211
+ `${server.inventory.user}@${server.inventory.host}`,
212
+ ]),
213
+ ]);
214
+ /** mkcert certs for a domain and its subdomains, for local dev and multipass. */
215
+ export const certsFor = (engine, domain, subdomains) => ok([certs(engine, domain, subdomains)]);
@@ -0,0 +1,43 @@
1
+ import { within } from "../brand.js";
2
+ import { capture } from "../interpret.js";
3
+ const describe = (machine) => {
4
+ switch (machine.kind) {
5
+ case "none":
6
+ return "not provisioned";
7
+ case "provisioned":
8
+ return `provisioned (${machine.ip ?? "?"})`;
9
+ case "unreadable":
10
+ return `state unreadable: ${machine.reason}`;
11
+ }
12
+ };
13
+ export const statusLine = (server, machine) => [
14
+ server.name.padEnd(10),
15
+ `kind=${server.kind.padEnd(9)} target=${server.target.padEnd(10)}`,
16
+ `domain=${server.domain.padEnd(24)}`,
17
+ `apps=${server.apps.map((app) => app.name).join(",").padEnd(10)}`,
18
+ describe(machine),
19
+ ].join(" ");
20
+ /** Why tofu could not read the state, in a few words: the first line of its error. */
21
+ export const unreadable = (stderr) => {
22
+ if (stderr.includes("tofu init"))
23
+ return "tofu init needed";
24
+ const line = stderr
25
+ .split("\n")
26
+ .map((each) => each.replace(/^[│╷╵\s]+/, "").replace(/^Error:\s*/, "").trim())
27
+ .find((each) => each !== "");
28
+ return line ?? "tofu failed";
29
+ };
30
+ const machine = async (server) => {
31
+ if (!server.terraform)
32
+ return { kind: "none" };
33
+ const chdir = `-chdir=${within(server.dir, "terraform")}`;
34
+ const state = await capture(["tofu", chdir, "state", "list", "-no-color"], "capture");
35
+ if (state.code !== 0)
36
+ return { kind: "unreadable", reason: unreadable(state.stderr) };
37
+ if (state.stdout.trim() === "")
38
+ return { kind: "none" };
39
+ const ip = await capture(["tofu", chdir, "output", "-raw", "server_ip"], "capture");
40
+ return { kind: "provisioned", ip: ip.code === 0 ? ip.stdout.replace(/\n+$/, "") : undefined };
41
+ };
42
+ /** Every server and whether a machine exists for it. Reads state, changes nothing. */
43
+ export const status = async (fleet) => Promise.all(fleet.servers.map(async (server) => statusLine(server, await machine(server))));
@@ -0,0 +1,134 @@
1
+ import { parse } from "yaml";
2
+ import { absolutePath, appName, domain, vaultPasswordFile, } from "./brand.js";
3
+ import { all, err, map, ok } from "./result.js";
4
+ export const serverKinds = ["platform", "edge", "nas"];
5
+ export const serverTargets = ["multipass", "hetzner", "home"];
6
+ const mapping = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : undefined;
7
+ const fields = (text) => {
8
+ const value = parse(text);
9
+ if (value === null)
10
+ return ok({});
11
+ const result = mapping(value);
12
+ return result === undefined ? err("is not a YAML mapping") : ok(result);
13
+ };
14
+ const vaulted = (text) => text.startsWith("$ANSIBLE_VAULT");
15
+ const oneOf = (allowed, value, field) => {
16
+ const match = allowed.find((candidate) => candidate === value);
17
+ return match === undefined
18
+ ? err(`${field} must be ${allowed.join(", ")}, not '${String(value)}'`)
19
+ : ok(match);
20
+ };
21
+ const vault = (descriptor) => {
22
+ const file = descriptor["vault_password_file"];
23
+ if (descriptor["vault"] !== true)
24
+ return ok({ kind: "none" });
25
+ if (file === undefined)
26
+ return ok({ kind: "ask" });
27
+ return typeof file === "string"
28
+ ? map(absolutePath(file), (path) => ({ kind: "file", file: vaultPasswordFile(path) }))
29
+ : err("vault_password_file must be a path");
30
+ };
31
+ /** Apps live in their own repo: Jenkins clones it over HTTPS, and the app root stays inside it. */
32
+ const app = (value) => {
33
+ const entry = mapping(value);
34
+ if (entry === undefined)
35
+ return err("an app must be a mapping");
36
+ const name = typeof entry["name"] === "string" ? appName(entry["name"]) : err("an app needs a name");
37
+ if (!name.ok)
38
+ return name;
39
+ const repo = entry["repo"];
40
+ if (typeof repo !== "string" || !repo.startsWith("https://"))
41
+ return err(`app ${name.value}: repo must be an https:// URL`);
42
+ const path = entry["path"] ?? ".";
43
+ if (typeof path !== "string" || `/${path}/`.includes("/../"))
44
+ return err(`app ${name.value}: path must stay inside its repo`);
45
+ return ok({ name: name.value, repo, entry: JSON.stringify(value) });
46
+ };
47
+ const inventoryField = (inventory, key) => new RegExp(`${key}=([^ \\n]*)`).exec(inventory)?.[1] ?? "";
48
+ const inventoryOf = (text) => ({
49
+ host: inventoryField(text, "ansible_host"),
50
+ port: inventoryField(text, "ansible_port") || "22",
51
+ user: inventoryField(text, "ansible_user"),
52
+ key: inventoryField(text, "ansible_ssh_private_key_file"),
53
+ });
54
+ const stackHosts = (stacks, serverDomain) => stacks
55
+ .flatMap((compose) => [...compose.matchAll(/Host\(`([^`]*)`\)/g)].map((match) => match[1] ?? ""))
56
+ .map((host) => (host.endsWith(`.${serverDomain}`) ? host.slice(0, -serverDomain.length - 1) : host));
57
+ /** An app's config is plaintext, to be read and diffed; its secrets are vaulted wherever secrets.yml is. */
58
+ const appValuesProblem = (appValues, secretsMustBeVaulted) => appValues
59
+ .map(({ file, text }) => file.endsWith(".secrets.yml")
60
+ ? secretsMustBeVaulted && !vaulted(text)
61
+ ? `apps/${file} must be vault-encrypted`
62
+ : undefined
63
+ : vaulted(text)
64
+ ? `apps/${file} is vault-encrypted; it holds config only — secrets go in apps/${file.replace(/\.yml$/, ".secrets.yml")}`
65
+ : undefined)
66
+ .find((problem) => problem !== undefined);
67
+ /** What every command may rely on, checked for every server whenever the fleet loads. */
68
+ export const parseServer = (files) => {
69
+ const descriptor = fields(files.descriptor);
70
+ if (!descriptor.ok)
71
+ return descriptor;
72
+ const kind = oneOf(serverKinds, descriptor.value["kind"] ?? "platform", "kind");
73
+ if (!kind.ok)
74
+ return kind;
75
+ const target = oneOf(serverTargets, descriptor.value["target"], "target");
76
+ if (!target.ok)
77
+ return target;
78
+ const declared = descriptor.value["domain"];
79
+ const serverDomain = typeof declared === "string" ? domain(declared) : err("domain is required");
80
+ if (!serverDomain.ok)
81
+ return serverDomain;
82
+ const serverVault = vault(descriptor.value);
83
+ if (!serverVault.ok)
84
+ return serverVault;
85
+ const declaredApps = descriptor.value["apps"] ?? [];
86
+ if (!Array.isArray(declaredApps))
87
+ return err("apps must be a list");
88
+ if (kind.value !== "platform" && declaredApps.length > 0)
89
+ return err("apps run on platform servers only");
90
+ const apps = all(declaredApps.map(app));
91
+ if (!apps.ok)
92
+ return apps;
93
+ if (target.value === "home" && files.inventory === undefined)
94
+ return err("target home needs a hand-written inventory.ini next to it");
95
+ if (target.value !== "home" && !files.terraform)
96
+ return err("no terraform/ next to it");
97
+ if (files.vars === undefined)
98
+ return err("no vars.yml next to it");
99
+ if (files.secrets === undefined)
100
+ return err("no secrets.yml next to it");
101
+ if (vaulted(files.vars))
102
+ return err("vars.yml is vault-encrypted; it holds settings only — secrets go in secrets.yml");
103
+ if (target.value !== "multipass" && serverVault.value.kind === "none")
104
+ return err(`target ${target.value} requires vault: true`);
105
+ if (target.value !== "multipass" && !vaulted(files.secrets))
106
+ return err(`target ${target.value} requires a vault-encrypted secrets.yml`);
107
+ const appValues = appValuesProblem(files.appValues, target.value !== "multipass");
108
+ if (appValues !== undefined)
109
+ return err(appValues);
110
+ const vars = fields(files.vars);
111
+ if (!vars.ok)
112
+ return err(`vars.yml ${vars.error}`);
113
+ const backupDir = mapping(vars.value["restic_backup"])?.["dir"];
114
+ return ok({
115
+ name: files.name,
116
+ dir: files.dir,
117
+ kind: kind.value,
118
+ target: target.value,
119
+ domain: serverDomain.value,
120
+ vault: serverVault.value,
121
+ secretsVaulted: vaulted(files.secrets),
122
+ apps: apps.value,
123
+ terraform: files.terraform,
124
+ vm: target.value === "home" && vars.value["vm"] !== undefined,
125
+ acmeDns: typeof vars.value["acme_dns"] === "string" && vars.value["acme_dns"] !== "",
126
+ inventory: files.inventory === undefined ? undefined : inventoryOf(files.inventory),
127
+ backupDir: typeof backupDir === "string" ? backupDir : undefined,
128
+ stackHosts: stackHosts(files.stacks, serverDomain.value),
129
+ });
130
+ };
131
+ export const serverNamed = (fleet, name) => {
132
+ const server = fleet.servers.find((candidate) => candidate.name === name);
133
+ return server === undefined ? err(`no server '${name}'`) : ok(server);
134
+ };