@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.
- package/LICENSE +21 -0
- package/README.md +81 -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 +102 -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 +333 -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,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,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
|