@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.
- package/LICENSE +21 -0
- package/README.md +80 -2
- package/ansible/.ansible-lint +13 -0
- package/ansible/ansible.cfg +12 -0
- package/ansible/playbooks/adopt.yml +35 -0
- package/ansible/playbooks/app.yml +21 -0
- package/ansible/playbooks/apps-prune.yml +30 -0
- package/ansible/playbooks/edge.yml +41 -0
- package/ansible/playbooks/nas.yml +47 -0
- package/ansible/playbooks/platform.yml +62 -0
- package/ansible/requirements.yml +7 -0
- package/ansible/roles/app/defaults/main.yml +9 -0
- package/ansible/roles/app/tasks/main.yml +107 -0
- package/ansible/roles/app/templates/app.env.j2 +9 -0
- package/ansible/roles/app/templates/casc-app.yaml.j2 +68 -0
- package/ansible/roles/backup-check/files/backup-check.service +9 -0
- package/ansible/roles/backup-check/files/backup-check.timer +10 -0
- package/ansible/roles/backup-check/tasks/main.yml +59 -0
- package/ansible/roles/backup-check/templates/backup-check.sh.j2 +117 -0
- package/ansible/roles/common/handlers/main.yml +16 -0
- package/ansible/roles/common/tasks/main.yml +100 -0
- package/ansible/roles/compose-stacks/tasks/main.yml +74 -0
- package/ansible/roles/docker/defaults/main.yml +3 -0
- package/ansible/roles/docker/files/buildkit-rootless +23 -0
- package/ansible/roles/docker/handlers/main.yml +12 -0
- package/ansible/roles/docker/tasks/main.yml +82 -0
- package/ansible/roles/edge-proxy/files/docker-compose.yml +13 -0
- package/ansible/roles/edge-proxy/handlers/main.yml +6 -0
- package/ansible/roles/edge-proxy/tasks/main.yml +34 -0
- package/ansible/roles/edge-proxy/templates/Caddyfile.j2 +25 -0
- package/ansible/roles/forgejo-config/tasks/main.yml +292 -0
- package/ansible/roles/platform/defaults/main.yml +76 -0
- package/ansible/roles/platform/tasks/main.yml +298 -0
- package/ansible/roles/platform/templates/authelia-users.yml.j2 +17 -0
- package/ansible/roles/platform/templates/platform.env.j2 +41 -0
- package/ansible/roles/restic-backup/files/docker-compose.postgres.yml +27 -0
- package/ansible/roles/restic-backup/files/docker-compose.yml +12 -0
- package/ansible/roles/restic-backup/handlers/main.yml +7 -0
- package/ansible/roles/restic-backup/tasks/main.yml +121 -0
- package/ansible/roles/restic-backup/templates/backup.env.j2 +9 -0
- package/ansible/roles/restic-backup/templates/backup.sh.j2 +28 -0
- package/ansible/roles/restic-backup/templates/docker-compose.override.yml.j2 +26 -0
- package/ansible/roles/restic-backup/templates/follow-restore-test.sh.j2 +28 -0
- package/ansible/roles/restic-backup/templates/restore-test.sh.j2 +51 -0
- package/ansible/roles/restic-backup/templates/root.crontab.j2 +6 -0
- package/ansible/roles/restic-backup/templates/ssh_config.j2 +12 -0
- package/ansible/roles/wireguard/handlers/main.yml +9 -0
- package/ansible/roles/wireguard/tasks/main.yml +33 -0
- package/ansible/roles/wireguard/templates/wg0.conf.j2 +12 -0
- package/bin/kayra.js +29 -0
- package/cli/dist/brand.js +41 -0
- package/cli/dist/cli.js +21 -0
- package/cli/dist/commands/doctor.js +79 -0
- package/cli/dist/commands/server.js +215 -0
- package/cli/dist/commands/status.js +43 -0
- package/cli/dist/fleet.js +134 -0
- package/cli/dist/interpret.js +98 -0
- package/cli/dist/load.js +49 -0
- package/cli/dist/main.js +94 -0
- package/cli/dist/plan.js +31 -0
- package/cli/dist/result.js +5 -0
- package/cloud-init.yml.tpl +14 -0
- package/compose/authelia/configuration.yml +138 -0
- package/compose/buildkit/buildkitd.toml +14 -0
- package/compose/docker-compose.acme-dns.yml +12 -0
- package/compose/docker-compose.ci.yml +156 -0
- package/compose/docker-compose.yml +83 -0
- package/compose/jenkins/Dockerfile +40 -0
- package/compose/jenkins/casc/00-platform.yaml +158 -0
- package/compose/jenkins/plugins.txt +97 -0
- package/compose/jenkins-agent/Dockerfile +24 -0
- package/compose/traefik/dynamic/authelia.yml +18 -0
- package/compose/traefik/dynamic/middlewares.yml +14 -0
- package/compose/traefik/dynamic/ratelimit.yml +21 -0
- package/compose/traefik/dynamic/tls.yml +9 -0
- package/compose/traefik/traefik.yml +46 -0
- package/hosts.conf +6 -0
- package/package.json +73 -4
- package/scripts/local/app-manifests.sh +26 -0
- package/scripts/local/authelia-dev-init.sh +58 -0
- package/scripts/local/certs.sh +55 -0
- package/scripts/local/home-image.sh +55 -0
- package/scripts/local/home-reach.sh +41 -0
- package/scripts/local/require-main.sh +38 -0
- package/scripts/local/setup-hosts.sh +30 -0
- package/scripts/server/app-purge +26 -0
- package/scripts/server/app-remove +30 -0
- package/scripts/server/jenkins-api +28 -0
- package/scripts/server/jenkins-app-apply +36 -0
- package/scripts/server/jenkins-reload +12 -0
- package/terraform/modules/hcloud-firewall/main.tf +54 -0
- package/terraform/modules/hcloud-firewall/outputs.tf +9 -0
- package/terraform/modules/hcloud-firewall/variables.tf +22 -0
- package/terraform/modules/hcloud-server/main.tf +62 -0
- package/terraform/modules/hcloud-server/outputs.tf +19 -0
- package/terraform/modules/hcloud-server/templates/inventory.ini.tpl +5 -0
- package/terraform/modules/hcloud-server/variables.tf +50 -0
- package/terraform/modules/multipass-instance/main.tf +67 -0
- package/terraform/modules/multipass-instance/outputs.tf +19 -0
- package/terraform/modules/multipass-instance/templates/inventory.ini.tpl +5 -0
- package/terraform/modules/multipass-instance/variables.tf +39 -0
|
@@ -0,0 +1,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,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,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");
|
package/cli/dist/cli.js
ADDED
|
@@ -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
|
+
];
|