@yunuseon/kayra 0.7.1 → 0.8.1

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 (58) hide show
  1. package/README.md +5 -5
  2. package/ansible/playbooks/apps-prune.yml +6 -6
  3. package/ansible/playbooks/platform.yml +5 -5
  4. package/ansible/roles/app/tasks/main.yml +26 -14
  5. package/ansible/roles/app/templates/app.env.j2 +1 -1
  6. package/ansible/roles/common/tasks/main.yml +1 -1
  7. package/ansible/roles/forgejo-config/tasks/main.yml +4 -9
  8. package/ansible/roles/platform/defaults/main.yml +8 -15
  9. package/ansible/roles/platform/tasks/main.yml +96 -63
  10. package/ansible/roles/platform/templates/platform.env.j2 +3 -11
  11. package/cli/dist/brand.js +0 -3
  12. package/cli/dist/cli.js +10 -1
  13. package/cli/dist/commands/server.js +16 -2
  14. package/cli/dist/deployer/app.js +47 -0
  15. package/cli/dist/deployer/brand.js +8 -0
  16. package/cli/dist/deployer/builder.js +196 -0
  17. package/cli/dist/deployer/bundle.js +49 -0
  18. package/cli/dist/deployer/compose.js +26 -0
  19. package/cli/dist/deployer/cron.js +44 -0
  20. package/cli/dist/deployer/ctl.js +77 -0
  21. package/cli/dist/deployer/deployer.js +284 -0
  22. package/cli/dist/deployer/forge.js +25 -0
  23. package/cli/dist/deployer/http.js +22 -0
  24. package/cli/dist/deployer/main.js +19 -0
  25. package/cli/dist/deployer/page.js +32 -0
  26. package/cli/dist/deployer/process.js +31 -0
  27. package/cli/dist/deployer/queue.js +28 -0
  28. package/cli/dist/deployer/records.js +48 -0
  29. package/cli/dist/deployer/run.js +76 -0
  30. package/cli/dist/deployer/store.js +31 -0
  31. package/cli/dist/deployer/webhook.js +31 -0
  32. package/cli/dist/fleet.js +1 -1
  33. package/cli/dist/main.js +11 -7
  34. package/cli/dist/settings.js +6 -10
  35. package/compose/authelia/configuration.yml +3 -3
  36. package/compose/deployer/Dockerfile.builder +37 -0
  37. package/compose/deployer/Dockerfile.deployer +40 -0
  38. package/compose/deployer/kayra-deployer +6 -0
  39. package/compose/docker-compose.ci.yml +71 -79
  40. package/hosts.conf +2 -2
  41. package/package.json +2 -3
  42. package/schema/defaults.json +2 -14
  43. package/schema/server.json +1 -1
  44. package/schema/vars-platform.json +2 -14
  45. package/scripts/local/require-main.sh +5 -1
  46. package/scripts/platform/compose-policy.sh +84 -0
  47. package/scripts/platform/registry-gc.sh +99 -0
  48. package/scripts/platform/resolve-images.sh +107 -0
  49. package/scripts/server/{jenkins-app-apply → app-apply} +9 -10
  50. package/scripts/server/app-purge +1 -1
  51. package/scripts/server/app-remove +8 -9
  52. package/ansible/roles/app/templates/casc-app.yaml.j2 +0 -68
  53. package/compose/jenkins/Dockerfile +0 -40
  54. package/compose/jenkins/casc/00-platform.yaml +0 -158
  55. package/compose/jenkins/plugins.txt +0 -97
  56. package/compose/jenkins-agent/Dockerfile +0 -24
  57. package/scripts/server/jenkins-api +0 -28
  58. package/scripts/server/jenkins-reload +0 -12
package/README.md CHANGED
@@ -4,9 +4,9 @@ A self-hosted app platform for running other people's apps safely, one server
4
4
  per customer, all of it as code.
5
5
 
6
6
  kayra sets up a server (Hetzner, a machine at home, or a local Multipass VM)
7
- with Traefik, Authelia, Jenkins, a private registry and rootless BuildKit, and
8
- then builds and deploys apps from their own repositories: a push to an app's
9
- branch is a deploy. Servers are described in a git repository (a **fleet**);
7
+ with Traefik, Authelia, a private registry, rootless BuildKit and a small
8
+ deployer, and then builds and deploys apps from their own repositories: a push
9
+ to an app's branch is a deploy. Servers are described in a git repository (a **fleet**);
10
10
  kayra is the engine that turns that description into running machines.
11
11
 
12
12
  ```
@@ -14,7 +14,7 @@ kayra is the engine that turns that description into running machines.
14
14
  ┌──────────────────────────────┐ ┌───────────────────────┐ ┌──────────────────┐
15
15
  │ CLI: kayra up/setup/apps/… │◀─────│ servers/<name>/ │ │ app.yml │
16
16
  │ Ansible, OpenTofu, compose │ pins │ server.yml, vars, │ │ Dockerfiles │
17
- │ Jenkins pipeline library │ │ vault secrets │ └────────┬─────────┘
17
+ │ the deployer: build, deploy │ │ vault secrets │ └────────┬─────────┘
18
18
  │ compose admission policy │ │ package.json → kayra │ │ built & deployed
19
19
  └──────────────────────────────┘ └───────────────────────┘ ▼ by the platform
20
20
  ```
@@ -26,7 +26,7 @@ kayra is the engine that turns that description into running machines.
26
26
  modelled on Kubernetes' Pod Security "restricted" before anything starts;
27
27
  platform services sit on a network apps cannot reach.
28
28
  - **Apps are plain data.** An app is an `app.yml` and its Dockerfiles — no
29
- Jenkinsfile, no Ansible. It runs without the platform locally and can leave
29
+ pipeline, no Ansible. It runs without the platform locally and can leave
30
30
  with its customer at any time.
31
31
  - **One server per customer, reproducible.** Everything about a server lives in
32
32
  its folder of the fleet; secrets per server in Ansible Vault; machines from
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  # =============================================================================
3
3
  # Apps-prune playbook — removes every app registered on the server that
4
- # server.yml no longer lists. Registered = has a 10-<app>.yaml, the one file
5
- # every app gets. Data (volumes) is kept; see bin/app-remove.
4
+ # server.yml no longer lists. Registered = has an apps/<app>.json, the one
5
+ # file every app gets. Data (volumes) is kept; see bin/app-remove.
6
6
  #
7
7
  # `keep` is the list of app names in server.yml, as JSON.
8
8
  # Usage: kayra apps <server> (runs this after registering the apps)
@@ -18,13 +18,13 @@
18
18
  tasks:
19
19
  - name: Find registered apps
20
20
  ansible.builtin.find:
21
- paths: "{{ platform_dir }}/jenkins/casc"
22
- patterns: "10-*.yaml"
23
- register: fragments
21
+ paths: "{{ platform_dir }}/apps"
22
+ patterns: "*.json"
23
+ register: registrations
24
24
 
25
25
  - name: Remove apps no longer in server.yml
26
26
  ansible.builtin.command: "{{ platform_dir }}/bin/app-remove {{ item }}"
27
27
  loop: "{{ registered | difference(keep) }}"
28
28
  vars:
29
- registered: "{{ fragments.files | map(attribute='path') | map('basename') | map('regex_replace', '^10-(.*)\\.yaml$', '\\1') | list }}"
29
+ registered: "{{ registrations.files | map(attribute='path') | map('basename') | map('regex_replace', '\\.json$', '') | list }}"
30
30
  changed_when: true
@@ -4,14 +4,14 @@
4
4
  #
5
5
  # Play 1: Bootstrap — base packages, deploy user, Docker installation.
6
6
  # Run once on a fresh server (idempotent, safe to re-run).
7
- # Play 2: Platform — Traefik + Jenkins + Registry via docker compose.
8
- # Re-run whenever platform config, certs, or Jenkins config changes.
7
+ # Play 2: Platform — Traefik, Authelia, the registry, BuildKit, the builder and the deployer via docker compose.
8
+ # Re-run whenever platform config, or certs change.
9
9
  #
10
10
  # The platform lives in /opt/platform and knows no app by name. Apps live in
11
- # /opt/apps/<name> and register with Jenkins via /opt/platform/bin/jenkins-app-apply.
11
+ # /opt/apps/<name> and register through /opt/platform/bin/app-apply.
12
12
  #
13
13
  # server, server_dir, domain and target are passed via -e. Platform vars
14
- # (git_token, jenkins_admin_password, etc.) come from servers/<server>/vars.yml.
14
+ # (git_token, webhook_secret, etc.) come from servers/<server>/vars.yml.
15
15
  #
16
16
  # Usage: kayra setup <server>
17
17
  # =============================================================================
@@ -56,7 +56,7 @@
56
56
  ansible.builtin.debug:
57
57
  msg: |
58
58
  Platform ready! ({{ server }})
59
- Jenkins: https://jenkins.{{ domain }}
59
+ Runs: https://deploy.{{ domain }}
60
60
  Traefik: https://traefik.{{ domain }}
61
61
 
62
62
  Next: `kayra apps {{ server }}` to register this server's apps.
@@ -5,14 +5,15 @@
5
5
  # plaintext) and <app>.secrets.yml (secrets, vault on real servers), and
6
6
  # registers the app:
7
7
  # {{ apps_dir }}/<app>/.env operator config for compose
8
- # {{ platform_dir }}/jenkins/casc/10-<app>.yaml folder, jobs, credentials
9
- # jenkins-app-apply secrets + JCasC reload
8
+ # app-apply secrets → app-secrets/<app>/
9
+ # {{ platform_dir }}/apps/<app>.json the registration the deployer
10
+ # and the builder read
10
11
  #
11
12
  # Compose files are not copied: the deploy takes them from the commit it
12
13
  # deploys. Nothing here is specific to any app.
13
14
  # =============================================================================
14
15
 
15
- # With the token Jenkins clones with, not the operator's git credentials: a
16
+ # With the token the builder clones with, not the operator's git credentials: a
16
17
  # token that cannot read the app fails here, at registration, rather than at
17
18
  # its first build. Sent as a header via the environment, so it is neither in
18
19
  # the clone's config nor in the process list (Ansible prints the environment
@@ -89,19 +90,30 @@
89
90
  mode: "0644"
90
91
  become: true
91
92
 
92
- - name: Render the app's JCasC fragment
93
- ansible.builtin.template:
94
- src: casc-app.yaml.j2
95
- dest: "{{ platform_dir }}/jenkins/casc/10-{{ app.name }}.yaml"
96
- owner: deploy
97
- group: deploy
98
- mode: "0644"
99
- become: true
100
-
101
- - name: Apply secrets and reload Jenkins
93
+ # Secrets before the registration: a push in between finds them in place.
94
+ - name: Apply secrets
102
95
  ansible.builtin.command:
103
- cmd: "{{ platform_dir }}/bin/jenkins-app-apply {{ app.name }}"
96
+ cmd: "{{ platform_dir }}/bin/app-apply {{ app.name }}"
104
97
  stdin: "{% for key in manifest.secrets | default([]) %}{{ key }}={{ app_secrets[key] }}\n{% endfor %}"
105
98
  no_log: true
106
99
  become: true
107
100
  changed_when: true
101
+
102
+ # What may deploy where, approved here: hosts and secret keys come from this
103
+ # file, never from the app's repository at the commit being deployed.
104
+ - name: Register the app
105
+ ansible.builtin.copy:
106
+ content: "{{ registration | to_nice_json }}\n"
107
+ dest: "{{ platform_dir }}/apps/{{ app.name }}.json"
108
+ owner: root
109
+ group: root
110
+ mode: "0644"
111
+ vars:
112
+ registration:
113
+ name: "{{ app.name }}"
114
+ repo: "{{ app_repo }}"
115
+ ref: "{{ app_ref }}"
116
+ path: "{{ app.path | default('.') }}"
117
+ hosts: "{{ manifest.subdomains | map('regex_replace', '$', '.' ~ domain) | list }}"
118
+ secrets: "{{ manifest.secrets | default([]) }}"
119
+ become: true
@@ -1,6 +1,6 @@
1
1
  # {{ apps_dir }}/{{ app.name }}/.env — generated by the platform app role from
2
2
  # servers/{{ server }}/apps/{{ app.name }}.yml. Do not edit on the server.
3
- # Secrets are not here: the deploy injects them from Jenkins credentials.
3
+ # Secrets are not here: the deployer passes them to compose in its environment.
4
4
 
5
5
  COMPOSE_PROJECT_NAME={{ app.name }}
6
6
  DOMAIN={{ domain }}
@@ -54,7 +54,7 @@
54
54
 
55
55
  # The box runs two JVMs and builds images while serving production. Swap is not
56
56
  # a substitute for RAM — it stops the OOM killer picking a random victim
57
- # (postgres, say) during a Jenkins build.
57
+ # (postgres, say) during a build.
58
58
  - name: Create swapfile
59
59
  ansible.builtin.shell: fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile
60
60
  args:
@@ -260,7 +260,8 @@
260
260
  # Only reads, so it runs in check mode too (kayra plan).
261
261
  check_mode: false
262
262
 
263
- # A push to any branch calls the URL (with notifyCommit: Jenkins polls).
263
+ # A push to any branch calls the URL, signed with webhook_secret: the
264
+ # builder's http://builder:8080/hook/<app> over platform-internal checks it.
264
265
  - name: Add missing webhooks
265
266
  ansible.builtin.uri:
266
267
  url: "{{ forgejo_config.url }}/api/v1/repos/{{ item.0.name }}/hooks"
@@ -272,7 +273,7 @@
272
273
  active: true
273
274
  events: [push]
274
275
  branch_filter: "*"
275
- config: {url: "{{ item.1 }}", content_type: json, http_method: post}
276
+ config: {url: "{{ item.1 }}", content_type: json, http_method: post, secret: "{{ webhook_secret }}"}
276
277
  status_code: [201]
277
278
  changed_when: true
278
279
  when: >-
@@ -318,7 +319,7 @@
318
319
  loop_control: {label: "{{ item.item.name }}"}
319
320
  no_log: true
320
321
 
321
- # Tokens for the platform's services on this server (Jenkins, Renovate), into
322
+ # Tokens for the platform's services on this server (builder and deployer, Renovate), into
322
323
  # the files the platform role would otherwise fill from the vault. Nobody ever
323
324
  # sees one: kept only in that file, minted again when it is missing, wrong or
324
325
  # revoked. Unique names, since the admin token cannot delete a user's tokens.
@@ -368,9 +369,3 @@
368
369
  loop_control: {label: "{{ item.item.item.item.secret }}"}
369
370
  become: true
370
371
  no_log: true
371
-
372
- - name: Reload JCasC with the new tokens
373
- ansible.builtin.command: "{{ platform_dir }}/bin/jenkins-reload"
374
- when: service_tokens_minted is changed
375
- become: true
376
- changed_when: true
@@ -7,11 +7,6 @@
7
7
  # live under apps_dir/<name>. Changing either means changing every app.
8
8
  platform_dir: /opt/platform
9
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
10
  domain: localhost
16
11
  # The engine's files (compose files, scripts) lie above the playbooks, wherever
17
12
  # the fleet it serves is; the fleet's (servers/, .cache/) above the server's
@@ -27,25 +22,23 @@ fleet_root: "{{ server_dir }}/../.."
27
22
  acme_dns: ""
28
23
  acme_dns_token: ""
29
24
 
30
- # Jenkins
31
- jenkins_admin_user: admin
32
- jenkins_admin_password: admin
33
-
34
- # Git
35
- git_remote_url: ""
25
+ # The token the builder clones app repos with and the deployer posts commit
26
+ # statuses with (Contents read, Commit statuses write).
36
27
  git_token: ""
28
+ # The secret forges sign webhooks with (deploy.<domain>/hook/<app>).
29
+ webhook_secret: ""
37
30
 
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.
31
+ # owner/repo of the repos this server's Renovate job keeps up to date
32
+ # (weekly version-bump PRs); the job runs only where the list is not empty.
40
33
  # renovate_token is a PAT reaching each of them: Contents, Pull requests and
41
34
  # Issues (the dependency dashboard) read & write. On a server whose own
42
35
  # Forgejo hosts them, forgejo_config.tokens mints it (and git_token) instead.
43
36
  renovate_repositories: []
44
37
  renovate_token: ""
45
- # When platform-renovate runs, as a Jenkins cron spec: weekly suits other
38
+ # When the Renovate job runs, as a five-field cron spec: weekly suits other
46
39
  # people's releases; a server whose repos carry the operator's own images
47
40
  # runs it more often, so a new build reaches a PR soon after it is pushed.
48
- renovate_schedule: "H 4 * * 1"
41
+ renovate_schedule: "0 4 * * 1"
49
42
  # Where those repos live: github, or forgejo with renovate_endpoint, its API
50
43
  # on platform-internal (http://forgejo:3000/api/v1). With a Forgejo, also
51
44
  # renovate_git_url: endpoint, so Renovate clones over that address and not the
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  # =============================================================================
3
- # Platform role — Traefik, Authelia, Registry, BuildKit and Jenkins as compose
3
+ # Platform role — Traefik, Authelia, Registry, BuildKit, the builder and the
4
+ # deployer as compose
4
5
  # project "platform" in {{ platform_dir }}. App-agnostic.
5
6
  #
6
7
  # {{ platform_dir }}/.env sets COMPOSE_FILE, so on the server every compose
@@ -28,17 +29,53 @@
28
29
  - "{{ platform_dir }}/authelia/secrets"
29
30
  become: true
30
31
 
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
32
+ # app-secrets/: written by bin/app-apply as root and chowned per app to the
33
+ # deployer's uid, so the directory itself stays root's. apps/: one
34
+ # registration per app, written by the app role.
35
+ - name: Create the registration and app secrets directories
34
36
  ansible.builtin.file:
35
- path: "{{ platform_dir }}/jenkins-secrets"
37
+ path: "{{ platform_dir }}/{{ item }}"
36
38
  state: directory
37
39
  owner: root
38
40
  group: root
39
41
  mode: "0755"
42
+ loop: [apps, app-secrets]
40
43
  become: true
41
44
 
45
+ # Written by the deployer (uid 1000); the builder reads runs/ for its pages.
46
+ - name: Create the runs and releases directories
47
+ ansible.builtin.file:
48
+ path: "{{ platform_dir }}/{{ item }}"
49
+ state: directory
50
+ owner: "1000"
51
+ group: "1000"
52
+ mode: "0755"
53
+ loop: [runs, releases]
54
+ become: true
55
+ register: deployer_dirs
56
+
57
+ # Jenkins kept the release records rollback and GC work from; carried over
58
+ # once, the first time releases/ is created. The volume itself stays until
59
+ # deleted by hand.
60
+ - name: Find Jenkins' release records
61
+ community.docker.docker_volume_info:
62
+ name: platform_jenkins-home
63
+ register: jenkins_home
64
+ when: deployer_dirs.results[1] is changed
65
+ become: true
66
+
67
+ - name: Carry Jenkins' release records over
68
+ ansible.builtin.shell: |
69
+ set -euo pipefail
70
+ [ -d "{{ jenkins_home.volume.Mountpoint }}/releases" ] || exit 0
71
+ cp -a "{{ jenkins_home.volume.Mountpoint }}/releases/." "{{ platform_dir }}/releases/"
72
+ chown -R 1000:1000 "{{ platform_dir }}/releases"
73
+ args:
74
+ executable: /bin/bash
75
+ when: deployer_dirs.results[1] is changed and jenkins_home.exists | default(false)
76
+ become: true
77
+ changed_when: true
78
+
42
79
  # buildkit/ matters more than it looks: without buildkitd.toml the bind mount
43
80
  # has no source, Docker creates a directory in its place, and buildkitd
44
81
  # crash-loops until the container is recreated.
@@ -55,13 +92,45 @@
55
92
  - docker-compose.acme-dns.yml
56
93
  - traefik/dynamic/
57
94
  - authelia/configuration.yml
58
- - jenkins/
59
- - jenkins-agent/
60
95
  - buildkit/
96
+ - deployer/
61
97
  become: true
62
98
  register: platform_files
63
99
 
64
- # Apps register with Jenkins through these, never by touching the controller.
100
+ # The deployer's code and the scripts it and the builder run, into the build
101
+ # context of both images: a checkout's TypeScript, or the published package's
102
+ # JavaScript (Node strips types only outside node_modules).
103
+ - name: Find the deployer's code
104
+ ansible.builtin.stat:
105
+ path: "{{ kayra_root }}/cli/src/deployer"
106
+ register: deployer_source
107
+ delegate_to: localhost
108
+ become: false
109
+
110
+ - name: Copy the deployer's code
111
+ ansible.builtin.copy:
112
+ src: "{{ kayra_root }}/cli/{{ code }}/{{ item }}"
113
+ dest: "{{ platform_dir }}/deployer/app/{{ item }}"
114
+ owner: deploy
115
+ group: deploy
116
+ mode: "0644"
117
+ loop:
118
+ - deployer/
119
+ - "result.{{ 'ts' if deployer_source.stat.exists else 'js' }}"
120
+ vars:
121
+ code: "{{ 'src' if deployer_source.stat.exists else 'dist' }}"
122
+ become: true
123
+
124
+ - name: Copy the platform scripts
125
+ ansible.builtin.copy:
126
+ src: "{{ kayra_root }}/scripts/platform/"
127
+ dest: "{{ platform_dir }}/deployer/app/scripts/platform/"
128
+ owner: deploy
129
+ group: deploy
130
+ mode: "0644"
131
+ become: true
132
+
133
+ # Apps register through these, never by writing the platform's files by hand.
65
134
  - name: Install platform scripts
66
135
  ansible.builtin.copy:
67
136
  src: "{{ kayra_root }}/scripts/server/"
@@ -178,7 +247,7 @@
178
247
  database: group
179
248
  key: docker
180
249
 
181
- # Read by the controller as uid 1000. An empty var leaves the file as it is:
250
+ # Read by the builder and the deployer as uid 1000. An empty var leaves the file as it is:
182
251
  # on a server whose Forgejo mints them (forgejo_config.tokens), that role
183
252
  # writes them.
184
253
  - name: Create platform secrets directory
@@ -218,6 +287,21 @@
218
287
  # It holds secrets: --diff (kayra plan) says that it would change, never how.
219
288
  diff: false
220
289
 
290
+ # Jenkins is gone; the copies above never delete files.
291
+ - name: Remove what Jenkins left
292
+ ansible.builtin.file:
293
+ path: "{{ platform_dir }}/{{ item }}"
294
+ state: absent
295
+ loop:
296
+ - jenkins
297
+ - jenkins-agent
298
+ - jenkins-secrets
299
+ - jenkins-agent.env
300
+ - bin/jenkins-api
301
+ - bin/jenkins-reload
302
+ - bin/jenkins-app-apply
303
+ become: true
304
+
221
305
  # The registry used to have a public, basic-auth router. Nothing outside the
222
306
  # server pushes or pulls, so it is gone; the copy above never deletes files.
223
307
  - name: Remove the retired registry basic auth
@@ -240,10 +324,13 @@
240
324
  ignore_buildable: true
241
325
  policy: missing
242
326
 
327
+ # remove_orphans: services the platform dropped (Jenkins' controller and agent)
328
+ # must not keep running.
243
329
  - name: Start platform
244
330
  community.docker.docker_compose_v2:
245
331
  project_src: "{{ platform_dir }}"
246
332
  build: always
333
+ remove_orphans: true
247
334
 
248
335
  # Only dynamic/ is watched; the static file is read once at startup.
249
336
  - name: Restart Traefik when its static config changed
@@ -260,57 +347,3 @@
260
347
  chdir: "{{ platform_dir }}"
261
348
  when: platform_files.results | selectattr('item', 'eq', 'buildkit/') | selectattr('changed') | list | length > 0
262
349
  changed_when: true
263
-
264
- # Inside the controller, not through Traefik: jenkins.<domain> sits behind
265
- # Authelia, which would answer every call here with a login redirect.
266
- - name: Wait for Jenkins
267
- ansible.builtin.command: docker compose exec -T jenkins-controller curl -sf -o /dev/null http://127.0.0.1:8080/login
268
- args:
269
- chdir: "{{ platform_dir }}"
270
- register: jenkins_health
271
- retries: 30
272
- delay: 10
273
- until: jenkins_health.rc == 0
274
- changed_when: false
275
- # Only reads, so it runs in check mode too (kayra plan).
276
- check_mode: false
277
-
278
- # casc/ is a bind mount, so a changed 00-platform.yaml only takes effect on
279
- # a reload; so do new tokens, read from files by ${readFile:…}.
280
- - name: Reload JCasC
281
- ansible.builtin.command: "{{ platform_dir }}/bin/jenkins-reload"
282
- when: >-
283
- platform_files.results | selectattr('item', 'equalto', 'jenkins/') | selectattr('changed') | list | length > 0
284
- or platform_tokens is changed
285
- become: true
286
- changed_when: true
287
-
288
- # ---------------------------------------------------------------------------
289
- # Agent secret. Fetched every run and kept in its own file: it is derived
290
- # from Jenkins' own state, so it has no var source, and re-rendering .env must
291
- # never be able to blank it. Refetching also heals a reset jenkins_home.
292
- # ---------------------------------------------------------------------------
293
- - name: Fetch Jenkins agent secret
294
- ansible.builtin.command: "{{ platform_dir }}/bin/jenkins-api GET /computer/builder/jenkins-agent.jnlp"
295
- register: agent_jnlp
296
- no_log: true
297
- changed_when: false
298
- check_mode: false
299
-
300
- - name: Write Jenkins agent secret
301
- ansible.builtin.copy:
302
- content: "JENKINS_SECRET={{ agent_jnlp.stdout | regex_findall('<argument>([a-f0-9]{64})</argument>') | first }}\n"
303
- dest: "{{ platform_dir }}/jenkins-agent.env"
304
- owner: deploy
305
- group: deploy
306
- mode: "0600"
307
- no_log: true
308
- become: true
309
- register: agent_secret
310
-
311
- - name: Recreate Jenkins agent with its secret
312
- ansible.builtin.command: docker compose up -d --no-deps --force-recreate jenkins-agent
313
- args:
314
- chdir: "{{ platform_dir }}"
315
- when: agent_secret is changed
316
- changed_when: true
@@ -3,8 +3,8 @@
3
3
  #
4
4
  # Compose reads it automatically in {{ platform_dir }}, and COMPOSE_FILE means
5
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.
6
+ # reach the deployer through bin/app-apply, app config lives in
7
+ # {{ apps_dir }}/<app>/.env.
8
8
  # =============================================================================
9
9
 
10
10
  COMPOSE_PROJECT_NAME=platform
@@ -13,8 +13,6 @@ DOMAIN={{ domain }}
13
13
  AUTH_POLICY={{ authelia_policy }}
14
14
  APPS_DIR={{ apps_dir }}
15
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
16
  PLATFORM_RENOVATE={{ (renovate_repositories | length > 0) | lower }}
19
17
  RENOVATE_SCHEDULE={{ renovate_schedule }}
20
18
  RENOVATE_PLATFORM={{ renovate_platform }}
@@ -24,13 +22,7 @@ RENOVATE_GITHUB_COM_TOKEN={{ renovate_github_com_token }}
24
22
  RENOVATE_HOST_RULES={{ renovate_host_rules | to_json if renovate_host_rules else '' }}
25
23
  RENOVATE_REPOSITORIES={{ renovate_repositories | to_json }}
26
24
 
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 }}
25
+ WEBHOOK_SECRET={{ webhook_secret }}
34
26
 
35
27
  # Authelia SMTP. Empty address falls the notifier back to a file — passkey
36
28
  # enrolment then needs the code read off the server. The password is NOT here:
package/cli/dist/brand.js CHANGED
@@ -19,9 +19,6 @@ export const absolutePath = (value) => {
19
19
  };
20
20
  export const kayraRoot = (path) => path;
21
21
  export const fleetRoot = (path) => path;
22
- export const kayraVersion = (value) => typeof value === "string" && /^\d+\.\d+\.\d+$/.test(value)
23
- ? ok(value)
24
- : err(`'${String(value)}' is not a version MAJOR.MINOR.PATCH`);
25
22
  export const serverDir = (root, name) => join(root, "servers", name);
26
23
  export const vaultPasswordFile = (path) => path;
27
24
  export const within = (base, ...segments) => join(base, ...segments);
package/cli/dist/cli.js CHANGED
@@ -14,7 +14,16 @@ export const parser = (fleet) => {
14
14
  // On one server
15
15
  or(onServer("up", message `Provision, set up everything, point /etc/hosts at it.`), onServer("provision", message `Create or update the machine (target home: build the image to import).`), onServer("deploy", message `Host setup and every app, no provisioning.`), onServer("setup", message `The host layer only: the playbook for the server's kind.`), onServer("plan", message `What provision and setup would change, changing nothing (OpenTofu plan, Ansible check mode).`), onServer("apps", message `Make the server's apps match server.yml: register each, remove the rest (data kept).`), onServer("hosts", message `Point the domain at a multipass VM in /etc/hosts (sudo).`), command("ssh", object({ name: constant("ssh"), server, remote: multiple(argument(string({ metavar: "COMMAND" }))) }), { brief: message `SSH into the server, or run a command there (after --).` }), onServer("down", message `Destroy the machine.`), onServer("adopt", message `Once, for a server we did not create: add the deploy user as root.`), command("backup", command("test", object({ name: constant("backup test"), server })), {
16
16
  brief: message `Restore the latest backup to a scratch dir, check it, delete it.`,
17
- }), command("app", command("purge", object({ name: constant("app purge"), server, app: argument(appNameParser) })), { brief: message `Delete a removed app's volumes: its data, irreversibly.` })),
17
+ }), command("app", or(command("purge", object({ name: constant("app purge"), server, app: argument(appNameParser) }), {
18
+ brief: message `Delete a removed app's volumes: its data, irreversibly.`,
19
+ }), command("runs", object({ name: constant("app runs"), server, app: argument(appNameParser) }), {
20
+ brief: message `The app's latest runs: id, result, commit, why.`,
21
+ }), command("logs", object({ name: constant("app logs"), server, app: argument(appNameParser), more: optional(argument(string({ metavar: "RUN" }))) }), { brief: message `A run's log, the newest unless named.` }), command("deploy", object({ name: constant("app deploy"), server, app: argument(appNameParser), more: optional(argument(string({ metavar: "REF" }))) }), { brief: message `Build and deploy a branch, tag or commit (default: the app's ref).` }), command("rollback", object({ name: constant("app rollback"), server, app: argument(appNameParser), more: optional(argument(string({ metavar: "SHA" }))) }), { brief: message `List earlier releases, or deploy one of them again.` })), { brief: message `An app on a server: its runs, logs, deploys, rollbacks, and purging its data.` }), command("job", object({
22
+ name: constant("job"),
23
+ server,
24
+ job: argument(choice(["gc", "renovate"], { metavar: "JOB" })),
25
+ noPrs: option("--no-prs", { description: message `Renovate: log what would change, open no PRs.` }),
26
+ }), { brief: message `Run a platform job now: the registry GC or Renovate.` })),
18
27
  // On the whole fleet
19
28
  or(command("status", object({ name: constant("status") }), {
20
29
  brief: message `Every server and whether a machine exists for it.`,
@@ -87,8 +87,6 @@ const hostPlaybook = (engine, server, extra = []) => run([
87
87
  `domain=${server.domain}`,
88
88
  "-e",
89
89
  `target=${server.target}`,
90
- "-e",
91
- `kayra_version=${engine.version}`,
92
90
  ...extra,
93
91
  ], ansibleEnv(engine, vaultEnv(server)));
94
92
  /** The host layer: the playbook for the server's kind. */
@@ -246,5 +244,21 @@ export const ssh = (server, remote) => server.inventory === undefined
246
244
  ...remote,
247
245
  ]),
248
246
  ]);
247
+ const ctlArgs = (command) => {
248
+ switch (command.kind) {
249
+ case "job":
250
+ return ["job", command.job, ...(command.noPrs ? ["--dry-run"] : [])];
251
+ case "logs":
252
+ return ["log", command.app, ...(command.more === undefined ? [] : [command.more])];
253
+ case "runs":
254
+ case "deploy":
255
+ case "rollback":
256
+ return [command.kind, command.app, ...(command.more === undefined ? [] : [command.more])];
257
+ }
258
+ };
259
+ /** Inside the deployer container, as the deploy user (in the docker group): nothing here needs root. */
260
+ export const deployer = (server, command) => server.kind === "platform"
261
+ ? ssh(server, ["cd", "/opt/platform", "&&", "docker", "compose", "exec", "-T", "deployer", "kayra-deployer", "ctl", ...ctlArgs(command)])
262
+ : err(`'${server.name}' is a ${server.kind} server: only platform servers run apps.`);
249
263
  /** mkcert certs for a domain and its subdomains, for local dev and multipass. */
250
264
  export const certsFor = (engine, domain, subdomains) => ok([certs(engine, domain, subdomains)]);
@@ -0,0 +1,47 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { err, ok } from "../result.js";
4
+ import { appName } from "./brand.js";
5
+ const strings = (value, pattern) => Array.isArray(value) && value.every((item) => typeof item === "string" && pattern.test(item))
6
+ ? value
7
+ : undefined;
8
+ export const field = (record, key) => {
9
+ const value = Reflect.get(record, key);
10
+ return value;
11
+ };
12
+ export const parseApp = (expected, json) => {
13
+ if (typeof json !== "object" || json === null)
14
+ return err(`${expected}: not an object`);
15
+ const name = field(json, "name");
16
+ const repo = field(json, "repo");
17
+ const ref = field(json, "ref");
18
+ const path = field(json, "path");
19
+ const hosts = strings(field(json, "hosts"), /^[a-z0-9.-]+$/);
20
+ const secrets = strings(field(json, "secrets"), /^[A-Z][A-Z0-9_]*$/);
21
+ if (name !== expected)
22
+ return err(`${expected}: registered as ${String(name)}`);
23
+ const minted = appName(expected);
24
+ if (!minted.ok)
25
+ return minted;
26
+ if (typeof repo !== "string" || !/^(https?|ssh):\/\//.test(repo))
27
+ return err(`${expected}: repo ${String(repo)}`);
28
+ if (typeof ref !== "string" || !/^[\w./-]+$/.test(ref))
29
+ return err(`${expected}: ref ${String(ref)}`);
30
+ if (typeof path !== "string" || path.split("/").includes(".."))
31
+ return err(`${expected}: path ${String(path)}`);
32
+ if (hosts === undefined || secrets === undefined)
33
+ return err(`${expected}: hosts or secrets malformed`);
34
+ return ok({ name: minted.value, repo, ref, path, hosts, secrets });
35
+ };
36
+ /** An app registered on this server, from <platformDir>/apps/<name>.json. */
37
+ export const registered = (platformDir, name) => {
38
+ const path = join(platformDir, "apps", `${name}.json`);
39
+ if (!appName(name).ok || !existsSync(path))
40
+ return err(`${name} is not registered here`);
41
+ try {
42
+ return parseApp(name, JSON.parse(readFileSync(path, "utf8")));
43
+ }
44
+ catch (error) {
45
+ return err(`${path}: ${String(error)}`);
46
+ }
47
+ };
@@ -0,0 +1,8 @@
1
+ import { err, ok } from "../result.js";
2
+ export const appName = (value) => /^[a-z][a-z0-9-]*$/.test(value) ? ok(value) : err(`'${value}' is not an app name`);
3
+ export const sha = (value) => /^[0-9a-f]{40}$/.test(value) ? ok(value) : err(`'${value}' is not a full commit id`);
4
+ export const runId = (value) => /^\d{8}T\d{6}Z-[0-9a-f]{6}$/.test(value) ? ok(value) : err(`'${value}' is not a run id`);
5
+ export const newRunId = (now, random) => `${now.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z")}-${random}`;
6
+ export const appOwner = (app) => app;
7
+ export const jobOwner = (job) => `_${job}`;
8
+ export const owner = (value) => /^(_gc|_renovate|[a-z][a-z0-9-]*)$/.test(value) ? ok(value) : err(`'${value}' has no runs`);