@yunuseon/kayra 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +80 -2
  3. package/ansible/.ansible-lint +13 -0
  4. package/ansible/ansible.cfg +12 -0
  5. package/ansible/playbooks/adopt.yml +35 -0
  6. package/ansible/playbooks/app.yml +21 -0
  7. package/ansible/playbooks/apps-prune.yml +30 -0
  8. package/ansible/playbooks/edge.yml +41 -0
  9. package/ansible/playbooks/nas.yml +47 -0
  10. package/ansible/playbooks/platform.yml +62 -0
  11. package/ansible/requirements.yml +7 -0
  12. package/ansible/roles/app/defaults/main.yml +9 -0
  13. package/ansible/roles/app/tasks/main.yml +107 -0
  14. package/ansible/roles/app/templates/app.env.j2 +9 -0
  15. package/ansible/roles/app/templates/casc-app.yaml.j2 +68 -0
  16. package/ansible/roles/backup-check/files/backup-check.service +9 -0
  17. package/ansible/roles/backup-check/files/backup-check.timer +10 -0
  18. package/ansible/roles/backup-check/tasks/main.yml +59 -0
  19. package/ansible/roles/backup-check/templates/backup-check.sh.j2 +117 -0
  20. package/ansible/roles/common/handlers/main.yml +16 -0
  21. package/ansible/roles/common/tasks/main.yml +100 -0
  22. package/ansible/roles/compose-stacks/tasks/main.yml +74 -0
  23. package/ansible/roles/docker/defaults/main.yml +3 -0
  24. package/ansible/roles/docker/files/buildkit-rootless +23 -0
  25. package/ansible/roles/docker/handlers/main.yml +12 -0
  26. package/ansible/roles/docker/tasks/main.yml +82 -0
  27. package/ansible/roles/edge-proxy/files/docker-compose.yml +13 -0
  28. package/ansible/roles/edge-proxy/handlers/main.yml +6 -0
  29. package/ansible/roles/edge-proxy/tasks/main.yml +34 -0
  30. package/ansible/roles/edge-proxy/templates/Caddyfile.j2 +25 -0
  31. package/ansible/roles/forgejo-config/tasks/main.yml +292 -0
  32. package/ansible/roles/platform/defaults/main.yml +76 -0
  33. package/ansible/roles/platform/tasks/main.yml +298 -0
  34. package/ansible/roles/platform/templates/authelia-users.yml.j2 +17 -0
  35. package/ansible/roles/platform/templates/platform.env.j2 +41 -0
  36. package/ansible/roles/restic-backup/files/docker-compose.postgres.yml +27 -0
  37. package/ansible/roles/restic-backup/files/docker-compose.yml +12 -0
  38. package/ansible/roles/restic-backup/handlers/main.yml +7 -0
  39. package/ansible/roles/restic-backup/tasks/main.yml +121 -0
  40. package/ansible/roles/restic-backup/templates/backup.env.j2 +9 -0
  41. package/ansible/roles/restic-backup/templates/backup.sh.j2 +28 -0
  42. package/ansible/roles/restic-backup/templates/docker-compose.override.yml.j2 +26 -0
  43. package/ansible/roles/restic-backup/templates/follow-restore-test.sh.j2 +28 -0
  44. package/ansible/roles/restic-backup/templates/restore-test.sh.j2 +51 -0
  45. package/ansible/roles/restic-backup/templates/root.crontab.j2 +6 -0
  46. package/ansible/roles/restic-backup/templates/ssh_config.j2 +12 -0
  47. package/ansible/roles/wireguard/handlers/main.yml +9 -0
  48. package/ansible/roles/wireguard/tasks/main.yml +33 -0
  49. package/ansible/roles/wireguard/templates/wg0.conf.j2 +12 -0
  50. package/bin/kayra.js +29 -0
  51. package/cli/dist/brand.js +41 -0
  52. package/cli/dist/cli.js +21 -0
  53. package/cli/dist/commands/doctor.js +79 -0
  54. package/cli/dist/commands/server.js +215 -0
  55. package/cli/dist/commands/status.js +43 -0
  56. package/cli/dist/fleet.js +134 -0
  57. package/cli/dist/interpret.js +98 -0
  58. package/cli/dist/load.js +49 -0
  59. package/cli/dist/main.js +94 -0
  60. package/cli/dist/plan.js +31 -0
  61. package/cli/dist/result.js +5 -0
  62. package/cloud-init.yml.tpl +14 -0
  63. package/compose/authelia/configuration.yml +138 -0
  64. package/compose/buildkit/buildkitd.toml +14 -0
  65. package/compose/docker-compose.acme-dns.yml +12 -0
  66. package/compose/docker-compose.ci.yml +156 -0
  67. package/compose/docker-compose.yml +83 -0
  68. package/compose/jenkins/Dockerfile +40 -0
  69. package/compose/jenkins/casc/00-platform.yaml +158 -0
  70. package/compose/jenkins/plugins.txt +97 -0
  71. package/compose/jenkins-agent/Dockerfile +24 -0
  72. package/compose/traefik/dynamic/authelia.yml +18 -0
  73. package/compose/traefik/dynamic/middlewares.yml +14 -0
  74. package/compose/traefik/dynamic/ratelimit.yml +21 -0
  75. package/compose/traefik/dynamic/tls.yml +9 -0
  76. package/compose/traefik/traefik.yml +46 -0
  77. package/hosts.conf +6 -0
  78. package/package.json +73 -4
  79. package/scripts/local/app-manifests.sh +26 -0
  80. package/scripts/local/authelia-dev-init.sh +58 -0
  81. package/scripts/local/certs.sh +55 -0
  82. package/scripts/local/home-image.sh +55 -0
  83. package/scripts/local/home-reach.sh +41 -0
  84. package/scripts/local/require-main.sh +38 -0
  85. package/scripts/local/setup-hosts.sh +30 -0
  86. package/scripts/server/app-purge +26 -0
  87. package/scripts/server/app-remove +30 -0
  88. package/scripts/server/jenkins-api +28 -0
  89. package/scripts/server/jenkins-app-apply +36 -0
  90. package/scripts/server/jenkins-reload +12 -0
  91. package/terraform/modules/hcloud-firewall/main.tf +54 -0
  92. package/terraform/modules/hcloud-firewall/outputs.tf +9 -0
  93. package/terraform/modules/hcloud-firewall/variables.tf +22 -0
  94. package/terraform/modules/hcloud-server/main.tf +62 -0
  95. package/terraform/modules/hcloud-server/outputs.tf +19 -0
  96. package/terraform/modules/hcloud-server/templates/inventory.ini.tpl +5 -0
  97. package/terraform/modules/hcloud-server/variables.tf +50 -0
  98. package/terraform/modules/multipass-instance/main.tf +67 -0
  99. package/terraform/modules/multipass-instance/outputs.tf +19 -0
  100. package/terraform/modules/multipass-instance/templates/inventory.ini.tpl +5 -0
  101. package/terraform/modules/multipass-instance/variables.tf +39 -0
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env bash
2
+ # Generated by Ansible (backup-check role). Hourly, from backup-check.timer.
3
+ #
4
+ # Opens a GitHub issue when a repository's newest snapshot is older than
5
+ # {{ backup_check.stale_after_hours }} hours, or (with restore_test_max_days) when its last restore
6
+ # test failed or is overdue, and closes it once that has passed. GitHub is
7
+ # only told about changes, which state/ records. The issue comes from a GitHub
8
+ # App, not a personal token: GitHub notifies no one about their own actions.
9
+ set -euo pipefail
10
+
11
+ state=/opt/backup-check/state
12
+ repo={{ backup_check.github_repository | quote }}
13
+ stale_after=$(( {{ backup_check.stale_after_hours | int }} * 3600 ))
14
+
15
+ b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
16
+
17
+ github_token() {
18
+ local now header payload jwt installation
19
+ now=$(date +%s)
20
+ header=$(printf '{"alg":"RS256","typ":"JWT"}' | b64url)
21
+ payload=$(printf '{"iat":%d,"exp":%d,"iss":"%s"}' $((now - 60)) $((now + 540)) {{ backup_check.github_app_id | quote }} | b64url)
22
+ jwt="${header}.${payload}.$(printf '%s.%s' "$header" "$payload" \
23
+ | openssl dgst -sha256 -sign /opt/backup-check/github-app.pem | b64url)"
24
+ installation=$(github "$jwt" "https://api.github.com/repos/${repo}/installation" | jq -r .id)
25
+ github "$jwt" -X POST "https://api.github.com/app/installations/${installation}/access_tokens" | jq -r .token
26
+ }
27
+
28
+ github() {
29
+ local token="$1"; shift
30
+ curl -fsS -H "Authorization: Bearer ${token}" -H "Accept: application/vnd.github+json" "$@"
31
+ }
32
+
33
+ # A file's Last-Modified as epoch seconds; nothing if the file is missing.
34
+ modified() {
35
+ local header status
36
+ header=$(curl -sSI --netrc-file /opt/backup-check/netrc "$1")
37
+ status=$(printf '%s\n' "$header" | head -1 | cut -d' ' -f2)
38
+ case "$status" in
39
+ 200) date -d "$(printf '%s\n' "$header" | sed -n 's/^last-modified: *//Ip' | tr -d '\r')" +%s ;;
40
+ 404) ;;
41
+ *) echo "$1: HTTP ${status}" >&2; return 1 ;;
42
+ esac
43
+ }
44
+
45
+ # The newest snapshot's upload time; nothing without one. A read-only Storage
46
+ # Box sub-account answers no PROPFIND, only Apache's directory listing: sorted
47
+ # newest first, then that file's Last-Modified.
48
+ newest_snapshot() {
49
+ local url="${1%/}/snapshots/" name
50
+ name=$(curl -fsS --netrc-file /opt/backup-check/netrc "${url}?C=M;O=D" \
51
+ | grep -oE 'href="[0-9a-f]{64}"' | head -1 | cut -d'"' -f2)
52
+ [ -z "$name" ] || modified "${url}${name}"
53
+ }
54
+
55
+ when() { date -d "@$1" '+%Y-%m-%d %H:%M %Z'; }
56
+
57
+ # One issue per key while its problem lasts; state/<key> holds its number.
58
+ open_issue() {
59
+ local key="$1" title="$2" body="$3"
60
+ [ ! -f "${state}/${key}" ] || return 0
61
+ github "$(github_token)" -X POST "https://api.github.com/repos/${repo}/issues" \
62
+ -d "$(jq -n --arg title "$title" --arg body "${body}
63
+
64
+ cc @${repo%%/*}" '{title: $title, body: $body}')" \
65
+ | jq -r .number > "${state}/${key}"
66
+ }
67
+
68
+ close_issue() {
69
+ local key="$1" comment="$2" token issue
70
+ [ -f "${state}/${key}" ] || return 0
71
+ token=$(github_token)
72
+ issue="https://api.github.com/repos/${repo}/issues/$(cat "${state}/${key}")"
73
+ github "$token" -X POST "${issue}/comments" -d "$(jq -n --arg body "$comment" '{body: $body}')" >/dev/null
74
+ github "$token" -X PATCH "$issue" -d '{"state": "closed", "state_reason": "completed"}' >/dev/null
75
+ rm "${state}/${key}"
76
+ }
77
+
78
+ check_snapshots() {
79
+ local name="$1" url="$2" newest last
80
+ newest=$(newest_snapshot "$url")
81
+ if [ $(( $(date +%s) - ${newest:-0} )) -gt "$stale_after" ]; then
82
+ if [ -n "$newest" ]; then last="The newest snapshot is from $(when "$newest")."; else last="There is no snapshot yet."; fi
83
+ open_issue "$name" "Backup stale: ${name}" "No backup of \`${name}\` has reached its repository for more than {{ backup_check.stale_after_hours }} hours. ${last}
84
+
85
+ Look at the backup container's log on the source. This issue closes itself when the next snapshot arrives."
86
+ else
87
+ close_issue "$name" "A new snapshot arrived."
88
+ fi
89
+ }
90
+
91
+ # The source uploads restore-test.passed or .failed next to the repository
92
+ # after each restore test (restic-backup role).
93
+ check_restore_test() {
94
+ local name="$1" url="${2%/}" max_days="$3" passed failed problem last
95
+ passed=$(modified "${url}/restore-test.passed")
96
+ failed=$(modified "${url}/restore-test.failed")
97
+ if [ "${failed:-0}" -gt "${passed:-0}" ]; then
98
+ problem="The last restore test of \`${name}\` failed, at $(when "$failed")."
99
+ elif [ $(( $(date +%s) - ${passed:-0} )) -gt $(( max_days * 86400 )) ]; then
100
+ if [ -n "$passed" ]; then last="The last one passed at $(when "$passed")."; else last="None has passed yet."; fi
101
+ problem="No restore test of \`${name}\` has passed for more than ${max_days} days. ${last}"
102
+ fi
103
+ if [ -n "${problem:-}" ]; then
104
+ open_issue "${name}-restore-test" "Restore test: ${name}" "${problem}
105
+
106
+ Its log is log/restore-test.log in the backup directory on the source; \`kayra backup test ${name}\` runs one by hand and follows it. This issue closes itself when the next one passes."
107
+ else
108
+ close_issue "${name}-restore-test" "A restore test passed."
109
+ fi
110
+ }
111
+
112
+ {% for repository in backup_check.repositories %}
113
+ check_snapshots {{ repository.name | quote }} {{ repository.url | quote }}
114
+ {% if repository.restore_test_max_days is defined %}
115
+ check_restore_test {{ repository.name | quote }} {{ repository.url | quote }} {{ repository.restore_test_max_days | int }}
116
+ {% endif %}
117
+ {% endfor %}
@@ -0,0 +1,16 @@
1
+ ---
2
+ # =============================================================================
3
+ # Common role — handlers
4
+ # =============================================================================
5
+
6
+ - name: Reload sysctl
7
+ ansible.builtin.command:
8
+ cmd: sysctl --system
9
+ become: true
10
+ changed_when: true
11
+
12
+ - name: Restart ssh
13
+ ansible.builtin.systemd:
14
+ name: ssh
15
+ state: restarted
16
+ become: true
@@ -0,0 +1,100 @@
1
+ ---
2
+ # =============================================================================
3
+ # Common role — Base system setup
4
+ # =============================================================================
5
+
6
+ # A refreshed cache changes nothing on the server: not worth a changed.
7
+ - name: Update apt cache
8
+ ansible.builtin.apt:
9
+ update_cache: true
10
+ cache_valid_time: 3600
11
+ become: true
12
+ changed_when: false
13
+
14
+ - name: Install essential packages
15
+ ansible.builtin.apt:
16
+ name:
17
+ - ca-certificates
18
+ - curl
19
+ - python3
20
+ # deb822_repository in the docker role needs it on the target.
21
+ - python3-debian
22
+ state: present
23
+ become: true
24
+
25
+ - name: Set hostname
26
+ ansible.builtin.hostname:
27
+ name: "{{ inventory_hostname }}"
28
+ become: true
29
+
30
+ - name: Read timezone
31
+ ansible.builtin.command: timedatectl show --property=Timezone --value
32
+ register: current_timezone
33
+ changed_when: false
34
+
35
+ - name: Set timezone
36
+ ansible.builtin.command: timedatectl set-timezone {{ timezone | default('Europe/Berlin') }}
37
+ when: current_timezone.stdout != timezone | default('Europe/Berlin')
38
+ changed_when: true
39
+ become: true
40
+
41
+ - name: Harden SSH
42
+ ansible.builtin.copy:
43
+ content: |
44
+ PermitRootLogin no
45
+ PasswordAuthentication no
46
+ # 10- sorts before cloud-init's 50-cloud-init.conf, and sshd keeps the
47
+ # first value it reads for each keyword.
48
+ dest: /etc/ssh/sshd_config.d/10-hardening.conf
49
+ mode: "0644"
50
+ become: true
51
+ notify: Restart ssh
52
+
53
+ # The box runs two JVMs and builds images while serving production. Swap is not
54
+ # a substitute for RAM — it stops the OOM killer picking a random victim
55
+ # (postgres, say) during a Jenkins build.
56
+ - name: Create swapfile
57
+ ansible.builtin.shell: fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile
58
+ args:
59
+ creates: /swapfile
60
+ become: true
61
+
62
+ - name: Persist swapfile in fstab
63
+ ansible.builtin.lineinfile:
64
+ path: /etc/fstab
65
+ line: "/swapfile none swap sw 0 0"
66
+ regexp: '^/swapfile\s'
67
+ become: true
68
+
69
+ - name: Enable swap
70
+ ansible.builtin.command: swapon --all
71
+ changed_when: false
72
+ become: true
73
+
74
+ # Prefer reclaiming page cache over swapping; swap here is an emergency
75
+ # backstop, not something we want the kernel reaching for routinely.
76
+ - name: Set vm.swappiness
77
+ ansible.builtin.copy:
78
+ content: "vm.swappiness=10\n"
79
+ dest: /etc/sysctl.d/99-swappiness.conf
80
+ mode: "0644"
81
+ become: true
82
+ notify: Reload sysctl
83
+
84
+ # Ubuntu's unattended-upgrades installs security fixes but never reboots, so a
85
+ # patched kernel or libc sits unused until someone logs in and notices. Every
86
+ # container is restart: unless-stopped, so a 04:00 reboot costs a minute.
87
+ - name: Install unattended-upgrades
88
+ ansible.builtin.apt:
89
+ name: unattended-upgrades
90
+ state: present
91
+ become: true
92
+
93
+ - name: Reboot after unattended upgrades when required
94
+ ansible.builtin.copy:
95
+ content: |
96
+ Unattended-Upgrade::Automatic-Reboot "true";
97
+ Unattended-Upgrade::Automatic-Reboot-Time "04:00";
98
+ dest: /etc/apt/apt.conf.d/52unattended-reboot
99
+ mode: "0644"
100
+ become: true
@@ -0,0 +1,74 @@
1
+ ---
2
+ # =============================================================================
3
+ # compose-stacks role — runs the server's own compose stacks: for each name in
4
+ # compose_stacks.stacks, servers/<server>/stacks/<name>/ (compose.yaml and any
5
+ # files it mounts) goes to <dir>/<name>/, its secrets
6
+ # (compose_stack_secrets.<name>) to a .env next to it, and `docker compose up -d` brings it to that state. Data lives wherever
7
+ # each compose file mounts it; this role writes nothing but <dir>.
8
+ #
9
+ # Never deletes: a stack dropped from the list keeps running until it is
10
+ # taken down by hand (docker compose -p <name> down).
11
+ # =============================================================================
12
+
13
+ - name: Create stack directories
14
+ ansible.builtin.file:
15
+ path: "{{ compose_stacks.dir }}/{{ item }}"
16
+ state: directory
17
+ owner: root
18
+ group: root
19
+ mode: "0700"
20
+ loop: "{{ [''] + compose_stacks.stacks }}"
21
+
22
+ # The whole stack folder: compose.yaml and any files it mounts (./…), which
23
+ # the containers read whatever user they run as.
24
+ - name: Copy stack files
25
+ ansible.builtin.copy:
26
+ src: "{{ server_dir }}/stacks/{{ item }}/"
27
+ dest: "{{ compose_stacks.dir }}/{{ item }}/"
28
+ mode: "0644"
29
+ directory_mode: "0755"
30
+ register: stack_files
31
+ loop: "{{ compose_stacks.stacks }}"
32
+
33
+ # Single-quoted: Compose reads those literally, without escapes or ${…}.
34
+ - name: Assert secrets fit in a .env
35
+ ansible.builtin.assert:
36
+ that: "\"'\" not in item.value | string and '\n' not in item.value | string"
37
+ fail_msg: "a secret in compose_stack_secrets contains a single quote or newline"
38
+ quiet: true
39
+ loop: "{{ compose_stack_secrets | default({}) | dict2items | map(attribute='value') | map('dict2items') | flatten }}"
40
+ loop_control:
41
+ label: "{{ item.key }}"
42
+ no_log: true
43
+
44
+ - name: Render secrets
45
+ ansible.builtin.copy:
46
+ content: |
47
+ {% for key, value in compose_stack_secrets[item].items() %}
48
+ {{ key }}='{{ value }}'
49
+ {% endfor %}
50
+ dest: "{{ compose_stacks.dir }}/{{ item }}/.env"
51
+ mode: "0600"
52
+ loop: "{{ compose_stacks.stacks | select('in', compose_stack_secrets | default({})) }}"
53
+ no_log: true
54
+
55
+ - name: Start stacks
56
+ community.docker.docker_compose_v2:
57
+ project_src: "{{ compose_stacks.dir }}/{{ item }}"
58
+ project_name: "{{ item }}"
59
+ files: [compose.yaml]
60
+ register: compose_up
61
+ loop: "{{ compose_stacks.stacks }}"
62
+
63
+ # `up` recreates a container only when its compose definition changed, not
64
+ # when a file it mounts did (a Caddyfile, Homepage's config): those take
65
+ # effect on a restart.
66
+ - name: Restart stacks whose files changed
67
+ ansible.builtin.command: docker compose --project-name {{ item.item }} --file compose.yaml restart
68
+ args:
69
+ chdir: "{{ compose_stacks.dir }}/{{ item.item }}"
70
+ changed_when: true
71
+ when: item.changed and not (compose_up.results | selectattr('item', 'eq', item.item) | first).changed
72
+ loop: "{{ stack_files.results }}"
73
+ loop_control:
74
+ label: "{{ item.item }}"
@@ -0,0 +1,3 @@
1
+ ---
2
+ # apt version pattern for docker-ce/docker-ce-cli: a major, held.
3
+ docker_version: "5:29*"
@@ -0,0 +1,23 @@
1
+ # AppArmor profile for the rootless BuildKit container.
2
+ #
3
+ # Ubuntu 23.10+ refuses unprivileged user namespaces to processes running
4
+ # unconfined (kernel.apparmor_restrict_unprivileged_userns=1) — which is
5
+ # precisely what rootlesskit needs in order to start. BuildKit's own docs
6
+ # suggest turning that sysctl off; this grants the capability to one container
7
+ # instead, and leaves the protection in place for everything else on the host.
8
+ #
9
+ # flags=(unconfined) keeps the permissive behaviour BuildKit requires (mount,
10
+ # unshare, and friends). Being a *named* profile rather than genuinely
11
+ # unconfined is what makes the userns grant possible at all — the restriction
12
+ # applies only to unconfined processes.
13
+ #
14
+ # Compose applies it by name: security_opt: apparmor=buildkit-rootless.
15
+ abi <abi/4.0>,
16
+
17
+ include <tunables/global>
18
+
19
+ profile buildkit-rootless flags=(unconfined) {
20
+ userns,
21
+
22
+ include if exists <local/buildkit-rootless>
23
+ }
@@ -0,0 +1,12 @@
1
+ ---
2
+ - name: Restart Docker
3
+ ansible.builtin.systemd:
4
+ name: docker
5
+ state: restarted
6
+ become: true
7
+
8
+ - name: Reload AppArmor profiles
9
+ ansible.builtin.command:
10
+ cmd: apparmor_parser -r -W /etc/apparmor.d/buildkit-rootless
11
+ changed_when: true
12
+ become: true
@@ -0,0 +1,82 @@
1
+ ---
2
+ # =============================================================================
3
+ # Docker role — Install Docker CE + Compose plugin
4
+ # =============================================================================
5
+
6
+ - name: Add Docker apt repository
7
+ ansible.builtin.deb822_repository:
8
+ name: docker
9
+ uris: https://download.docker.com/linux/ubuntu
10
+ suites: "{{ ansible_facts.distribution_release }}"
11
+ components: stable
12
+ signed_by: https://download.docker.com/linux/ubuntu/gpg
13
+ become: true
14
+
15
+ # Pinned to a major and held, so no apt upgrade, unattended or manual, moves
16
+ # it; a new major is a tested change of docker_version.
17
+ - name: Install Docker CE {{ docker_version }}
18
+ ansible.builtin.apt:
19
+ name:
20
+ - docker-ce={{ docker_version }}
21
+ - docker-ce-cli={{ docker_version }}
22
+ - containerd.io
23
+ - docker-compose-plugin
24
+ update_cache: true
25
+ allow_change_held_packages: true
26
+ become: true
27
+
28
+ - name: Hold Docker
29
+ ansible.builtin.dpkg_selections:
30
+ name: "{{ item }}"
31
+ selection: hold
32
+ loop: [docker-ce, docker-ce-cli]
33
+ become: true
34
+
35
+ # Unrotated json-file logs grow until the disk is full. `local` is compressed
36
+ # and rotated; it applies to containers created after the change.
37
+ - name: Configure the Docker daemon
38
+ ansible.builtin.copy:
39
+ content: "{{ {'log-driver': 'local', 'log-opts': {'max-size': '10m', 'max-file': '3'}} | to_nice_json }}\n"
40
+ dest: /etc/docker/daemon.json
41
+ owner: root
42
+ group: root
43
+ mode: "0644"
44
+ become: true
45
+ notify: Restart Docker
46
+
47
+ # Now, not at the end of the play: in a single-play playbook (edge) the
48
+ # containers started later in the play would otherwise be created before the
49
+ # daemon picks up its config, and keep the old log driver.
50
+ - name: Apply the daemon config before any container starts
51
+ ansible.builtin.meta: flush_handlers
52
+
53
+ - name: Ensure Docker is running
54
+ ansible.builtin.systemd:
55
+ name: docker
56
+ state: started
57
+ enabled: true
58
+ become: true
59
+
60
+ - name: Add deploy user to docker group
61
+ ansible.builtin.user:
62
+ name: deploy
63
+ groups: docker
64
+ append: true
65
+ become: true
66
+
67
+ # Group membership only applies to new logins, and Ansible reuses its SSH
68
+ # connection — without this the platform play cannot reach the docker socket.
69
+ - name: Reconnect so the docker group takes effect
70
+ ansible.builtin.meta: reset_connection
71
+
72
+ # Must be loaded before the platform play starts buildkitd — handlers flush at
73
+ # the end of this play, which is the bootstrap one, so the ordering holds.
74
+ - name: Install AppArmor profile for rootless BuildKit
75
+ ansible.builtin.copy:
76
+ src: buildkit-rootless
77
+ dest: /etc/apparmor.d/buildkit-rootless
78
+ owner: root
79
+ group: root
80
+ mode: "0644"
81
+ become: true
82
+ notify: Reload AppArmor profiles
@@ -0,0 +1,13 @@
1
+ # Managed by Ansible (edge-proxy role).
2
+ services:
3
+ caddy:
4
+ image: caddy:2.11.7
5
+ restart: unless-stopped
6
+ ports:
7
+ - "80:80"
8
+ - "443:443"
9
+ - "443:443/udp"
10
+ volumes:
11
+ - ./caddy:/etc/caddy:ro
12
+ - ./data:/data
13
+ - ./config:/config
@@ -0,0 +1,6 @@
1
+ ---
2
+ - name: Reload Caddy
3
+ ansible.builtin.command: docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile
4
+ args:
5
+ chdir: /opt/edge-proxy
6
+ changed_when: true
@@ -0,0 +1,34 @@
1
+ ---
2
+ # =============================================================================
3
+ # Edge proxy role — Caddy in front of whatever edge_routes point at (upstreams
4
+ # on the far side of the WireGuard tunnel). Caddy gets its certs itself.
5
+ # =============================================================================
6
+
7
+ - name: Create edge proxy directories
8
+ ansible.builtin.file:
9
+ path: "/opt/edge-proxy/{{ item }}"
10
+ state: directory
11
+ owner: root
12
+ group: root
13
+ mode: "0755"
14
+ loop: ["", caddy, data, config]
15
+
16
+ - name: Copy compose file
17
+ ansible.builtin.copy:
18
+ src: docker-compose.yml
19
+ dest: /opt/edge-proxy/docker-compose.yml
20
+ mode: "0644"
21
+
22
+ # Into caddy/, which is mounted as a directory: Ansible replaces files
23
+ # atomically (new inode), and a single-file bind mount would keep showing the
24
+ # container the old one.
25
+ - name: Render Caddyfile
26
+ ansible.builtin.template:
27
+ src: Caddyfile.j2
28
+ dest: /opt/edge-proxy/caddy/Caddyfile
29
+ mode: "0644"
30
+ notify: Reload Caddy
31
+
32
+ - name: Start Caddy
33
+ community.docker.docker_compose_v2:
34
+ project_src: /opt/edge-proxy
@@ -0,0 +1,25 @@
1
+ # Generated by Ansible from edge_routes in servers/<server>/vars.yml.
2
+ {
3
+ email {{ acme_email }}
4
+
5
+ # Avoid QUIC/HTTP3 surprises on corporate networks.
6
+ servers {
7
+ protocols h1 h2
8
+ }
9
+ }
10
+ {% for route in edge_routes %}
11
+
12
+ {{ route.subdomains | map('regex_replace', '$', '.' ~ domain) | join(',\n') }} {
13
+ encode zstd gzip
14
+ {% if route.forwarded_headers | default(false) %}
15
+ reverse_proxy {{ route.upstream }} {
16
+ header_up Host {host}
17
+ header_up X-Forwarded-Proto https
18
+ header_up X-Forwarded-For {remote_host}
19
+ header_up X-Forwarded-Port 443
20
+ }
21
+ {% else %}
22
+ reverse_proxy {{ route.upstream }}
23
+ {% endif %}
24
+ }
25
+ {% endfor %}