@yunuseon/kayra 0.0.0-stage → 0.5.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 +81 -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 +102 -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 +333 -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
|
@@ -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,102 @@
|
|
|
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
|
+
# Private images: compose_registry_logins (in secrets.yml, a list of
|
|
13
|
+
# {registry, username, password}) are logged in with a Docker config kept in
|
|
14
|
+
# <dir>/.docker, which every compose call here uses (DOCKER_CONFIG) — the
|
|
15
|
+
# host's own Docker config is never written.
|
|
16
|
+
# =============================================================================
|
|
17
|
+
|
|
18
|
+
- name: Create stack directories
|
|
19
|
+
ansible.builtin.file:
|
|
20
|
+
path: "{{ compose_stacks.dir }}/{{ item }}"
|
|
21
|
+
state: directory
|
|
22
|
+
owner: root
|
|
23
|
+
group: root
|
|
24
|
+
mode: "0700"
|
|
25
|
+
loop: "{{ [''] + compose_stacks.stacks }}"
|
|
26
|
+
|
|
27
|
+
# The whole stack folder: compose.yaml and any files it mounts (./…), which
|
|
28
|
+
# the containers read whatever user they run as.
|
|
29
|
+
- name: Copy stack files
|
|
30
|
+
ansible.builtin.copy:
|
|
31
|
+
src: "{{ server_dir }}/stacks/{{ item }}/"
|
|
32
|
+
dest: "{{ compose_stacks.dir }}/{{ item }}/"
|
|
33
|
+
mode: "0644"
|
|
34
|
+
directory_mode: "0755"
|
|
35
|
+
register: stack_files
|
|
36
|
+
loop: "{{ compose_stacks.stacks }}"
|
|
37
|
+
|
|
38
|
+
# Single-quoted: Compose reads those literally, without escapes or ${…}.
|
|
39
|
+
- name: Assert secrets fit in a .env
|
|
40
|
+
ansible.builtin.assert:
|
|
41
|
+
that: "\"'\" not in item.value | string and '\n' not in item.value | string"
|
|
42
|
+
fail_msg: "a secret in compose_stack_secrets contains a single quote or newline"
|
|
43
|
+
quiet: true
|
|
44
|
+
loop: "{{ compose_stack_secrets | default({}) | dict2items | map(attribute='value') | map('dict2items') | flatten }}"
|
|
45
|
+
loop_control:
|
|
46
|
+
label: "{{ item.key }}"
|
|
47
|
+
no_log: true
|
|
48
|
+
|
|
49
|
+
- name: Render secrets
|
|
50
|
+
ansible.builtin.copy:
|
|
51
|
+
content: |
|
|
52
|
+
{% for key, value in compose_stack_secrets[item].items() %}
|
|
53
|
+
{{ key }}='{{ value }}'
|
|
54
|
+
{% endfor %}
|
|
55
|
+
dest: "{{ compose_stacks.dir }}/{{ item }}/.env"
|
|
56
|
+
mode: "0600"
|
|
57
|
+
loop: "{{ compose_stacks.stacks | select('in', compose_stack_secrets | default({})) }}"
|
|
58
|
+
no_log: true
|
|
59
|
+
|
|
60
|
+
- name: Create the stacks' Docker config directory
|
|
61
|
+
ansible.builtin.file:
|
|
62
|
+
path: "{{ compose_stacks.dir }}/.docker"
|
|
63
|
+
state: directory
|
|
64
|
+
owner: root
|
|
65
|
+
group: root
|
|
66
|
+
mode: "0700"
|
|
67
|
+
when: compose_registry_logins | default([]) | length > 0
|
|
68
|
+
|
|
69
|
+
- name: Log in to private registries
|
|
70
|
+
community.docker.docker_login:
|
|
71
|
+
registry_url: "{{ item.registry }}"
|
|
72
|
+
username: "{{ item.username }}"
|
|
73
|
+
password: "{{ item.password }}"
|
|
74
|
+
config_path: "{{ compose_stacks.dir }}/.docker/config.json"
|
|
75
|
+
loop: "{{ compose_registry_logins | default([]) }}"
|
|
76
|
+
loop_control: {label: "{{ item.registry }}"}
|
|
77
|
+
no_log: true
|
|
78
|
+
|
|
79
|
+
- name: Start stacks
|
|
80
|
+
environment:
|
|
81
|
+
DOCKER_CONFIG: "{{ compose_stacks.dir }}/.docker"
|
|
82
|
+
community.docker.docker_compose_v2:
|
|
83
|
+
project_src: "{{ compose_stacks.dir }}/{{ item }}"
|
|
84
|
+
project_name: "{{ item }}"
|
|
85
|
+
files: [compose.yaml]
|
|
86
|
+
register: compose_up
|
|
87
|
+
loop: "{{ compose_stacks.stacks }}"
|
|
88
|
+
|
|
89
|
+
# `up` recreates a container only when its compose definition changed, not
|
|
90
|
+
# when a file it mounts did (a Caddyfile, Homepage's config): those take
|
|
91
|
+
# effect on a restart.
|
|
92
|
+
- name: Restart stacks whose files changed
|
|
93
|
+
environment:
|
|
94
|
+
DOCKER_CONFIG: "{{ compose_stacks.dir }}/.docker"
|
|
95
|
+
ansible.builtin.command: docker compose --project-name {{ item.item }} --file compose.yaml restart
|
|
96
|
+
args:
|
|
97
|
+
chdir: "{{ compose_stacks.dir }}/{{ item.item }}"
|
|
98
|
+
changed_when: true
|
|
99
|
+
when: item.changed and not (compose_up.results | selectattr('item', 'eq', item.item) | first).changed
|
|
100
|
+
loop: "{{ stack_files.results }}"
|
|
101
|
+
loop_control:
|
|
102
|
+
label: "{{ item.item }}"
|
|
@@ -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,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 %}
|