@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yunus Öztürk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,81 @@
1
- # Temporary Holding Version
1
+ # kayra
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A self-hosted app platform for running other people's apps safely, one server
4
+ per customer, all of it as code.
5
+
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**);
10
+ kayra is the engine that turns that description into running machines.
11
+
12
+ ```
13
+ kayra (this repo, versioned) a fleet (your repo) apps (their repos)
14
+ ┌──────────────────────────────┐ ┌───────────────────────┐ ┌──────────────────┐
15
+ │ CLI: kayra up/setup/apps/… │◀─────│ servers/<name>/ │ │ app.yml │
16
+ │ Ansible, OpenTofu, compose │ pins │ server.yml, vars, │ │ Dockerfiles │
17
+ │ Jenkins pipeline library │ │ vault secrets │ └────────┬─────────┘
18
+ │ compose admission policy │ │ package.json → kayra │ │ built & deployed
19
+ └──────────────────────────────┘ └───────────────────────┘ ▼ by the platform
20
+ ```
21
+
22
+ ## Why
23
+
24
+ - **Apps are untrusted by design.** Builds run in rootless BuildKit with no
25
+ Docker socket; an app's compose files are admitted against an allow-list
26
+ modelled on Kubernetes' Pod Security "restricted" before anything starts;
27
+ platform services sit on a network apps cannot reach.
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
30
+ with its customer at any time.
31
+ - **One server per customer, reproducible.** Everything about a server lives in
32
+ its folder of the fleet; secrets per server in Ansible Vault; machines from
33
+ OpenTofu; every version pinned and kept current by Renovate.
34
+ - **Small and self-hosted.** No cloud provider beyond a VPS; it runs on a
35
+ single Hetzner box or at home.
36
+
37
+ ## Status
38
+
39
+ `0.x`: in production on its author's own servers, and the fleet format may
40
+ still change between releases. Expect to read the code.
41
+
42
+ ## Quick start
43
+
44
+ You need Node 24, `yq`, `jq`, OpenTofu and Ansible (and Multipass plus
45
+ `mkcert` for a local test server):
46
+
47
+ ```bash
48
+ brew install yq jq opentofu ansible mkcert nss && mkcert -install
49
+ npm install -g @yunuseon/kayra
50
+ ```
51
+
52
+ A fleet is a git repository with a `package.json` that pins kayra
53
+ (`npm install -D -E @yunuseon/kayra`), a `servers/defaults.yml` and one folder per
54
+ server under `servers/` — see [CLAUDE.md](CLAUDE.md#the-fleet-contract-one-folder-per-server)
55
+ for what goes where. Any `kayra` on your PATH then runs the version the fleet
56
+ pins:
57
+
58
+ ```bash
59
+ kayra doctor # what is still missing
60
+ kayra up shop-test # provision + set up a server, register its apps
61
+ kayra deploy shop-prod # host setup + apps, no re-provision (from main only)
62
+ kayra status
63
+ ```
64
+
65
+ ## Documentation
66
+
67
+ | | |
68
+ |---|---|
69
+ | [`CLAUDE.md`](CLAUDE.md) | how everything fits together, and the rules that keep it working |
70
+ | [`docs/app-contract.md`](docs/app-contract.md) | what an app provides and what it may not do |
71
+ | [`docs/authentication.md`](docs/authentication.md) | staff login (Authelia), adding people, recovery |
72
+ | [`docs/ci-isolation.md`](docs/ci-isolation.md) | why app code cannot reach the host or the platform |
73
+
74
+ ## Contributing
75
+
76
+ Issues and pull requests are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md).
77
+ Security problems: [SECURITY.md](SECURITY.md).
78
+
79
+ ## License
80
+
81
+ [MIT](LICENSE) © Yunus Öztürk
@@ -0,0 +1,13 @@
1
+ # ansible-lint, run by the platform checks (ci/checks).
2
+ skip_list:
3
+ # Variable names are the contract with every server's vars.yml (several
4
+ # vaulted) and are shared across roles (domain, apps_dir); prefixing them
5
+ # per role would rename that contract for nothing.
6
+ - var-naming[no-role-prefix]
7
+ # edge-proxy predates the check; renaming a role is churn, not safety.
8
+ - role-name
9
+ # Ordering matters in the platform role: JCasC reloads before the agent
10
+ # secret is fetched, the agent is recreated right after. Handlers would run
11
+ # at the end of the play instead.
12
+ - no-handler
13
+ - yaml[line-length]
@@ -0,0 +1,12 @@
1
+ [defaults]
2
+ roles_path = roles/
3
+ host_key_checking = False
4
+ retry_files_enabled = False
5
+ callbacks_enabled = ansible.builtin.default
6
+ [callback_default]
7
+ result_format = yaml
8
+ nocows = True
9
+
10
+ [ssh_connection]
11
+ pipelining = True
12
+ ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null
@@ -0,0 +1,35 @@
1
+ ---
2
+ # =============================================================================
3
+ # Adopt playbook — makes a server we did not create reachable the way our
4
+ # cloud-init would have: a `deploy` user with sudo and our key. Runs as root,
5
+ # once; everything after that is the server's normal playbook as deploy.
6
+ #
7
+ # Usage: kayra adopt <server>
8
+ # =============================================================================
9
+
10
+ - name: Adopt server
11
+ hosts: servers
12
+ gather_facts: false
13
+ vars:
14
+ # A play var, so it beats the inventory's ansible_user=deploy (-u does not).
15
+ ansible_user: root
16
+
17
+ tasks:
18
+ - name: Create the deploy user
19
+ ansible.builtin.user:
20
+ name: deploy
21
+ groups: sudo
22
+ append: true
23
+ shell: /bin/bash
24
+
25
+ - name: Authorize our key for deploy
26
+ ansible.posix.authorized_key:
27
+ user: deploy
28
+ key: "{{ lookup('file', ansible_ssh_private_key_file ~ '.pub') }}"
29
+
30
+ - name: Passwordless sudo for deploy
31
+ ansible.builtin.copy:
32
+ content: "deploy ALL=(ALL) NOPASSWD:ALL\n"
33
+ dest: /etc/sudoers.d/deploy
34
+ mode: "0440"
35
+ validate: visudo -cf %s
@@ -0,0 +1,21 @@
1
+ ---
2
+ # =============================================================================
3
+ # App Playbook — registers one app with the platform.
4
+ #
5
+ # `app` is the app's entry from servers/<server>/server.yml, as JSON:
6
+ # {"name": "x", "repo": "https://…", "ref": "main", "path": "."}
7
+ #
8
+ # Usage: kayra apps <server>
9
+ # =============================================================================
10
+
11
+ - name: Register app
12
+ hosts: servers
13
+
14
+ # git_token: the app's repo is fetched with it.
15
+ vars_files:
16
+ - "{{ server_dir }}/../defaults.yml"
17
+ - "{{ server_dir }}/vars.yml"
18
+ - "{{ server_dir }}/secrets.yml"
19
+
20
+ roles:
21
+ - app
@@ -0,0 +1,30 @@
1
+ ---
2
+ # =============================================================================
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.
6
+ #
7
+ # `keep` is the list of app names in server.yml, as JSON.
8
+ # Usage: kayra apps <server> (runs this after registering the apps)
9
+ # =============================================================================
10
+
11
+ - name: Prune apps
12
+ hosts: servers
13
+ become: true
14
+
15
+ vars:
16
+ platform_dir: /opt/platform
17
+
18
+ tasks:
19
+ - name: Find registered apps
20
+ ansible.builtin.find:
21
+ paths: "{{ platform_dir }}/jenkins/casc"
22
+ patterns: "10-*.yaml"
23
+ register: fragments
24
+
25
+ - name: Remove apps no longer in server.yml
26
+ ansible.builtin.command: "{{ platform_dir }}/bin/app-remove {{ item }}"
27
+ loop: "{{ registered | difference(keep) }}"
28
+ vars:
29
+ registered: "{{ fragments.files | map(attribute='path') | map('basename') | map('regex_replace', '^10-(.*)\\.yaml$', '\\1') | list }}"
30
+ changed_when: true
@@ -0,0 +1,41 @@
1
+ ---
2
+ # =============================================================================
3
+ # Edge playbook — a server that exposes services from a private network (the
4
+ # home NAS behind a Fritzbox) to the internet: a WireGuard tunnel to the LAN
5
+ # and Caddy terminating TLS in front of it, plus the outside check on that
6
+ # network's backups. No platform, no apps. Its firewall is Hetzner's
7
+ # (servers/<server>/terraform/); nothing on the host filters on top.
8
+ #
9
+ # Usage: kayra setup <server> (for servers with kind: edge)
10
+ # =============================================================================
11
+
12
+ - name: Edge server
13
+ hosts: servers
14
+ become: true
15
+
16
+ vars_files:
17
+ - "{{ server_dir }}/../defaults.yml"
18
+ - "{{ server_dir }}/vars.yml"
19
+ - "{{ server_dir }}/secrets.yml"
20
+
21
+ roles:
22
+ - common
23
+ - docker
24
+ - wireguard
25
+ - edge-proxy
26
+ - backup-check
27
+
28
+ # ufw predates the repo on adopted servers: a second, drifting copy of the
29
+ # Hetzner firewall's rules. Docker's own FORWARD chains carry Caddy's
30
+ # traffic into the tunnel either way.
31
+ tasks:
32
+ - name: Read ufw's status
33
+ ansible.builtin.command: ufw status
34
+ register: ufw_status
35
+ changed_when: false
36
+ failed_when: false
37
+
38
+ - name: Disable ufw
39
+ ansible.builtin.command: ufw disable
40
+ when: "'Status: active' in ufw_status.stdout"
41
+ changed_when: true
@@ -0,0 +1,47 @@
1
+ ---
2
+ # =============================================================================
3
+ # NAS playbook — a storage box whose OS belongs to its vendor (UGOS). Nothing
4
+ # here touches the OS: no packages, users or system config, which an update
5
+ # could reset. Everything runs as containers from files below nas_root, and
6
+ # the play stops before any change if a path it writes lies outside. What it
7
+ # backs up may lie anywhere: mounted read-only, and only if it already exists.
8
+ #
9
+ # Usage: kayra setup <server> (for servers with kind: nas)
10
+ # =============================================================================
11
+
12
+ - name: NAS
13
+ hosts: servers
14
+ become: true
15
+
16
+ vars_files:
17
+ - "{{ server_dir }}/../defaults.yml"
18
+ - "{{ server_dir }}/vars.yml"
19
+ - "{{ server_dir }}/secrets.yml"
20
+
21
+ pre_tasks:
22
+ - name: Write only inside nas_root
23
+ ansible.builtin.assert:
24
+ that: item is match('^' ~ nas_root ~ '/') and '/../' not in item ~ '/'
25
+ fail_msg: "{{ item }} lies outside {{ nas_root }}"
26
+ quiet: true
27
+ loop: "{{ [restic_backup.dir, compose_stacks.dir] }}"
28
+
29
+ # Docker creates a missing bind-mount source, a write wherever it points.
30
+ - name: Find the backed-up paths
31
+ ansible.builtin.stat:
32
+ path: "{{ item }}"
33
+ register: backed_up
34
+ loop: "{{ restic_backup.paths }}"
35
+
36
+ - name: Back up only paths that exist
37
+ ansible.builtin.assert:
38
+ that: item.stat.isdir | default(false) and '/../' not in item.item ~ '/'
39
+ fail_msg: "{{ item.item }} is not an existing directory"
40
+ quiet: true
41
+ loop: "{{ backed_up.results }}"
42
+ loop_control:
43
+ label: "{{ item.item }}"
44
+
45
+ roles:
46
+ - restic-backup
47
+ - compose-stacks
@@ -0,0 +1,62 @@
1
+ ---
2
+ # =============================================================================
3
+ # Platform Playbook — app-agnostic infrastructure setup
4
+ #
5
+ # Play 1: Bootstrap — base packages, deploy user, Docker installation.
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.
9
+ #
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.
12
+ #
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.
15
+ #
16
+ # Usage: kayra setup <server>
17
+ # =============================================================================
18
+
19
+ - name: Bootstrap server
20
+ hosts: servers
21
+ become: true
22
+
23
+ vars_files:
24
+ - "{{ server_dir }}/../defaults.yml"
25
+ - "{{ server_dir }}/vars.yml"
26
+ - "{{ server_dir }}/secrets.yml"
27
+
28
+ roles:
29
+ - common
30
+ - docker
31
+
32
+ post_tasks:
33
+ - name: Verify Docker is working
34
+ ansible.builtin.command: docker info
35
+ changed_when: false
36
+
37
+ - name: Deploy platform stack
38
+ hosts: servers
39
+
40
+ vars_files:
41
+ - "{{ server_dir }}/../defaults.yml"
42
+ - "{{ server_dir }}/vars.yml"
43
+ - "{{ server_dir }}/secrets.yml"
44
+
45
+ roles:
46
+ - platform
47
+ # A server's own stacks (servers/<server>/stacks/), where it has any.
48
+ - role: compose-stacks
49
+ become: true
50
+ when: compose_stacks is defined
51
+ - role: forgejo-config
52
+ when: forgejo_config is defined
53
+
54
+ post_tasks:
55
+ - name: Show platform status
56
+ ansible.builtin.debug:
57
+ msg: |
58
+ Platform ready! ({{ server }})
59
+ Jenkins: https://jenkins.{{ domain }}
60
+ Traefik: https://traefik.{{ domain }}
61
+
62
+ Next: `kayra apps {{ server }}` to register this server's apps.
@@ -0,0 +1,7 @@
1
+ # Collections the playbooks use, pinned. Installed by kayra
2
+ # before any playbook runs.
3
+ collections:
4
+ - name: ansible.posix
5
+ version: 2.2.2
6
+ - name: community.docker
7
+ version: 5.3.0
@@ -0,0 +1,9 @@
1
+ ---
2
+ # The platform's server-side contract (see the platform role's defaults).
3
+ platform_dir: /opt/platform
4
+ apps_dir: /opt/apps
5
+ # The fleet's files (servers/, .cache/apps) lie above the server's folder;
6
+ # the engine's (compose files, scripts, hosts.conf) above the playbooks. Two
7
+ # roots, so the engine can live apart from the fleet it serves.
8
+ fleet_root: "{{ server_dir }}/../.."
9
+ kayra_root: "{{ playbook_dir }}/../.."
@@ -0,0 +1,107 @@
1
+ ---
2
+ # =============================================================================
3
+ # App role — the same for every app. Reads the app's app.yml and the
4
+ # operator's values for it, servers/<server>/apps/<app>.yml (config,
5
+ # plaintext) and <app>.secrets.yml (secrets, vault on real servers), and
6
+ # registers the app:
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
10
+ #
11
+ # Compose files are not copied: the deploy takes them from the commit it
12
+ # deploys. Nothing here is specific to any app.
13
+ # =============================================================================
14
+
15
+ # With the token Jenkins clones with, not the operator's git credentials: a
16
+ # token that cannot read the app fails here, at registration, rather than at
17
+ # its first build. Sent as a header via the environment, so it is neither in
18
+ # the clone's config nor in the process list (Ansible prints the environment
19
+ # only at -vvv).
20
+ - name: Fetch app from its repository
21
+ ansible.builtin.git:
22
+ repo: "{{ app.repo }}"
23
+ version: "{{ app.ref | default('main') }}"
24
+ dest: "{{ fleet_root }}/.cache/apps/{{ app.name }}"
25
+ depth: 1
26
+ force: true
27
+ environment:
28
+ GIT_CONFIG_COUNT: "1"
29
+ GIT_CONFIG_KEY_0: http.extraHeader
30
+ GIT_CONFIG_VALUE_0: "Authorization: Basic {{ ('x-access-token:' ~ git_token) | b64encode }}"
31
+ delegate_to: localhost
32
+ run_once: true
33
+
34
+ - name: Resolve app source
35
+ ansible.builtin.set_fact:
36
+ app_root: "{{ fleet_root }}/.cache/apps/{{ app.name }}/{{ app.path | default('.') }}"
37
+ app_repo: "{{ app.repo }}"
38
+ app_ref: "{{ app.ref | default('main') }}"
39
+
40
+ - name: Read app.yml
41
+ ansible.builtin.include_vars:
42
+ file: "{{ app_root }}/app.yml"
43
+ name: manifest
44
+
45
+ # Either file may be missing: an app without config or without secrets.
46
+ - name: Read the operator's values for this app
47
+ ansible.builtin.include_vars:
48
+ file: "{{ server_dir }}/apps/{{ app.name }}{{ item.suffix }}"
49
+ name: "{{ item.name }}"
50
+ when: (server_dir ~ '/apps/' ~ app.name ~ item.suffix) is file
51
+ loop:
52
+ - {name: app_config, suffix: .yml}
53
+ - {name: app_secrets, suffix: .secrets.yml}
54
+ loop_control: {label: "{{ app.name }}{{ item.suffix }}"}
55
+
56
+ - name: Check the app against the platform
57
+ ansible.builtin.assert:
58
+ that:
59
+ - manifest.name == app.name
60
+ - missing_secrets | length == 0
61
+ - missing_config | length == 0
62
+ - reserved_subdomains | length == 0
63
+ fail_msg: >-
64
+ {{ app.name }}: app.yml name={{ manifest.name }};
65
+ missing config {{ missing_config }} in servers/{{ server }}/apps/{{ app.name }}.yml,
66
+ missing secrets {{ missing_secrets }} in servers/{{ server }}/apps/{{ app.name }}.secrets.yml;
67
+ subdomains reserved by the platform {{ reserved_subdomains }}
68
+ quiet: true
69
+ vars:
70
+ missing_secrets: "{{ manifest.secrets | default([]) | difference(app_secrets | default({}) | list) }}"
71
+ missing_config: "{{ manifest.config | default([]) | difference(app_config | default({}) | list) }}"
72
+ reserved_subdomains: "{{ manifest.subdomains | intersect(lookup('file', kayra_root ~ '/hosts.conf').splitlines() | reject('match', '^\\s*(#|$)') | list) }}"
73
+
74
+ - name: Create app directory
75
+ ansible.builtin.file:
76
+ path: "{{ apps_dir }}/{{ app.name }}"
77
+ state: directory
78
+ owner: deploy
79
+ group: deploy
80
+ mode: "0755"
81
+ become: true
82
+
83
+ - name: Render app .env
84
+ ansible.builtin.template:
85
+ src: app.env.j2
86
+ dest: "{{ apps_dir }}/{{ app.name }}/.env"
87
+ owner: deploy
88
+ group: deploy
89
+ mode: "0644"
90
+ become: true
91
+
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
102
+ ansible.builtin.command:
103
+ cmd: "{{ platform_dir }}/bin/jenkins-app-apply {{ app.name }}"
104
+ stdin: "{% for key in manifest.secrets | default([]) %}{{ key }}={{ app_secrets[key] }}\n{% endfor %}"
105
+ no_log: true
106
+ become: true
107
+ changed_when: true
@@ -0,0 +1,9 @@
1
+ # {{ apps_dir }}/{{ app.name }}/.env — generated by the platform app role from
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.
4
+
5
+ COMPOSE_PROJECT_NAME={{ app.name }}
6
+ DOMAIN={{ domain }}
7
+ {% for key in manifest.config | default([]) %}
8
+ {{ key }}={{ app_config[key] }}
9
+ {% endfor %}
@@ -0,0 +1,68 @@
1
+ {#- Rendered once per app by the platform app role. The jobs run the platform
2
+ library's pipelines; the app's repo is only ever checked out as data. -#}
3
+ {% set coords = {
4
+ 'name': app.name, 'repo': app_repo, 'ref': app_ref, 'path': app.path | default('.'),
5
+ 'hosts': manifest.subdomains | map('regex_replace', '$', '.' ~ domain) | list,
6
+ 'secrets': manifest.secrets | default([]),
7
+ } -%}
8
+ {% set literal = coords | to_json | regex_replace('^\\{', '[') | regex_replace('\\}$', ']') | regex_replace('"(\\w+)": ', '\\1: ') %}
9
+ # =============================================================================
10
+ # {{ app.name }} — generated by the platform app role. Do not edit on the server.
11
+ # =============================================================================
12
+
13
+ credentials:
14
+ system:
15
+ domainCredentials:
16
+ - credentials:
17
+ {% for key in manifest.secrets | default([]) %}
18
+ - string:
19
+ scope: GLOBAL
20
+ id: "{{ app.name }}-{{ key | lower | replace('_', '-') }}"
21
+ description: "{{ app.name }}: {{ key }}"
22
+ secret: "${readFile:/run/app-secrets/{{ app.name }}/{{ key }}}"
23
+ {% endfor %}
24
+
25
+ jobs:
26
+ - script: |
27
+ folder("{{ app.name }}") {
28
+ displayName("{{ app.name }}")
29
+ }
30
+
31
+ pipelineJob("{{ app.name }}/build") {
32
+ displayName("Build")
33
+ description("Builds {{ app.name }}'s images from {{ app_ref }} on every push (GitHub webhook, hourly poll as fallback); any other branch on demand. A green {{ app_ref }} build deploys that exact commit.")
34
+ logRotator { numToKeep(20) }
35
+ // Declared here as well as in the pipeline: a JCasC reload regenerates
36
+ // the job from this DSL, and parameters only the pipeline declared
37
+ // would be gone — Jenkins then silently drops the ones passed in.
38
+ parameters {
39
+ stringParam("BRANCH", "{{ app_ref }}", "Branch, tag or commit to build")
40
+ }
41
+ definition {
42
+ cps {
43
+ script('''@Library('platform') _
44
+ appBuild({{ literal }})''')
45
+ sandbox(true)
46
+ }
47
+ }
48
+ triggers {
49
+ githubPush()
50
+ scm("H * * * *")
51
+ }
52
+ }
53
+
54
+ pipelineJob("{{ app.name }}/deploy") {
55
+ displayName("Deploy")
56
+ description("Deploys any branch, tag or commit of {{ app.name }}: builds missing images, admits the compose config against the platform policy, runs it, records the release. Rollback starts it with an older SHA.")
57
+ logRotator { numToKeep(50) }
58
+ parameters {
59
+ stringParam("REF", "{{ app_ref }}", "Branch, tag or commit SHA to deploy")
60
+ }
61
+ definition {
62
+ cps {
63
+ script('''@Library('platform') _
64
+ appDeploy({{ literal }})''')
65
+ sandbox(true)
66
+ }
67
+ }
68
+ }
@@ -0,0 +1,9 @@
1
+ # Managed by Ansible (backup-check role).
2
+ [Unit]
3
+ Description=Report stale backups as GitHub issues
4
+ After=network-online.target
5
+ Wants=network-online.target
6
+
7
+ [Service]
8
+ Type=oneshot
9
+ ExecStart=/opt/backup-check/bin/backup-check
@@ -0,0 +1,10 @@
1
+ # Managed by Ansible (backup-check role).
2
+ [Unit]
3
+ Description=Hourly check for stale backups
4
+
5
+ [Timer]
6
+ OnCalendar=hourly
7
+ Persistent=true
8
+
9
+ [Install]
10
+ WantedBy=timers.target
@@ -0,0 +1,59 @@
1
+ ---
2
+ # =============================================================================
3
+ # backup-check role — hourly, asks each of backup_check.repositories (a restic
4
+ # repository served read-only over HTTPS) for its newest snapshot, and
5
+ # reports one older than stale_after_hours as a GitHub issue, and likewise a
6
+ # failed or overdue restore test (restore_test_max_days). Runs off the
7
+ # backed-up network on purpose: it notices a source that has gone quiet.
8
+ # =============================================================================
9
+
10
+ - name: Install jq
11
+ ansible.builtin.apt:
12
+ name: jq
13
+
14
+ - name: Create backup check directories
15
+ ansible.builtin.file:
16
+ path: "/opt/backup-check/{{ item }}"
17
+ state: directory
18
+ owner: root
19
+ group: root
20
+ mode: "0700"
21
+ loop: ["", bin, state]
22
+
23
+ - name: Install GitHub App key
24
+ ansible.builtin.copy:
25
+ content: "{{ backup_check_github_app_key }}"
26
+ dest: /opt/backup-check/github-app.pem
27
+ mode: "0600"
28
+ no_log: true
29
+
30
+ # curl reads the logins from here, so they never show in a process list.
31
+ - name: Render repository logins
32
+ ansible.builtin.copy:
33
+ content: |
34
+ {% for repository in backup_check.repositories %}
35
+ machine {{ repository.url | urlsplit('hostname') }} login {{ repository.user }} password {{ backup_check_passwords[repository.name] }}
36
+ {% endfor %}
37
+ dest: /opt/backup-check/netrc
38
+ mode: "0600"
39
+ no_log: true
40
+
41
+ - name: Render backup check
42
+ ansible.builtin.template:
43
+ src: backup-check.sh.j2
44
+ dest: /opt/backup-check/bin/backup-check
45
+ mode: "0700"
46
+
47
+ - name: Install backup check units
48
+ ansible.builtin.copy:
49
+ src: "{{ item }}"
50
+ dest: "/etc/systemd/system/{{ item }}"
51
+ mode: "0644"
52
+ loop: [backup-check.service, backup-check.timer]
53
+
54
+ - name: Enable backup check timer
55
+ ansible.builtin.systemd:
56
+ name: backup-check.timer
57
+ enabled: true
58
+ state: started
59
+ daemon_reload: true