@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,298 @@
1
+ ---
2
+ # =============================================================================
3
+ # Platform role — Traefik, Authelia, Registry, BuildKit and Jenkins as compose
4
+ # project "platform" in {{ platform_dir }}. App-agnostic.
5
+ #
6
+ # {{ platform_dir }}/.env sets COMPOSE_FILE, so on the server every compose
7
+ # command is just `docker compose ...` run in {{ platform_dir }}.
8
+ # =============================================================================
9
+
10
+ - name: Check Renovate has a token
11
+ ansible.builtin.assert:
12
+ that: renovate_token | length > 0 or 'RENOVATE_TOKEN' in (forgejo_config.tokens | default([]) | map(attribute='secret'))
13
+ fail_msg: renovate_repositories is set, so renovate_token must be set in servers/{{ server }}/secrets.yml
14
+ when: renovate_repositories | length > 0
15
+
16
+ - name: Create platform directories
17
+ ansible.builtin.file:
18
+ path: "{{ item }}"
19
+ state: directory
20
+ owner: deploy
21
+ group: deploy
22
+ mode: "0755"
23
+ loop:
24
+ - "{{ apps_dir }}"
25
+ - "{{ platform_dir }}"
26
+ - "{{ platform_dir }}/traefik/dynamic"
27
+ - "{{ platform_dir }}/traefik/certs"
28
+ - "{{ platform_dir }}/authelia/secrets"
29
+ become: true
30
+
31
+ # Written by bin/jenkins-app-apply as root and chowned per app to the
32
+ # controller's uid, so the directory itself stays root's.
33
+ - name: Create Jenkins app secrets directory
34
+ ansible.builtin.file:
35
+ path: "{{ platform_dir }}/jenkins-secrets"
36
+ state: directory
37
+ owner: root
38
+ group: root
39
+ mode: "0755"
40
+ become: true
41
+
42
+ # buildkit/ matters more than it looks: without buildkitd.toml the bind mount
43
+ # has no source, Docker creates a directory in its place, and buildkitd
44
+ # crash-loops until the container is recreated.
45
+ - name: Copy platform files
46
+ ansible.builtin.copy:
47
+ src: "{{ kayra_root }}/compose/{{ item }}"
48
+ dest: "{{ platform_dir }}/{{ item }}"
49
+ owner: deploy
50
+ group: deploy
51
+ mode: "0644"
52
+ loop:
53
+ - docker-compose.yml
54
+ - docker-compose.ci.yml
55
+ - docker-compose.acme-dns.yml
56
+ - traefik/dynamic/
57
+ - authelia/configuration.yml
58
+ - jenkins/
59
+ - jenkins-agent/
60
+ - buildkit/
61
+ become: true
62
+ register: platform_files
63
+
64
+ # Apps register with Jenkins through these, never by touching the controller.
65
+ - name: Install platform scripts
66
+ ansible.builtin.copy:
67
+ src: "{{ kayra_root }}/scripts/server/"
68
+ dest: "{{ platform_dir }}/bin/"
69
+ owner: root
70
+ group: root
71
+ mode: "0755"
72
+ become: true
73
+
74
+ - name: Render Traefik static config
75
+ ansible.builtin.copy:
76
+ content: "{{ (traefik_static_dns if acme_dns else traefik_static) ~ '\n' }}"
77
+ dest: "{{ platform_dir }}/traefik/traefik.yml"
78
+ owner: deploy
79
+ group: deploy
80
+ mode: "0644"
81
+ vars:
82
+ # Traefik does not expand env vars in its static file.
83
+ traefik_static: "{{ lookup('file', kayra_root ~ '/compose/traefik/traefik.yml') | regex_replace('(?m)^(\\s+email:).*$', '\\1 \"' ~ acme_email ~ '\"') }}"
84
+ # DNS-01: the challenge instead of HTTP-01, checked with public resolvers
85
+ # (a home server's own DNS may answer its zone itself), a minute after the
86
+ # record is set: Hetzner's three nameservers take that long to agree, and
87
+ # Let's Encrypt asks more than one.
88
+ traefik_static_dns: >-
89
+ {{ traefik_static | from_yaml
90
+ | combine({'certificatesResolvers': {'le': {'acme': {
91
+ 'email': acme_email, 'storage': '/acme/acme.json',
92
+ 'dnsChallenge': {'provider': acme_dns, 'resolvers': ['8.8.8.8:53', '9.9.9.9:53'],
93
+ 'propagation': {'delayBeforeChecks': '60s'}}}}}})
94
+ | to_nice_yaml(indent=2) }}
95
+ become: true
96
+ register: traefik_static_config
97
+
98
+ # Vault values rendered to files rather than .env lines, which Authelia reads
99
+ # via *_FILE. Authelia never writes the user database back — password change
100
+ # and reset are disabled — so rendering it every run is safe.
101
+ - name: Render Authelia user database
102
+ ansible.builtin.template:
103
+ src: authelia-users.yml.j2
104
+ dest: "{{ platform_dir }}/authelia/users_database.yml"
105
+ owner: deploy
106
+ group: deploy
107
+ mode: "0600"
108
+ become: true
109
+
110
+ - name: Render Authelia secrets
111
+ ansible.builtin.copy:
112
+ content: "{{ item.value }}"
113
+ dest: "{{ platform_dir }}/authelia/secrets/{{ item.name }}"
114
+ owner: deploy
115
+ group: deploy
116
+ mode: "0600"
117
+ loop:
118
+ - { name: session, value: "{{ authelia_session_secret }}" }
119
+ - { name: storage_encryption, value: "{{ authelia_storage_encryption_key }}" }
120
+ - { name: smtp_password, value: "{{ authelia_smtp_password }}" }
121
+ loop_control:
122
+ label: "{{ item.name }}"
123
+ when: item.name != 'smtp_password' or authelia_smtp_address | length > 0
124
+ no_log: true
125
+ become: true
126
+
127
+ # Servers not on the internet (multipass, home) without acme_dns. Elsewhere
128
+ # Traefik's ACME resolver issues the certs, and it skips ACME for any hostname
129
+ # a file-provided cert already covers — a stray mkcert cert would be served
130
+ # as-is, hence removed below.
131
+ - name: Copy mkcert TLS cert + key
132
+ ansible.builtin.copy:
133
+ src: "{{ fleet_root }}/.cache/certs/{{ item.name }}"
134
+ dest: "{{ platform_dir }}/traefik/certs/{{ item.name }}"
135
+ owner: deploy
136
+ group: deploy
137
+ mode: "{{ item.mode }}"
138
+ loop:
139
+ - { name: cert.pem, mode: "0644" }
140
+ - { name: key.pem, mode: "0600" }
141
+ when: target != 'hetzner' and not acme_dns
142
+ become: true
143
+
144
+ - name: Remove a mkcert cert where DNS-01 issues them
145
+ ansible.builtin.file:
146
+ path: "{{ platform_dir }}/traefik/certs/{{ item }}"
147
+ state: absent
148
+ loop: [cert.pem, key.pem]
149
+ when: acme_dns | length > 0
150
+ become: true
151
+
152
+ # Read by lego through HETZNER_API_TOKEN_FILE (docker-compose.acme-dns.yml).
153
+ - name: Create the DNS-01 token directory
154
+ ansible.builtin.file:
155
+ path: "{{ platform_dir }}/traefik/secrets"
156
+ state: directory
157
+ owner: root
158
+ group: root
159
+ mode: "0700"
160
+ when: acme_dns | length > 0
161
+ become: true
162
+
163
+ - name: Render the DNS-01 token
164
+ ansible.builtin.copy:
165
+ content: "{{ acme_dns_token }}"
166
+ dest: "{{ platform_dir }}/traefik/secrets/acme-dns-token"
167
+ owner: root
168
+ group: root
169
+ mode: "0600"
170
+ when: acme_dns | length > 0
171
+ no_log: true
172
+ become: true
173
+
174
+ - name: Look up the docker group
175
+ ansible.builtin.getent:
176
+ database: group
177
+ key: docker
178
+
179
+ # Read by the controller as uid 1000. An empty var leaves the file as it is:
180
+ # on a server whose Forgejo mints them (forgejo_config.tokens), that role
181
+ # writes them.
182
+ - name: Create platform secrets directory
183
+ ansible.builtin.file:
184
+ path: "{{ platform_dir }}/platform-secrets"
185
+ state: directory
186
+ owner: "1000"
187
+ group: "1000"
188
+ mode: "0500"
189
+ become: true
190
+
191
+ - name: Render platform tokens
192
+ ansible.builtin.copy:
193
+ content: "{{ item.value }}"
194
+ dest: "{{ platform_dir }}/platform-secrets/{{ item.name }}"
195
+ owner: "1000"
196
+ group: "1000"
197
+ mode: "0400"
198
+ force: "{{ item.value | length > 0 }}"
199
+ loop:
200
+ - {name: GIT_TOKEN, value: "{{ git_token }}"}
201
+ - {name: RENOVATE_TOKEN, value: "{{ renovate_token }}"}
202
+ loop_control:
203
+ label: "{{ item.name }}"
204
+ no_log: true
205
+ become: true
206
+ register: platform_tokens
207
+
208
+ - name: Render platform .env
209
+ ansible.builtin.template:
210
+ src: platform.env.j2
211
+ dest: "{{ platform_dir }}/.env"
212
+ owner: deploy
213
+ group: deploy
214
+ mode: "0600"
215
+ become: true
216
+
217
+ # The registry used to have a public, basic-auth router. Nothing outside the
218
+ # server pushes or pulls, so it is gone; the copy above never deletes files.
219
+ - name: Remove the retired registry basic auth
220
+ ansible.builtin.file:
221
+ path: "{{ platform_dir }}/traefik/dynamic/{{ item }}"
222
+ state: absent
223
+ loop: [registry-auth.yml, registry-htpasswd]
224
+ become: true
225
+
226
+ # ---------------------------------------------------------------------------
227
+ # Start. `up` recreates any container whose interpolated config changed, so
228
+ # a changed .env needs nothing extra.
229
+ # ---------------------------------------------------------------------------
230
+ - name: Pull platform images
231
+ community.docker.docker_compose_v2_pull:
232
+ project_src: "{{ platform_dir }}"
233
+ ignore_buildable: true
234
+
235
+ - name: Start platform
236
+ community.docker.docker_compose_v2:
237
+ project_src: "{{ platform_dir }}"
238
+ build: always
239
+
240
+ # Only dynamic/ is watched; the static file is read once at startup.
241
+ - name: Restart Traefik when its static config changed
242
+ ansible.builtin.command: docker compose restart traefik
243
+ args:
244
+ chdir: "{{ platform_dir }}"
245
+ when: traefik_static_config is changed
246
+ changed_when: true
247
+
248
+ # Inside the controller, not through Traefik: jenkins.<domain> sits behind
249
+ # Authelia, which would answer every call here with a login redirect.
250
+ - name: Wait for Jenkins
251
+ ansible.builtin.command: docker compose exec -T jenkins-controller curl -sf -o /dev/null http://127.0.0.1:8080/login
252
+ args:
253
+ chdir: "{{ platform_dir }}"
254
+ register: jenkins_health
255
+ retries: 30
256
+ delay: 10
257
+ until: jenkins_health.rc == 0
258
+ changed_when: false
259
+
260
+ # casc/ is a bind mount, so a changed 00-platform.yaml only takes effect on
261
+ # a reload; so do new tokens, read from files by ${readFile:…}.
262
+ - name: Reload JCasC
263
+ ansible.builtin.command: "{{ platform_dir }}/bin/jenkins-reload"
264
+ when: >-
265
+ platform_files.results | selectattr('item', 'equalto', 'jenkins/') | selectattr('changed') | list | length > 0
266
+ or platform_tokens is changed
267
+ become: true
268
+ changed_when: true
269
+
270
+ # ---------------------------------------------------------------------------
271
+ # Agent secret. Fetched every run and kept in its own file: it is derived
272
+ # from Jenkins' own state, so it has no var source, and re-rendering .env must
273
+ # never be able to blank it. Refetching also heals a reset jenkins_home.
274
+ # ---------------------------------------------------------------------------
275
+ - name: Fetch Jenkins agent secret
276
+ ansible.builtin.command: "{{ platform_dir }}/bin/jenkins-api GET /computer/builder/jenkins-agent.jnlp"
277
+ register: agent_jnlp
278
+ no_log: true
279
+ changed_when: false
280
+ check_mode: false
281
+
282
+ - name: Write Jenkins agent secret
283
+ ansible.builtin.copy:
284
+ content: "JENKINS_SECRET={{ agent_jnlp.stdout | regex_findall('<argument>([a-f0-9]{64})</argument>') | first }}\n"
285
+ dest: "{{ platform_dir }}/jenkins-agent.env"
286
+ owner: deploy
287
+ group: deploy
288
+ mode: "0600"
289
+ no_log: true
290
+ become: true
291
+ register: agent_secret
292
+
293
+ - name: Recreate Jenkins agent with its secret
294
+ ansible.builtin.command: docker compose up -d --no-deps --force-recreate jenkins-agent
295
+ args:
296
+ chdir: "{{ platform_dir }}"
297
+ when: agent_secret is changed
298
+ changed_when: true
@@ -0,0 +1,17 @@
1
+ # =============================================================================
2
+ # Authelia user database — rendered by Ansible from authelia_users.
3
+ # Do not edit on the server: this file is regenerated on every platform run.
4
+ #
5
+ # Password hashes are argon2id, generated with:
6
+ # docker run --rm authelia/authelia:4.39 authelia crypto hash generate argon2 \
7
+ # --variant argon2id --iterations 3 --memory 32768 --parallelism 2
8
+ # =============================================================================
9
+ users:
10
+ {% for username, user in authelia_users.items() %}
11
+ {{ username }}:
12
+ disabled: {{ user.disabled | default(false) | lower }}
13
+ displayname: '{{ user.displayname }}'
14
+ password: '{{ user.password }}'
15
+ email: '{{ user.email }}'
16
+ groups: [{% for group in user.groups %}'{{ group }}'{% if not loop.last %}, {% endif %}{% endfor %}]
17
+ {% endfor %}
@@ -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