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