@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,292 @@
|
|
|
1
|
+
---
|
|
2
|
+
# =============================================================================
|
|
3
|
+
# forgejo-config role — what a Forgejo holds beyond its own config file, as
|
|
4
|
+
# code: users and their SSH keys, Actions runners, repos (created empty
|
|
5
|
+
# where missing) and their settings (issues, pull requests, actions), Actions
|
|
6
|
+
# secrets, collaborators, webhooks and push mirrors, from
|
|
7
|
+
# forgejo_config in the server's vars.yml. Adds what is missing through the
|
|
8
|
+
# API, adopts what already matches, never deletes. Talks to Forgejo on the
|
|
9
|
+
# server itself (forgejo_config.url) with forgejo_admin_token, and mints the
|
|
10
|
+
# tokens the platform's own services use there (forgejo_config.tokens).
|
|
11
|
+
# =============================================================================
|
|
12
|
+
|
|
13
|
+
- name: Wait for Forgejo's API
|
|
14
|
+
ansible.builtin.uri:
|
|
15
|
+
url: "{{ forgejo_config.url }}/api/v1/version"
|
|
16
|
+
register: forgejo_version
|
|
17
|
+
until: forgejo_version.status == 200
|
|
18
|
+
retries: 30
|
|
19
|
+
delay: 2
|
|
20
|
+
|
|
21
|
+
- name: Look up users
|
|
22
|
+
ansible.builtin.uri:
|
|
23
|
+
url: "{{ forgejo_config.url }}/api/v1/users/{{ item.name }}"
|
|
24
|
+
headers: {Authorization: "token {{ forgejo_admin_token }}"}
|
|
25
|
+
status_code: [200, 404]
|
|
26
|
+
register: forgejo_users
|
|
27
|
+
loop: "{{ forgejo_config.users | default([]) }}"
|
|
28
|
+
loop_control: {label: "{{ item.name }}"}
|
|
29
|
+
no_log: true
|
|
30
|
+
|
|
31
|
+
# Random password, never stored: these users work with tokens.
|
|
32
|
+
- name: Create missing users
|
|
33
|
+
ansible.builtin.uri:
|
|
34
|
+
url: "{{ forgejo_config.url }}/api/v1/admin/users"
|
|
35
|
+
method: POST
|
|
36
|
+
headers: {Authorization: "token {{ forgejo_admin_token }}"}
|
|
37
|
+
body_format: json
|
|
38
|
+
body:
|
|
39
|
+
username: "{{ item.name }}"
|
|
40
|
+
email: "{{ item.email }}"
|
|
41
|
+
password: "{{ lookup('ansible.builtin.password', '/dev/null', length=32) }}"
|
|
42
|
+
must_change_password: false
|
|
43
|
+
status_code: [201]
|
|
44
|
+
changed_when: true
|
|
45
|
+
loop: "{{ forgejo_users.results | selectattr('status', 'eq', 404) | map(attribute='item') | list }}"
|
|
46
|
+
loop_control: {label: "{{ item.name }}"}
|
|
47
|
+
no_log: true
|
|
48
|
+
|
|
49
|
+
- name: Look up SSH keys
|
|
50
|
+
ansible.builtin.uri:
|
|
51
|
+
url: "{{ forgejo_config.url }}/api/v1/users/{{ item.user }}/keys"
|
|
52
|
+
headers: {Authorization: "token {{ forgejo_admin_token }}"}
|
|
53
|
+
register: forgejo_keys
|
|
54
|
+
loop: "{{ forgejo_config.ssh_keys | default([]) }}"
|
|
55
|
+
loop_control: {label: "{{ item.user }} {{ item.title }}"}
|
|
56
|
+
no_log: true
|
|
57
|
+
|
|
58
|
+
# Matched by type and key, whatever title or comment either side carries.
|
|
59
|
+
- name: Add missing SSH keys
|
|
60
|
+
ansible.builtin.uri:
|
|
61
|
+
url: "{{ forgejo_config.url }}/api/v1/admin/users/{{ item.item.user }}/keys"
|
|
62
|
+
method: POST
|
|
63
|
+
headers: {Authorization: "token {{ forgejo_admin_token }}"}
|
|
64
|
+
body_format: json
|
|
65
|
+
body: {title: "{{ item.item.title }}", key: "{{ item.item.key }}", read_only: false}
|
|
66
|
+
status_code: [201]
|
|
67
|
+
changed_when: true
|
|
68
|
+
when: >-
|
|
69
|
+
(item.item.key.split()[:2] | join(' ')) not in
|
|
70
|
+
(item.json | map(attribute='key') | map('split') | map('batch', 2) | map('first') | map('join', ' ') | list)
|
|
71
|
+
loop: "{{ forgejo_keys.results }}"
|
|
72
|
+
loop_control: {label: "{{ item.item.user }} {{ item.item.title }}"}
|
|
73
|
+
no_log: true
|
|
74
|
+
|
|
75
|
+
# Idempotent with the same secret; the runner side registers itself with it.
|
|
76
|
+
# --keep-labels: the runner declares its labels when it starts, and a
|
|
77
|
+
# register without it replaces them with none, so no job finds a runner
|
|
78
|
+
# until the runner restarts.
|
|
79
|
+
- name: Register runners
|
|
80
|
+
ansible.builtin.command:
|
|
81
|
+
argv: [docker, exec, -i, "{{ forgejo_config.container }}", forgejo, forgejo-cli, actions, register,
|
|
82
|
+
--secret-file, /dev/stdin, --name, "{{ item.name }}", --scope, "{{ item.scope }}", --keep-labels]
|
|
83
|
+
stdin: "{{ forgejo_runner_secrets[item.name] }}"
|
|
84
|
+
# Exactly 40 characters: a trailing newline would be a 41st.
|
|
85
|
+
stdin_add_newline: false
|
|
86
|
+
changed_when: false
|
|
87
|
+
loop: "{{ forgejo_config.runners | default([]) }}"
|
|
88
|
+
loop_control: {label: "{{ item.name }}"}
|
|
89
|
+
no_log: true
|
|
90
|
+
|
|
91
|
+
- name: Look up repos
|
|
92
|
+
ansible.builtin.uri:
|
|
93
|
+
url: "{{ forgejo_config.url }}/api/v1/repos/{{ item.name }}"
|
|
94
|
+
headers: {Authorization: "token {{ forgejo_admin_token }}"}
|
|
95
|
+
status_code: [200, 404]
|
|
96
|
+
register: forgejo_repos
|
|
97
|
+
loop: "{{ forgejo_config.repos }}"
|
|
98
|
+
loop_control: {label: "{{ item.name }}"}
|
|
99
|
+
no_log: true
|
|
100
|
+
|
|
101
|
+
# Empty, and private from the start where its settings say so (the settings
|
|
102
|
+
# are applied below too, but a public repo, even for a moment, is published);
|
|
103
|
+
# the content is pushed by hand.
|
|
104
|
+
- name: Create missing repos
|
|
105
|
+
ansible.builtin.uri:
|
|
106
|
+
url: "{{ forgejo_config.url }}/api/v1/admin/users/{{ item.name.split('/')[0] }}/repos"
|
|
107
|
+
method: POST
|
|
108
|
+
headers: {Authorization: "token {{ forgejo_admin_token }}"}
|
|
109
|
+
body_format: json
|
|
110
|
+
body:
|
|
111
|
+
name: "{{ item.name.split('/')[1] }}"
|
|
112
|
+
private: "{{ item.settings.private | default(false) }}"
|
|
113
|
+
status_code: [201]
|
|
114
|
+
changed_when: true
|
|
115
|
+
loop: "{{ forgejo_repos.results | selectattr('status', 'eq', 404) | map(attribute='item') | list }}"
|
|
116
|
+
loop_control: {label: "{{ item.name }}"}
|
|
117
|
+
no_log: true
|
|
118
|
+
|
|
119
|
+
- name: Look up collaborator permissions
|
|
120
|
+
ansible.builtin.uri:
|
|
121
|
+
url: "{{ forgejo_config.url }}/api/v1/repos/{{ item.0.name }}/collaborators/{{ item.1.user }}/permission"
|
|
122
|
+
headers: {Authorization: "token {{ forgejo_admin_token }}"}
|
|
123
|
+
status_code: [200, 403, 404]
|
|
124
|
+
register: forgejo_permissions
|
|
125
|
+
loop: "{{ forgejo_config.repos | subelements('collaborators', skip_missing=True) }}"
|
|
126
|
+
loop_control: {label: "{{ item.0.name }} {{ item.1.user }}"}
|
|
127
|
+
no_log: true
|
|
128
|
+
|
|
129
|
+
- name: Grant collaborators their permission
|
|
130
|
+
ansible.builtin.uri:
|
|
131
|
+
url: "{{ forgejo_config.url }}/api/v1/repos/{{ item.item.0.name }}/collaborators/{{ item.item.1.user }}"
|
|
132
|
+
method: PUT
|
|
133
|
+
headers: {Authorization: "token {{ forgejo_admin_token }}"}
|
|
134
|
+
body_format: json
|
|
135
|
+
body: {permission: "{{ item.item.1.permission }}"}
|
|
136
|
+
status_code: [204]
|
|
137
|
+
changed_when: true
|
|
138
|
+
when: item.status != 200 or item.json.permission != item.item.1.permission
|
|
139
|
+
loop: "{{ forgejo_permissions.results }}"
|
|
140
|
+
loop_control: {label: "{{ item.item.0.name }} {{ item.item.1.user }}"}
|
|
141
|
+
no_log: true
|
|
142
|
+
|
|
143
|
+
# A converted mirror keeps issues and pull requests off.
|
|
144
|
+
- name: Apply repo settings
|
|
145
|
+
ansible.builtin.uri:
|
|
146
|
+
url: "{{ forgejo_config.url }}/api/v1/repos/{{ item.item.name }}"
|
|
147
|
+
method: PATCH
|
|
148
|
+
headers: {Authorization: "token {{ forgejo_admin_token }}"}
|
|
149
|
+
body_format: json
|
|
150
|
+
body: "{{ item.item.settings }}"
|
|
151
|
+
changed_when: true
|
|
152
|
+
# Differs if laying the wanted settings over the repo changes anything.
|
|
153
|
+
when: item.json | combine(item.item.settings) != item.json
|
|
154
|
+
loop: "{{ forgejo_repos.results | selectattr('item.settings', 'defined') | list }}"
|
|
155
|
+
loop_control: {label: "{{ item.item.name }}"}
|
|
156
|
+
no_log: true
|
|
157
|
+
|
|
158
|
+
# Write-only in Forgejo, so no comparing: the same value every run.
|
|
159
|
+
- name: Set Actions secrets
|
|
160
|
+
ansible.builtin.uri:
|
|
161
|
+
url: "{{ forgejo_config.url }}/api/v1/repos/{{ item.repo }}/actions/secrets/{{ item.name }}"
|
|
162
|
+
method: PUT
|
|
163
|
+
headers: {Authorization: "token {{ forgejo_admin_token }}"}
|
|
164
|
+
body_format: json
|
|
165
|
+
body: {data: "{{ item.value }}"}
|
|
166
|
+
status_code: [201, 204]
|
|
167
|
+
changed_when: false
|
|
168
|
+
loop: "{{ forgejo_action_secrets | default([]) }}"
|
|
169
|
+
loop_control: {label: "{{ item.repo }} {{ item.name }}"}
|
|
170
|
+
no_log: true
|
|
171
|
+
|
|
172
|
+
- name: Look up webhooks and push mirrors
|
|
173
|
+
ansible.builtin.uri:
|
|
174
|
+
url: "{{ forgejo_config.url }}/api/v1/repos/{{ item.0.name }}/{{ item.1 }}"
|
|
175
|
+
headers: {Authorization: "token {{ forgejo_admin_token }}"}
|
|
176
|
+
register: forgejo_existing
|
|
177
|
+
loop: "{{ forgejo_config.repos | product(['hooks', 'push_mirrors']) | list }}"
|
|
178
|
+
loop_control: {label: "{{ item.0.name }} {{ item.1 }}"}
|
|
179
|
+
no_log: true
|
|
180
|
+
|
|
181
|
+
# A push to any branch calls the URL (with notifyCommit: Jenkins polls).
|
|
182
|
+
- name: Add missing webhooks
|
|
183
|
+
ansible.builtin.uri:
|
|
184
|
+
url: "{{ forgejo_config.url }}/api/v1/repos/{{ item.0.name }}/hooks"
|
|
185
|
+
method: POST
|
|
186
|
+
headers: {Authorization: "token {{ forgejo_admin_token }}"}
|
|
187
|
+
body_format: json
|
|
188
|
+
body:
|
|
189
|
+
type: forgejo
|
|
190
|
+
active: true
|
|
191
|
+
events: [push]
|
|
192
|
+
branch_filter: "*"
|
|
193
|
+
config: {url: "{{ item.1 }}", content_type: json, http_method: post}
|
|
194
|
+
status_code: [201]
|
|
195
|
+
changed_when: true
|
|
196
|
+
when: >-
|
|
197
|
+
item.1 not in (forgejo_existing.results
|
|
198
|
+
| selectattr('item.0.name', 'eq', item.0.name) | selectattr('item.1', 'eq', 'hooks')
|
|
199
|
+
| first).json | map(attribute='config.url') | list
|
|
200
|
+
loop: "{{ forgejo_config.repos | subelements('webhooks', skip_missing=True) }}"
|
|
201
|
+
loop_control: {label: "{{ item.0.name }} {{ item.1 }}"}
|
|
202
|
+
no_log: true
|
|
203
|
+
|
|
204
|
+
- name: Add missing push mirrors
|
|
205
|
+
ansible.builtin.uri:
|
|
206
|
+
url: "{{ forgejo_config.url }}/api/v1/repos/{{ item.name }}/push_mirrors"
|
|
207
|
+
method: POST
|
|
208
|
+
headers: {Authorization: "token {{ forgejo_admin_token }}"}
|
|
209
|
+
body_format: json
|
|
210
|
+
body:
|
|
211
|
+
remote_address: "{{ item.push_mirror }}"
|
|
212
|
+
remote_username: "{{ forgejo_push_mirror_credentials.username }}"
|
|
213
|
+
remote_password: "{{ forgejo_push_mirror_credentials.password }}"
|
|
214
|
+
sync_on_commit: true
|
|
215
|
+
interval: 8h0m0s
|
|
216
|
+
status_code: [200, 201]
|
|
217
|
+
changed_when: true
|
|
218
|
+
register: forgejo_new_mirrors
|
|
219
|
+
when: >-
|
|
220
|
+
item.push_mirror is defined and item.push_mirror not in (forgejo_existing.results
|
|
221
|
+
| selectattr('item.0.name', 'eq', item.name) | selectattr('item.1', 'eq', 'push_mirrors')
|
|
222
|
+
| first).json | map(attribute='remote_address') | list
|
|
223
|
+
loop: "{{ forgejo_config.repos }}"
|
|
224
|
+
loop_control: {label: "{{ item.name }}"}
|
|
225
|
+
no_log: true
|
|
226
|
+
|
|
227
|
+
# A new push mirror would wait for the next push or its interval.
|
|
228
|
+
- name: Sync new push mirrors once
|
|
229
|
+
ansible.builtin.uri:
|
|
230
|
+
url: "{{ forgejo_config.url }}/api/v1/repos/{{ item.item.name }}/push_mirrors-sync"
|
|
231
|
+
method: POST
|
|
232
|
+
headers: {Authorization: "token {{ forgejo_admin_token }}"}
|
|
233
|
+
changed_when: true
|
|
234
|
+
when: item is changed
|
|
235
|
+
loop: "{{ forgejo_new_mirrors.results }}"
|
|
236
|
+
loop_control: {label: "{{ item.item.name }}"}
|
|
237
|
+
no_log: true
|
|
238
|
+
|
|
239
|
+
# Tokens for the platform's services on this server (Jenkins, Renovate), into
|
|
240
|
+
# the files the platform role would otherwise fill from the vault. Nobody ever
|
|
241
|
+
# sees one: kept only in that file, minted again when it is missing, wrong or
|
|
242
|
+
# revoked. Unique names, since the admin token cannot delete a user's tokens.
|
|
243
|
+
- name: Read service tokens
|
|
244
|
+
ansible.builtin.slurp:
|
|
245
|
+
src: "{{ platform_dir }}/platform-secrets/{{ item.secret }}"
|
|
246
|
+
register: service_token_files
|
|
247
|
+
loop: "{{ forgejo_config.tokens | default([]) }}"
|
|
248
|
+
loop_control: {label: "{{ item.secret }}"}
|
|
249
|
+
become: true
|
|
250
|
+
no_log: true
|
|
251
|
+
|
|
252
|
+
- name: Check service tokens
|
|
253
|
+
ansible.builtin.uri:
|
|
254
|
+
url: "{{ forgejo_config.url }}/api/v1/user"
|
|
255
|
+
headers: {Authorization: "token {{ item.content | b64decode | trim }}"}
|
|
256
|
+
status_code: [200, 400, 401, 403]
|
|
257
|
+
register: service_token_checks
|
|
258
|
+
loop: "{{ service_token_files.results }}"
|
|
259
|
+
loop_control: {label: "{{ item.item.secret }}"}
|
|
260
|
+
no_log: true
|
|
261
|
+
|
|
262
|
+
- name: Mint service tokens
|
|
263
|
+
ansible.builtin.command:
|
|
264
|
+
argv: [docker, exec, "{{ forgejo_config.container }}", forgejo, admin, user, generate-access-token,
|
|
265
|
+
--username, "{{ item.item.item.user }}",
|
|
266
|
+
--token-name, "{{ item.item.item.name }}-{{ now(utc=true, fmt='%Y%m%d%H%M') }}",
|
|
267
|
+
--scopes, "{{ (item.item.item.scopes + ['read:user']) | unique | join(',') }}", --raw]
|
|
268
|
+
register: service_tokens_minted
|
|
269
|
+
changed_when: true
|
|
270
|
+
when: item.status != 200 or item.json.login != item.item.item.user
|
|
271
|
+
loop: "{{ service_token_checks.results }}"
|
|
272
|
+
loop_control: {label: "{{ item.item.item.secret }}"}
|
|
273
|
+
no_log: true
|
|
274
|
+
|
|
275
|
+
- name: Store service tokens
|
|
276
|
+
ansible.builtin.copy:
|
|
277
|
+
content: "{{ item.stdout | trim }}"
|
|
278
|
+
dest: "{{ platform_dir }}/platform-secrets/{{ item.item.item.item.secret }}"
|
|
279
|
+
owner: "1000"
|
|
280
|
+
group: "1000"
|
|
281
|
+
mode: "0400"
|
|
282
|
+
when: item is changed
|
|
283
|
+
loop: "{{ service_tokens_minted.results }}"
|
|
284
|
+
loop_control: {label: "{{ item.item.item.item.secret }}"}
|
|
285
|
+
become: true
|
|
286
|
+
no_log: true
|
|
287
|
+
|
|
288
|
+
- name: Reload JCasC with the new tokens
|
|
289
|
+
ansible.builtin.command: "{{ platform_dir }}/bin/jenkins-reload"
|
|
290
|
+
when: service_tokens_minted is changed
|
|
291
|
+
become: true
|
|
292
|
+
changed_when: true
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
---
|
|
2
|
+
# =============================================================================
|
|
3
|
+
# Default variables for the platform role
|
|
4
|
+
# App-agnostic: only knows about infrastructure concerns
|
|
5
|
+
# =============================================================================
|
|
6
|
+
# The server-side contract apps rely on: they drop files into platform_dir and
|
|
7
|
+
# live under apps_dir/<name>. Changing either means changing every app.
|
|
8
|
+
platform_dir: /opt/platform
|
|
9
|
+
apps_dir: /opt/apps
|
|
10
|
+
# The ref of git_remote_url (kayra's repo) Jenkins loads the pipeline library
|
|
11
|
+
# and platform jobs from: the tag of the release the operator ran
|
|
12
|
+
# (kayra_version, passed by kayra), so the Mac and the server run the same
|
|
13
|
+
# kayra. Only a test server sets a branch here instead.
|
|
14
|
+
platform_ref: "v{{ kayra_version }}"
|
|
15
|
+
domain: localhost
|
|
16
|
+
# The engine's files (compose files, scripts) lie above the playbooks, wherever
|
|
17
|
+
# the fleet it serves is; the fleet's (servers/, .cache/) above the server's
|
|
18
|
+
# folder.
|
|
19
|
+
kayra_root: "{{ playbook_dir }}/../.."
|
|
20
|
+
fleet_root: "{{ server_dir }}/../.."
|
|
21
|
+
|
|
22
|
+
# Certificates by DNS-01 instead of HTTP-01, for a server the internet never
|
|
23
|
+
# reaches (target home): "hetzner" (the only provider so far), with
|
|
24
|
+
# acme_dns_token, a token of the Hetzner project holding the zone. Traefik then
|
|
25
|
+
# gets a certificate per hostname that way (each router names its resolver,
|
|
26
|
+
# so an entrypoint wildcard would go unused), and no mkcert cert is used.
|
|
27
|
+
acme_dns: ""
|
|
28
|
+
acme_dns_token: ""
|
|
29
|
+
|
|
30
|
+
# Jenkins
|
|
31
|
+
jenkins_admin_user: admin
|
|
32
|
+
jenkins_admin_password: admin
|
|
33
|
+
|
|
34
|
+
# Git
|
|
35
|
+
git_remote_url: ""
|
|
36
|
+
git_token: ""
|
|
37
|
+
|
|
38
|
+
# owner/repo of the repos this server's platform-renovate keeps up to date
|
|
39
|
+
# (weekly version-bump PRs); the job exists only where the list is not empty.
|
|
40
|
+
# renovate_token is a PAT reaching each of them: Contents, Pull requests and
|
|
41
|
+
# Issues (the dependency dashboard) read & write. On a server whose own
|
|
42
|
+
# Forgejo hosts them, forgejo_config.tokens mints it (and git_token) instead.
|
|
43
|
+
renovate_repositories: []
|
|
44
|
+
renovate_token: ""
|
|
45
|
+
# When platform-renovate runs, as a Jenkins cron spec: weekly suits other
|
|
46
|
+
# people's releases; a server whose repos carry the operator's own images
|
|
47
|
+
# runs it more often, so a new build reaches a PR soon after it is pushed.
|
|
48
|
+
renovate_schedule: "H 4 * * 1"
|
|
49
|
+
# Where those repos live: github, or forgejo with renovate_endpoint, its API
|
|
50
|
+
# on platform-internal (http://forgejo:3000/api/v1). With a Forgejo, also
|
|
51
|
+
# renovate_git_url: endpoint, so Renovate clones over that address and not the
|
|
52
|
+
# public one the API reports, and a renovate_host_rules entry allowing it as
|
|
53
|
+
# an internal host.
|
|
54
|
+
renovate_platform: github
|
|
55
|
+
renovate_endpoint: ""
|
|
56
|
+
renovate_git_url: ""
|
|
57
|
+
renovate_host_rules: []
|
|
58
|
+
# Off GitHub, Renovate still looks versions up there (github-releases,
|
|
59
|
+
# github-tags) and refuses to without a token: a fine-grained one with
|
|
60
|
+
# public-repository read access only, no other permissions.
|
|
61
|
+
renovate_github_com_token: ""
|
|
62
|
+
|
|
63
|
+
# --- Authelia ---------------------------------------------------------------
|
|
64
|
+
# Secrets are per server and belong in servers/<server>/vars.yml (vault for prod).
|
|
65
|
+
authelia_session_secret: ""
|
|
66
|
+
authelia_storage_encryption_key: ""
|
|
67
|
+
# Leave the address empty to fall the notifier back to a file, which is what
|
|
68
|
+
# dev does. Passkey enrolment needs real SMTP; see the runbook.
|
|
69
|
+
authelia_smtp_address: ""
|
|
70
|
+
authelia_smtp_username: ""
|
|
71
|
+
authelia_smtp_sender: ""
|
|
72
|
+
authelia_smtp_password: ""
|
|
73
|
+
authelia_users: {}
|
|
74
|
+
|
|
75
|
+
# two_factor or one_factor. Lower it per environment, never here.
|
|
76
|
+
authelia_policy: "two_factor"
|
|
@@ -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 %}
|