@yunuseon/kayra 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +80 -2
  3. package/ansible/.ansible-lint +13 -0
  4. package/ansible/ansible.cfg +12 -0
  5. package/ansible/playbooks/adopt.yml +35 -0
  6. package/ansible/playbooks/app.yml +21 -0
  7. package/ansible/playbooks/apps-prune.yml +30 -0
  8. package/ansible/playbooks/edge.yml +41 -0
  9. package/ansible/playbooks/nas.yml +47 -0
  10. package/ansible/playbooks/platform.yml +62 -0
  11. package/ansible/requirements.yml +7 -0
  12. package/ansible/roles/app/defaults/main.yml +9 -0
  13. package/ansible/roles/app/tasks/main.yml +107 -0
  14. package/ansible/roles/app/templates/app.env.j2 +9 -0
  15. package/ansible/roles/app/templates/casc-app.yaml.j2 +68 -0
  16. package/ansible/roles/backup-check/files/backup-check.service +9 -0
  17. package/ansible/roles/backup-check/files/backup-check.timer +10 -0
  18. package/ansible/roles/backup-check/tasks/main.yml +59 -0
  19. package/ansible/roles/backup-check/templates/backup-check.sh.j2 +117 -0
  20. package/ansible/roles/common/handlers/main.yml +16 -0
  21. package/ansible/roles/common/tasks/main.yml +100 -0
  22. package/ansible/roles/compose-stacks/tasks/main.yml +74 -0
  23. package/ansible/roles/docker/defaults/main.yml +3 -0
  24. package/ansible/roles/docker/files/buildkit-rootless +23 -0
  25. package/ansible/roles/docker/handlers/main.yml +12 -0
  26. package/ansible/roles/docker/tasks/main.yml +82 -0
  27. package/ansible/roles/edge-proxy/files/docker-compose.yml +13 -0
  28. package/ansible/roles/edge-proxy/handlers/main.yml +6 -0
  29. package/ansible/roles/edge-proxy/tasks/main.yml +34 -0
  30. package/ansible/roles/edge-proxy/templates/Caddyfile.j2 +25 -0
  31. package/ansible/roles/forgejo-config/tasks/main.yml +292 -0
  32. package/ansible/roles/platform/defaults/main.yml +76 -0
  33. package/ansible/roles/platform/tasks/main.yml +298 -0
  34. package/ansible/roles/platform/templates/authelia-users.yml.j2 +17 -0
  35. package/ansible/roles/platform/templates/platform.env.j2 +41 -0
  36. package/ansible/roles/restic-backup/files/docker-compose.postgres.yml +27 -0
  37. package/ansible/roles/restic-backup/files/docker-compose.yml +12 -0
  38. package/ansible/roles/restic-backup/handlers/main.yml +7 -0
  39. package/ansible/roles/restic-backup/tasks/main.yml +121 -0
  40. package/ansible/roles/restic-backup/templates/backup.env.j2 +9 -0
  41. package/ansible/roles/restic-backup/templates/backup.sh.j2 +28 -0
  42. package/ansible/roles/restic-backup/templates/docker-compose.override.yml.j2 +26 -0
  43. package/ansible/roles/restic-backup/templates/follow-restore-test.sh.j2 +28 -0
  44. package/ansible/roles/restic-backup/templates/restore-test.sh.j2 +51 -0
  45. package/ansible/roles/restic-backup/templates/root.crontab.j2 +6 -0
  46. package/ansible/roles/restic-backup/templates/ssh_config.j2 +12 -0
  47. package/ansible/roles/wireguard/handlers/main.yml +9 -0
  48. package/ansible/roles/wireguard/tasks/main.yml +33 -0
  49. package/ansible/roles/wireguard/templates/wg0.conf.j2 +12 -0
  50. package/bin/kayra.js +29 -0
  51. package/cli/dist/brand.js +41 -0
  52. package/cli/dist/cli.js +21 -0
  53. package/cli/dist/commands/doctor.js +79 -0
  54. package/cli/dist/commands/server.js +215 -0
  55. package/cli/dist/commands/status.js +43 -0
  56. package/cli/dist/fleet.js +134 -0
  57. package/cli/dist/interpret.js +98 -0
  58. package/cli/dist/load.js +49 -0
  59. package/cli/dist/main.js +94 -0
  60. package/cli/dist/plan.js +31 -0
  61. package/cli/dist/result.js +5 -0
  62. package/cloud-init.yml.tpl +14 -0
  63. package/compose/authelia/configuration.yml +138 -0
  64. package/compose/buildkit/buildkitd.toml +14 -0
  65. package/compose/docker-compose.acme-dns.yml +12 -0
  66. package/compose/docker-compose.ci.yml +156 -0
  67. package/compose/docker-compose.yml +83 -0
  68. package/compose/jenkins/Dockerfile +40 -0
  69. package/compose/jenkins/casc/00-platform.yaml +158 -0
  70. package/compose/jenkins/plugins.txt +97 -0
  71. package/compose/jenkins-agent/Dockerfile +24 -0
  72. package/compose/traefik/dynamic/authelia.yml +18 -0
  73. package/compose/traefik/dynamic/middlewares.yml +14 -0
  74. package/compose/traefik/dynamic/ratelimit.yml +21 -0
  75. package/compose/traefik/dynamic/tls.yml +9 -0
  76. package/compose/traefik/traefik.yml +46 -0
  77. package/hosts.conf +6 -0
  78. package/package.json +73 -4
  79. package/scripts/local/app-manifests.sh +26 -0
  80. package/scripts/local/authelia-dev-init.sh +58 -0
  81. package/scripts/local/certs.sh +55 -0
  82. package/scripts/local/home-image.sh +55 -0
  83. package/scripts/local/home-reach.sh +41 -0
  84. package/scripts/local/require-main.sh +38 -0
  85. package/scripts/local/setup-hosts.sh +30 -0
  86. package/scripts/server/app-purge +26 -0
  87. package/scripts/server/app-remove +30 -0
  88. package/scripts/server/jenkins-api +28 -0
  89. package/scripts/server/jenkins-app-apply +36 -0
  90. package/scripts/server/jenkins-reload +12 -0
  91. package/terraform/modules/hcloud-firewall/main.tf +54 -0
  92. package/terraform/modules/hcloud-firewall/outputs.tf +9 -0
  93. package/terraform/modules/hcloud-firewall/variables.tf +22 -0
  94. package/terraform/modules/hcloud-server/main.tf +62 -0
  95. package/terraform/modules/hcloud-server/outputs.tf +19 -0
  96. package/terraform/modules/hcloud-server/templates/inventory.ini.tpl +5 -0
  97. package/terraform/modules/hcloud-server/variables.tf +50 -0
  98. package/terraform/modules/multipass-instance/main.tf +67 -0
  99. package/terraform/modules/multipass-instance/outputs.tf +19 -0
  100. package/terraform/modules/multipass-instance/templates/inventory.ini.tpl +5 -0
  101. package/terraform/modules/multipass-instance/variables.tf +39 -0
@@ -0,0 +1,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 %}