@yunuseon/kayra 0.0.0-stage → 0.4.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 +80 -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 +74 -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 +292 -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,41 @@
1
+ # =============================================================================
2
+ # {{ platform_dir }}/.env — generated by Ansible, do not edit on the server.
3
+ #
4
+ # Compose reads it automatically in {{ platform_dir }}, and COMPOSE_FILE means
5
+ # `docker compose <cmd>` there needs no -f flags. App-agnostic: app secrets
6
+ # reach Jenkins through bin/jenkins-app-apply, app config lives in
7
+ # {{ apps_dir }}/<app>/.env. The agent secret lives in jenkins-agent.env.
8
+ # =============================================================================
9
+
10
+ COMPOSE_PROJECT_NAME=platform
11
+ COMPOSE_FILE=docker-compose.yml:docker-compose.ci.yml{{ ':docker-compose.acme-dns.yml' if acme_dns else '' }}
12
+ DOMAIN={{ domain }}
13
+ AUTH_POLICY={{ authelia_policy }}
14
+ APPS_DIR={{ apps_dir }}
15
+ DOCKER_GID={{ ansible_facts.getent_group.docker[1] }}
16
+ # Branch the pipeline library and platform jobs are loaded from.
17
+ PLATFORM_REF={{ platform_ref }}
18
+ PLATFORM_RENOVATE={{ (renovate_repositories | length > 0) | lower }}
19
+ RENOVATE_SCHEDULE={{ renovate_schedule }}
20
+ RENOVATE_PLATFORM={{ renovate_platform }}
21
+ RENOVATE_ENDPOINT={{ renovate_endpoint }}
22
+ RENOVATE_GIT_URL={{ renovate_git_url }}
23
+ RENOVATE_GITHUB_COM_TOKEN={{ renovate_github_com_token }}
24
+ RENOVATE_HOST_RULES={{ renovate_host_rules | to_json if renovate_host_rules else '' }}
25
+ RENOVATE_REPOSITORIES={{ renovate_repositories | to_json }}
26
+
27
+ # Jenkins
28
+ JENKINS_ADMIN_USER={{ jenkins_admin_user }}
29
+ JENKINS_ADMIN_PASSWORD={{ jenkins_admin_password }}
30
+
31
+ # Git
32
+ GIT_REMOTE_URL={{ git_remote_url }}
33
+ GITHUB_WEBHOOK_SECRET={{ github_webhook_secret }}
34
+
35
+ # Authelia SMTP. Empty address falls the notifier back to a file — passkey
36
+ # enrolment then needs the code read off the server. The password is NOT here:
37
+ # it is rendered to authelia/secrets/smtp_password, because an empty
38
+ # AUTHELIA_NOTIFIER_SMTP_PASSWORD would make Authelia think SMTP is configured.
39
+ AUTHELIA_SMTP_ADDRESS={{ authelia_smtp_address }}
40
+ AUTHELIA_SMTP_USERNAME={{ authelia_smtp_username }}
41
+ AUTHELIA_SMTP_SENDER={{ authelia_smtp_sender }}
@@ -0,0 +1,27 @@
1
+ # Managed by Ansible (restic-backup role), where restic_backup.postgres lists
2
+ # databases to dump: copied as docker-compose.yml. Mounts and networks, which
3
+ # differ per server, are in docker-compose.override.yml next to it.
4
+ services:
5
+ # Hands its static restic binary to the backup container and exits.
6
+ restic:
7
+ image: restic/restic:0.19.1
8
+ entrypoint: ["cp", "/usr/bin/restic", "/opt/restic/restic"]
9
+ volumes:
10
+ - restic-bin:/opt/restic
11
+
12
+ # Postgres for pg_dump, busybox crond for the schedule. A newer pg_dump
13
+ # reads older servers, so its major only has to be the highest dumped.
14
+ backup:
15
+ image: postgres:18.6-alpine3.24
16
+ restart: unless-stopped
17
+ entrypoint: ["crond", "-f", "-d", "8"]
18
+ depends_on:
19
+ restic:
20
+ condition: service_completed_successfully
21
+ volumes:
22
+ - restic-bin:/opt/restic:ro
23
+ - ./config:/etc/backup:ro
24
+ - ./cron:/etc/crontabs:ro
25
+
26
+ volumes:
27
+ restic-bin:
@@ -0,0 +1,12 @@
1
+ # Managed by Ansible (restic-backup role). Mounts and networks, which differ
2
+ # per server, are in docker-compose.override.yml next to it. Where databases
3
+ # are dumped first, docker-compose.postgres.yml takes this file's place.
4
+ services:
5
+ # restic's image with busybox crond for the schedule.
6
+ backup:
7
+ image: restic/restic:0.19.1
8
+ restart: unless-stopped
9
+ entrypoint: ["crond", "-f", "-d", "8"]
10
+ volumes:
11
+ - ./config:/etc/backup:ro
12
+ - ./cron:/etc/crontabs:ro
@@ -0,0 +1,7 @@
1
+ ---
2
+ # busybox crond reads its crontab at start only.
3
+ - name: Restart backup
4
+ ansible.builtin.command: docker compose restart backup
5
+ args:
6
+ chdir: "{{ restic_backup.dir }}"
7
+ changed_when: true
@@ -0,0 +1,121 @@
1
+ ---
2
+ # =============================================================================
3
+ # restic-backup role — backs restic_backup.paths up to a restic repository (a
4
+ # REST server, or SFTP with restic_backup.ssh), nightly, from a compose stack in restic_backup.dir. Postgres databases are
5
+ # dumped first. Nothing outside restic_backup.dir is written, and the paths
6
+ # are mounted read-only.
7
+ #
8
+ # By hand, on the server:
9
+ # docker compose -f <dir>/docker-compose.yml exec backup /etc/backup/backup.sh
10
+ # docker compose -f <dir>/docker-compose.yml logs backup
11
+ # Restore test: kayra backup test <server> (starts one, follows its log)
12
+ # =============================================================================
13
+
14
+ - name: Assert every database has a password
15
+ ansible.builtin.assert:
16
+ that:
17
+ - item.name is match('^[a-z0-9_]+$')
18
+ - restic_backup_postgres_passwords[item.name] | default('') | length > 0
19
+ fail_msg: "postgres dump {{ item.name }}: name must be [a-z0-9_], and restic_backup_postgres_passwords.{{ item.name }} set in secrets.yml"
20
+ quiet: true
21
+ loop: "{{ restic_backup.postgres | default([]) }}"
22
+ loop_control:
23
+ label: "{{ item.name }}"
24
+
25
+ # Traversable, and log/ readable: the operator follows a restore test over
26
+ # SSH as their own user (kayra backup test). Everything else stays root's.
27
+ - name: Create the backup directory
28
+ ansible.builtin.file:
29
+ path: "{{ restic_backup.dir }}"
30
+ state: directory
31
+ owner: root
32
+ group: root
33
+ mode: "0711"
34
+
35
+ - name: Create backup directories
36
+ ansible.builtin.file:
37
+ path: "{{ restic_backup.dir }}/{{ item }}"
38
+ state: directory
39
+ owner: root
40
+ group: root
41
+ mode: "{{ '0755' if item == 'log' else '0700' }}"
42
+ loop: [config, cron, dumps, cache, restore-test, ssh, log]
43
+
44
+ # With databases to dump, Postgres (pg_dump) with restic copied in; without,
45
+ # restic's own image.
46
+ - name: Copy compose file
47
+ ansible.builtin.copy:
48
+ src: "{{ 'docker-compose.postgres.yml' if restic_backup.postgres | default([]) else 'docker-compose.yml' }}"
49
+ dest: "{{ restic_backup.dir }}/docker-compose.yml"
50
+ mode: "0600"
51
+
52
+ - name: Render compose override
53
+ ansible.builtin.template:
54
+ src: docker-compose.override.yml.j2
55
+ dest: "{{ restic_backup.dir }}/docker-compose.override.yml"
56
+ mode: "0600"
57
+
58
+ - name: Render environment
59
+ ansible.builtin.template:
60
+ src: backup.env.j2
61
+ dest: "{{ restic_backup.dir }}/config/backup.env"
62
+ mode: "0600"
63
+ no_log: true
64
+
65
+ - name: Render scripts
66
+ ansible.builtin.template:
67
+ src: "{{ item }}.j2"
68
+ dest: "{{ restic_backup.dir }}/config/{{ item }}"
69
+ mode: "0700"
70
+ loop: [backup.sh, restore-test.sh]
71
+
72
+ # On the host, not in the container: the operator's SSH user runs it.
73
+ - name: Render the restore test's follower
74
+ ansible.builtin.template:
75
+ src: follow-restore-test.sh.j2
76
+ dest: "{{ restic_backup.dir }}/follow-restore-test.sh"
77
+ owner: root
78
+ group: root
79
+ mode: "0755"
80
+
81
+ # For an SFTP repository: the target's alias, its pinned host key, our key.
82
+ - name: Render SSH config
83
+ ansible.builtin.template:
84
+ src: ssh_config.j2
85
+ dest: "{{ restic_backup.dir }}/ssh/config"
86
+ mode: "0600"
87
+ when: restic_backup.ssh is defined
88
+
89
+ - name: Pin the target's host key
90
+ ansible.builtin.copy:
91
+ content: "{{ restic_backup.ssh.known_host }}\n"
92
+ dest: "{{ restic_backup.dir }}/ssh/known_hosts"
93
+ mode: "0600"
94
+ when: restic_backup.ssh is defined
95
+
96
+ - name: Install the backup's SSH key
97
+ ansible.builtin.copy:
98
+ content: "{{ restic_backup_ssh_key | trim }}\n"
99
+ dest: "{{ restic_backup.dir }}/ssh/id_ed25519"
100
+ mode: "0600"
101
+ when: restic_backup.ssh is defined
102
+ no_log: true
103
+
104
+ - name: Render exclude list
105
+ ansible.builtin.copy:
106
+ content: "{{ restic_backup.exclude | default([]) | join('\n') }}\n"
107
+ dest: "{{ restic_backup.dir }}/config/excludes.txt"
108
+ mode: "0600"
109
+
110
+ # Into cron/, which is mounted as a directory: a single-file bind mount would
111
+ # keep showing the container the file Ansible just replaced.
112
+ - name: Render crontab
113
+ ansible.builtin.template:
114
+ src: root.crontab.j2
115
+ dest: "{{ restic_backup.dir }}/cron/root"
116
+ mode: "0600"
117
+ notify: Restart backup
118
+
119
+ - name: Start backup stack
120
+ community.docker.docker_compose_v2:
121
+ project_src: "{{ restic_backup.dir }}"
@@ -0,0 +1,9 @@
1
+ # Generated by Ansible (restic-backup role). Sourced by the scripts: cron
2
+ # jobs do not inherit the container's environment.
3
+ export PATH=/opt/restic:/usr/local/bin:/usr/bin:/bin
4
+ export RESTIC_REPOSITORY={{ restic_backup.repository | quote }}
5
+ export RESTIC_PASSWORD={{ restic_backup_password | quote }}
6
+ export RESTIC_CACHE_DIR={{ (restic_backup.dir ~ '/cache') | quote }}
7
+ {% for db in restic_backup.postgres | default([]) %}
8
+ export PG_PASSWORD_{{ db.name | upper }}={{ restic_backup_postgres_passwords[db.name] | quote }}
9
+ {% endfor %}
@@ -0,0 +1,28 @@
1
+ #!/bin/sh
2
+ # Generated by Ansible (restic-backup role). Run by cron in the backup
3
+ # container: dump the databases, back everything up, and on Sundays forget
4
+ # and prune what restic_backup.keep no longer covers (where it is set; an
5
+ # append-only repository is pruned on the receiving side instead).
6
+ set -eu
7
+ . /etc/backup/backup.env
8
+
9
+ dumps='{{ restic_backup.dir }}/dumps'
10
+ {% for db in restic_backup.postgres | default([]) %}
11
+ PGPASSWORD="$PG_PASSWORD_{{ db.name | upper }}" pg_dump --format=custom \
12
+ --host={{ db.host }} --username={{ db.user }} --dbname={{ db.database }} \
13
+ --file="${dumps}/{{ db.name }}.dump.tmp"
14
+ # UGOS's ACLs override the umask: new files would be world-readable.
15
+ chmod 600 "${dumps}/{{ db.name }}.dump.tmp"
16
+ mv "${dumps}/{{ db.name }}.dump.tmp" "${dumps}/{{ db.name }}.dump"
17
+ {% endfor %}
18
+
19
+ restic cat config >/dev/null 2>&1 || restic init
20
+ restic backup --host '{{ inventory_hostname }}' --exclude-file /etc/backup/excludes.txt \
21
+ {{ restic_backup.paths | map('quote') | join(' ') }}
22
+ {% if restic_backup.keep is defined %}
23
+
24
+ if [ "$(date +%u)" = 7 ]; then
25
+ restic forget --prune --host '{{ inventory_hostname }}'{% for period, count in restic_backup.keep.items() %} --keep-{{ period }} {{ count }}{% endfor %}
26
+
27
+ fi
28
+ {% endif %}
@@ -0,0 +1,26 @@
1
+ # Generated by Ansible from restic_backup in servers/<server>/vars.yml.
2
+ {% set networks = restic_backup.postgres | default([]) | map(attribute='network') | unique | list %}
3
+ services:
4
+ backup:
5
+ volumes:
6
+ {% for path in restic_backup.paths %}
7
+ - {{ path }}:{{ path }}:ro
8
+ {% endfor %}
9
+ {% for dir in ['dumps', 'cache', 'restore-test', 'log'] %}
10
+ - {{ restic_backup.dir }}/{{ dir }}:{{ restic_backup.dir }}/{{ dir }}
11
+ {% endfor %}
12
+ {% if restic_backup.ssh is defined %}
13
+ - {{ restic_backup.dir }}/ssh:/root/.ssh:ro
14
+ {% endif %}
15
+ {% if networks %}
16
+ networks:
17
+ {% for network in networks %}
18
+ - {{ network }}
19
+ {% endfor %}
20
+
21
+ networks:
22
+ {% for network in networks %}
23
+ {{ network }}:
24
+ external: true
25
+ {% endfor %}
26
+ {% endif %}
@@ -0,0 +1,28 @@
1
+ #!/bin/sh
2
+ # Generated by Ansible (restic-backup role). Follows the restore test that is
3
+ # running, or about to: its log until it ends, then exits with its status.
4
+ # Runs on the host as the operator's SSH user (kayra backup test), so it reads
5
+ # only log/. Stopping it leaves the restore test running.
6
+ set -eu
7
+
8
+ log='{{ restic_backup.dir }}/log'
9
+ lock="${log}/restore-test.lock"
10
+
11
+ # A test that runs holds the lock, and removed the last one's status only
12
+ # after starting its log. It was just started, so wait for that.
13
+ waited=0
14
+ until [ -e "$lock" ] && ! flock -n "$lock" true && [ ! -e "${log}/restore-test.status" ]; do
15
+ if [ "$waited" -ge 30 ]; then
16
+ echo "No restore test started; its first lines go to the backup container's log." >&2
17
+ exit 1
18
+ fi
19
+ waited=$((waited + 1))
20
+ sleep 1
21
+ done
22
+
23
+ tail -n +1 -F "${log}/restore-test.log" &
24
+ follower=$!
25
+ flock "$lock" true
26
+ sleep 1
27
+ kill "$follower"
28
+ exit "$(cat "${log}/restore-test.status")"
@@ -0,0 +1,51 @@
1
+ #!/bin/sh
2
+ # Generated by Ansible (restic-backup role). Proves the latest snapshot comes
3
+ # back: checks the repository, restores it (or restic_backup.restore_test_paths
4
+ # of it) below restore-test/, and has
5
+ # pg_restore read every database dump. Deletes the restored copy afterwards.
6
+ # Started by cron (restore_test_schedule) or by kayra backup test, which
7
+ # follows its log with follow-restore-test.sh. One at a time.
8
+ set -eu
9
+ . /etc/backup/backup.env
10
+
11
+ log='{{ restic_backup.dir }}/log'
12
+ exec 9>"${log}/restore-test.lock"
13
+ if ! flock -n 9; then
14
+ echo "A restore test is already running: ${log}/restore-test.log" >&2
15
+ exit 75
16
+ fi
17
+ # The log first, then the old status: a follower waits for both to be fresh.
18
+ exec >"${log}/restore-test.log" 2>&1
19
+ rm -f "${log}/restore-test.status"
20
+ # restic leaves progress out without a terminal; every 10 s instead.
21
+ export RESTIC_PROGRESS_FPS=0.1
22
+
23
+ result=failed
24
+ finish() {
25
+ status=$?
26
+ echo "$status" > "${log}/restore-test.status"
27
+ {% if restic_backup.ssh is defined %}
28
+ # The result, next to the repository, for the backup-check role to read.
29
+ date -u '+%Y-%m-%dT%H:%M:%SZ' > "/tmp/restore-test.${result}"
30
+ echo "put /tmp/restore-test.${result} {{ restic_backup.repository.split(':', 2)[2] }}/restore-test.${result}" \
31
+ | sftp -q -b - backup-target
32
+ {% endif %}
33
+ }
34
+ trap finish EXIT
35
+
36
+ target='{{ restic_backup.dir }}/restore-test'
37
+ find "$target" -mindepth 1 -delete
38
+
39
+ # Waits out a backup's or prune's lock; reads a twelfth of the data, a
40
+ # different one each month, so all of it once a year.
41
+ restic check --retry-lock 1h --read-data-subset "$(date +%m | sed 's/^0//')/12"
42
+ restic restore latest --host '{{ inventory_hostname }}' --target "$target"{% for path in restic_backup.restore_test_paths | default([]) %} --include {{ path | quote }}{% endfor %}
43
+
44
+ {% for db in restic_backup.postgres | default([]) %}
45
+ pg_restore --list "${target}{{ restic_backup.dir }}/dumps/{{ db.name }}.dump" >/dev/null
46
+ echo "{{ db.name }}: dump readable"
47
+ {% endfor %}
48
+ echo "restored $(find "$target" -type f | wc -l) files, $(du -sh "$target" | cut -f1)"
49
+ find "$target" -mindepth 1 -delete
50
+ echo "restore test passed"
51
+ result=passed
@@ -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
+ ];