@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.
- package/README.md +5 -5
- package/ansible/playbooks/apps-prune.yml +6 -6
- package/ansible/playbooks/platform.yml +5 -5
- package/ansible/roles/app/tasks/main.yml +26 -14
- package/ansible/roles/app/templates/app.env.j2 +1 -1
- package/ansible/roles/common/tasks/main.yml +1 -1
- package/ansible/roles/forgejo-config/tasks/main.yml +4 -9
- package/ansible/roles/platform/defaults/main.yml +8 -15
- package/ansible/roles/platform/tasks/main.yml +96 -63
- package/ansible/roles/platform/templates/platform.env.j2 +3 -11
- package/cli/dist/brand.js +0 -3
- package/cli/dist/cli.js +10 -1
- package/cli/dist/commands/server.js +16 -2
- package/cli/dist/deployer/app.js +47 -0
- package/cli/dist/deployer/brand.js +8 -0
- package/cli/dist/deployer/builder.js +196 -0
- package/cli/dist/deployer/bundle.js +49 -0
- package/cli/dist/deployer/compose.js +26 -0
- package/cli/dist/deployer/cron.js +44 -0
- package/cli/dist/deployer/ctl.js +77 -0
- package/cli/dist/deployer/deployer.js +284 -0
- package/cli/dist/deployer/forge.js +25 -0
- package/cli/dist/deployer/http.js +22 -0
- package/cli/dist/deployer/main.js +19 -0
- package/cli/dist/deployer/page.js +32 -0
- package/cli/dist/deployer/process.js +31 -0
- package/cli/dist/deployer/queue.js +28 -0
- package/cli/dist/deployer/records.js +48 -0
- package/cli/dist/deployer/run.js +76 -0
- package/cli/dist/deployer/store.js +31 -0
- package/cli/dist/deployer/webhook.js +31 -0
- package/cli/dist/fleet.js +1 -1
- package/cli/dist/main.js +11 -7
- package/cli/dist/settings.js +6 -10
- package/compose/authelia/configuration.yml +3 -3
- package/compose/deployer/Dockerfile.builder +37 -0
- package/compose/deployer/Dockerfile.deployer +40 -0
- package/compose/deployer/kayra-deployer +6 -0
- package/compose/docker-compose.ci.yml +71 -79
- package/hosts.conf +2 -2
- package/package.json +2 -3
- package/schema/defaults.json +2 -14
- package/schema/server.json +1 -1
- package/schema/vars-platform.json +2 -14
- package/scripts/local/require-main.sh +5 -1
- package/scripts/platform/compose-policy.sh +84 -0
- package/scripts/platform/registry-gc.sh +99 -0
- package/scripts/platform/resolve-images.sh +107 -0
- package/scripts/server/{jenkins-app-apply → app-apply} +9 -10
- package/scripts/server/app-purge +1 -1
- package/scripts/server/app-remove +8 -9
- package/ansible/roles/app/templates/casc-app.yaml.j2 +0 -68
- package/compose/jenkins/Dockerfile +0 -40
- package/compose/jenkins/casc/00-platform.yaml +0 -158
- package/compose/jenkins/plugins.txt +0 -97
- package/compose/jenkins-agent/Dockerfile +0 -24
- package/scripts/server/jenkins-api +0 -28
- 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,
|
|
8
|
-
then builds and deploys apps from their own repositories: a push
|
|
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
|
-
│
|
|
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
|
-
|
|
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
|
|
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 }}/
|
|
22
|
-
patterns: "
|
|
23
|
-
register:
|
|
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: "{{
|
|
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
|
|
8
|
-
# Re-run whenever platform config,
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
#
|
|
9
|
-
#
|
|
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
|
|
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
|
-
|
|
93
|
-
|
|
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/
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
#
|
|
31
|
-
|
|
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
|
|
39
|
-
# (weekly version-bump PRs); the job
|
|
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
|
|
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: "
|
|
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
|
|
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
|
-
#
|
|
32
|
-
#
|
|
33
|
-
|
|
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 }}/
|
|
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
|
-
#
|
|
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
|
|
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
|
|
7
|
-
# {{ apps_dir }}/<app>/.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
|
-
|
|
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) })
|
|
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`);
|