Compare commits

...

98 Commits

Author SHA1 Message Date
Fabio Scotto di Santolo
18eb2d2eb2 Document confirmed Gitea domain transition completion 2026-10-03 15:15:21 +02:00
Fabio Scotto di Santolo
269fb13665 Document Gitea domain and retire Prometheus DuckDNS 2026-10-03 15:09:34 +02:00
Fabio Scotto di Santolo
2dfe766b7b Enable boot startup for Atlas iCloudPD 2026-10-03 13:59:05 +02:00
Fabio Scotto di Santolo
755f24bc72 Retire Prometheus Compose stack and document cleanup 2026-10-03 13:44:57 +02:00
Fabio Scotto di Santolo
7bc7f0e645 Feature/prometheus npm quadlet (#15)
* Stage Prometheus NPM Quadlet with backup-safe cutover

* Complete Prometheus NPM Quadlet cutover
2026-10-03 11:53:12 +02:00
Fabio Scotto di Santolo
1577eec19d Merge branch 'feature/gitea-https-validation' 2026-10-03 10:19:09 +02:00
Fabio Scotto di Santolo
e30683c3d1 Record Gitea HTTPS validation 2026-10-03 10:18:25 +02:00
Fabio Scotto di Santolo
4bd6aafb53 Feature/atlas icloudpd migration (#14)
* Design gated Atlas iCloudPD migration target

* Target Atlas iCloudPD photos to Photobook

* Record isolated iCloudPD Photobook ACL validation

* Record Aegis iCloudPD source audit gap

* Verify iCloudPD backup source scope and Borg access

* Record Atlas iCloudPD deployment gate checks

* Pin iCloudPD photo file and directory modes

* Validate inactive iCloudPD Quadlet on Atlas generator

* Keep iCloudPD in Archive and reserve Photobook for Immich

* Prepare guarded Aegis iCloudPD retirement

* Declare inactive Atlas iCloudPD storage and Quadlet

* Retire Aegis iCloudPD from desired state

* Clear retired Aegis iCloudPD failed-unit state

* Remove completed iCloudPD retirement tasks from Aegis

* Record initial Atlas iCloudPD service start

* Manage Atlas iCloudPD config from Vault

* Fix Atlas iCloudPD traceroute startup and config drift

* Use Atlas Vault key for iCloudPD Apple ID

* Add HEIC decoding to Fedora desktops

* Record completed iCloudPD ingestion and remaining recovery checks
2026-10-03 09:59:59 +02:00
Fabio Scotto di Santolo
9e76309833 Merge main and reconcile Atlas checklist 2026-10-02 17:54:33 +02:00
Fabio Scotto di Santolo
ed3fee06e8 Record operator-validated Gitea SSH pull and push 2026-10-02 17:47:40 +02:00
Fabio Scotto di Santolo
309d64b4ed Record successful public Gitea SSH authentication 2026-10-02 10:19:23 +02:00
Fabio Scotto di Santolo
dd33a4f55d Move Atlas Gitea Quadlet to admin with internal gitea user 2026-10-02 10:06:51 +02:00
Fabio Scotto di Santolo
0028fe8c4d Cut over Gitea HTTPS to Atlas with managed NPM upstream 2026-10-02 09:36:45 +02:00
Fabio Scotto di Santolo
12037fcc9a Enable restored rootless Gitea on Atlas 2026-10-02 09:35:44 +02:00
Fabio Scotto di Santolo
3f9a626759 Validate Gitea USB backup restore 2026-10-02 09:13:06 +02:00
Fabio Scotto di Santolo
31fedb8d44 Prepare gated Gitea HTTPS and SSH cutover 2026-10-01 22:09:57 +02:00
Fabio Scotto di Santolo
9b5ee77905 Prepare guarded final Gitea restore on Atlas 2026-10-01 22:00:41 +02:00
Fabio Scotto di Santolo
54fb7d46d7 Prepare consistent final Gitea export on Prometheus 2026-10-01 21:57:29 +02:00
Fabio Scotto di Santolo
a609e68f42 Record ZFS and Borg coverage for staged Gitea 2026-10-01 21:38:39 +02:00
Fabio Scotto di Santolo
06d3b175cb Rehearse rootless Gitea restore from verified backup 2026-10-01 21:33:18 +02:00
Fabio Scotto di Santolo
256d758b1a Prepare isolated rootless Gitea Quadlet on Atlas 2026-10-01 21:23:40 +02:00
Fabio Scotto di Santolo
9d0013769c Correct Atlas Gitea migration to rootless Quadlet 2026-10-01 21:15:42 +02:00
Fabio Scotto di Santolo
5b0f415163 Document staged Gitea migration to Atlas 2026-10-01 21:10:32 +02:00
Fabio Scotto di Santolo
3401b6137d Merge pull request #12 from fscotto/feature/atlas-navidrome-music 2026-10-01 20:59:49 +02:00
Fabio Scotto di Santolo
bae6a9f554 Record first scheduled Prometheus backup pull 2026-10-01 19:58:46 +02:00
Fabio Scotto di Santolo
c37483ba38 Track daily Atlas music copy separately in checklist 2026-10-01 10:35:41 +02:00
Fabio Scotto di Santolo
f491362365 Schedule daily Atlas Navidrome music copy 2026-10-01 10:32:48 +02:00
Fabio Scotto di Santolo
5a1047adde Document validated Atlas Navidrome music copy 2026-09-30 22:55:47 +02:00
Fabio Scotto di Santolo
802cb8c7ba Merge pull request #10 from fscotto/feature/atlas-priority2-recovery
Feature/atlas priority2 recovery
2026-09-30 21:25:36 +02:00
Fabio Scotto di Santolo
0144600a4a Add verified Prometheus backup pull to Atlas 2026-09-30 21:21:48 +02:00
Fabio Scotto di Santolo
3d2ef02c98 Keep Atlas SMB and NFS namespaces separate 2026-09-30 21:20:15 +02:00
Fabio Scotto di Santolo
8844d00e24 Define controlled Atlas kernel and OpenZFS updates 2026-09-30 21:19:48 +02:00
Fabio Scotto di Santolo
9798fe3a12 Document and rehearse Atlas disaster recovery 2026-09-30 21:19:28 +02:00
Fabio Scotto di Santolo
702283b430 Document Atlas backup and retention validation 2026-09-30 09:13:55 +02:00
Fabio Scotto di Santolo
797087c66f Fix Atlas Borg runtime permissions 2026-09-30 08:56:01 +02:00
Fabio Scotto di Santolo
fa1c8c0b82 Document Atlas NAS backups and monitoring 2026-09-25 14:06:28 +02:00
Fabio Scotto di Santolo
0b6efc9ad8 Harden Atlas USB backup cleanup and document restorecon workflow 2026-09-25 08:02:44 +02:00
Fabio Scotto di Santolo
361ee77d72 Add manual Atlas USB backup and reminder services 2026-09-24 08:55:52 +02:00
Fabio Scotto di Santolo
d4e40d423a Document deferred Cerberus management node plan 2026-09-23 09:01:18 +02:00
Fabio Scotto di Santolo
0a5c2ac1a4 Add progress logging to Atlas Borg backups 2026-09-22 21:21:21 +02:00
Fabio Scotto di Santolo
48a7f57f7e Add Cockpit storage management to Atlas and Prometheus 2026-09-22 20:49:18 +02:00
Fabio Scotto di Santolo
defa98c968 Install Cockpit Podman plugin on Atlas 2026-09-22 15:00:10 +02:00
Fabio Scotto di Santolo
21e41f4fc1 Prepare Atlas encryption dependencies and update Vault placeholders 2026-09-22 14:37:25 +02:00
Fabio Scotto di Santolo
e10c6694f8 Document populated Atlas archive and pending Borg evaluation 2026-09-21 22:15:42 +02:00
Fabio Scotto di Santolo
4c10af3187 Add openssl to Fedora base packages 2026-09-21 12:07:50 +02:00
Fabio Scotto di Santolo
3ac732751c Add pinned Java 25 to Mise on WSL workstation 2026-09-21 11:57:51 +02:00
Fabio Scotto di Santolo
e837b0059b Document validated Atlas Borg backup activation 2026-09-18 10:09:12 +02:00
Fabio Scotto di Santolo
7e498514dd Add encrypted Borg backups for Atlas 2026-09-18 10:00:45 +02:00
Fabio Scotto di Santolo
e7836ea25f Manage Atlas ZFS snapshots and scrubs 2026-09-17 23:10:53 +02:00
Fabio Scotto di Santolo
de2c24d15c Mount the shared Archive in Syncthing 2026-09-17 19:41:23 +02:00
Fabio Scotto di Santolo
bd13cb65cc Move temporary Navidrome and Syncthing services to Atlas 2026-09-17 10:18:23 +02:00
Fabio Scotto di Santolo
77afdda0a3 Move WireGuard gateway to Aegis 2026-09-17 09:08:25 +02:00
Fabio Scotto di Santolo
add75d74e9 refactor(atlas): separate service and host backup datasets 2026-09-17 00:06:46 +02:00
Fabio Scotto di Santolo
e46a2b22d7 Update Atlas NAS validation status and remaining TODOs 2026-09-16 10:25:38 +02:00
Fabio Scotto di Santolo
a97c64b08f Configure Aegis host DNS and NFS support 2026-09-16 10:19:06 +02:00
Fabio Scotto di Santolo
160d63c02d Add Atlas media and storage services [Phase 1] (#9)
* Add Atlas media and storage services

* Document Atlas backend phase one and WireGuard deployment

* Enable Atlas NAS management and document bootstrap workflow

* Harden Atlas network, SSH, firewall, and sharing

* Rotate Ansible Vault secrets

* Allow configurable Aegis SSH users and authorized keys

* Manage Aegis SSH authorized key fragments

* Manage SSH authorized key fragments for infrastructure hosts

* Harden Rocky storage and sharing configuration

* Verify WireGuard handshakes and restore Podman networking
2026-09-15 22:39:28 +02:00
Fabio Scotto di Santolo
73bf2cd62a feature: add WSL container tooling 2026-09-15 16:38:33 +02:00
Fabio Scotto di Santolo
e8d059c8ed Install ChatGPT desktop on Fedora desktops 2026-09-11 21:27:48 +02:00
Fabio Scotto di Santolo
569e6ef24e Fix desktop dotfile idempotency 2026-09-08 23:24:53 +02:00
Fabio Scotto di Santolo
ab8c51a57d Migrate Prometheus stack to Podman 2026-09-08 23:12:41 +02:00
Fabio Scotto di Santolo
7203ac6e2d Install cockpit and enable socket on prometheus 2026-09-08 19:17:22 +02:00
Fabio Scotto di Santolo
fc8df74050 Keep podman on rocky server for future Docker migration 2026-09-08 19:17:20 +02:00
Fabio Scotto di Santolo
350fc7ac95 Rename SSH host alias vps to prometheus-ubuntu 2026-09-08 18:56:00 +02:00
Fabio Scotto di Santolo
ab0bbf30ab Document DNS Filter role for Aegis 2026-09-08 18:55:54 +02:00
Fabio Scotto di Santolo
24268938bd Hotfix/duckdns token (#8)
* fix: source DuckDNS token from Vault

* chore: rotate DuckDNS Vault token
2026-09-08 18:48:13 +02:00
Fabio Scotto di Santolo
54e2917062 chore: uninstall GitHub Copilot agent 2026-09-08 10:42:25 +02:00
Fabio Scotto di Santolo
eab66b6d3d Add dormant Rocky server profile (#2)
* Add dormant Rocky server profile

* feat: activate Rocky server profile for prometheus

* fix: complete Rocky server migration tooling

* Remove FreeBSD profile

* feat: add Aegis Fedora CoreOS profile

* docs: define Git Flow branch prefixes

* Add Aegis TPM-backed btrfs storage

Co-authored-by: fscotto <17803710+fscotto@users.noreply.github.com>

* Preserve existing Aegis filesystem tables

Co-authored-by: fscotto <17803710+fscotto@users.noreply.github.com>

* Remove unsupported Aegis TPM storage

Co-authored-by: fscotto <17803710+fscotto@users.noreply.github.com>

* Configure Aegis pi Btrfs storage

Co-authored-by: fscotto <17803710+fscotto@users.noreply.github.com>

* Mount Aegis Btrfs as root

Co-authored-by: fscotto <17803710+fscotto@users.noreply.github.com>

* Handle Maven on Mise

* Feature/aegis coreos (#7)

* feat(aegis): add ARM image bootstrap workflow

* feat(aegis): manage firewall and SSH hardening

* feat(aegis): harden Fedora IoT services

* chore(aegis): remove local TLS configuration

* fix(aegis): restore supported Fedora IoT bootstrap

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: fscotto <17803710+fscotto@users.noreply.github.com>
2026-09-08 10:36:29 +02:00
Fabio Scotto di Santolo
ae1ca44aa7 Feature/aegis coreos (#7)
* feat(aegis): add ARM image bootstrap workflow

* feat(aegis): manage firewall and SSH hardening

* feat(aegis): harden Fedora IoT services

* chore(aegis): remove local TLS configuration

* fix(aegis): restore supported Fedora IoT bootstrap
2026-09-05 16:58:53 +02:00
Fabio Scotto di Santolo
295f1a62ad Handle Maven on Mise 2026-09-04 09:36:01 +02:00
Copilot
6da430b9e4 Merge pull request #5 from fscotto/copilot/featureadd-pi-user-and-encrypt-filesystem
Configure Aegis with a pi user and Btrfs root filesystem
2026-09-04 01:04:28 +02:00
copilot-swe-agent[bot]
e92ff1c729 Mount Aegis Btrfs as root
Co-authored-by: fscotto <17803710+fscotto@users.noreply.github.com>
2026-09-03 23:02:42 +00:00
copilot-swe-agent[bot]
8d3f67885c Configure Aegis pi Btrfs storage
Co-authored-by: fscotto <17803710+fscotto@users.noreply.github.com>
2026-09-03 22:51:57 +00:00
copilot-swe-agent[bot]
bc9b381525 Remove unsupported Aegis TPM storage
Co-authored-by: fscotto <17803710+fscotto@users.noreply.github.com>
2026-09-03 22:48:04 +00:00
copilot-swe-agent[bot]
2004b48cf3 Preserve existing Aegis filesystem tables
Co-authored-by: fscotto <17803710+fscotto@users.noreply.github.com>
2026-09-03 22:44:21 +00:00
copilot-swe-agent[bot]
54e1e88a4e Add Aegis TPM-backed btrfs storage
Co-authored-by: fscotto <17803710+fscotto@users.noreply.github.com>
2026-09-03 22:43:54 +00:00
Fabio Scotto di Santolo
cb88e95385 docs: define Git Flow branch prefixes 2026-09-01 22:44:14 +02:00
Fabio Scotto di Santolo
764d329a8f feat: add Aegis Fedora CoreOS profile 2026-09-01 22:38:49 +02:00
Fabio Scotto di Santolo
1517c92866 Merge pull request #3 from fscotto/remove-freebsd-profile
Remove FreeBSD profile
2026-08-31 21:39:44 +02:00
Fabio Scotto di Santolo
6296925f2d Remove FreeBSD profile 2026-08-31 21:38:13 +02:00
Fabio Scotto di Santolo
45340103d0 feat: manage Java 11 with Mise on Fedora WSL 2026-08-31 18:20:52 +02:00
Fabio Scotto di Santolo
67b576d9b3 Fix Git config on Fedora WSL 2026-08-31 17:49:48 +02:00
Fabio Scotto di Santolo
5914ae1557 feat: use BobIDE as diff/merge tool for workstation 2026-08-31 12:51:53 +02:00
Fabio Scotto di Santolo
671581d717 feat: manage coding agents independently 2026-08-31 10:02:32 +02:00
Fabio Scotto di Santolo
7f6a45f614 Fix GitHub Copilot CLI 2026-08-30 23:56:36 +02:00
Fabio Scotto di Santolo
aadebbebd0 Add Raspberry Pi Imager to Fedora desktops 2026-08-30 23:37:24 +02:00
Fabio Scotto di Santolo
bb907febf8 Update Atlas offsite backup plan 2026-08-30 23:30:16 +02:00
Fabio Scotto di Santolo
2c1f58ce8e Add Seahorse and GSConnect to GNOME desktops 2026-08-30 16:45:44 +02:00
Fabio Scotto di Santolo
10bf0ee685 Sync Ikaros app picker layout 2026-08-30 16:37:10 +02:00
Fabio Scotto di Santolo
17ffa58e19 Use native Evolution desktop client 2026-08-30 16:32:09 +02:00
Fabio Scotto di Santolo
097374f2c5 Replace LibreOffice with OnlyOffice Flatpak 2026-08-30 16:07:01 +02:00
Fabio Scotto di Santolo
8ed439317a Add Rocky 9 Atlas NAS profile 2026-08-30 14:40:01 +02:00
Fabio Scotto di Santolo
bc5a7572a7 Add English README 2026-08-28 11:51:23 +02:00
Fabio Scotto di Santolo
a6a58245ff Refactor workstation to Fedora WSL target 2026-08-28 11:41:14 +02:00
Fabio Scotto di Santolo
3f0654a4bd Merge branch 'feature/fedora-wsl' 2026-08-27 22:41:10 +02:00
Fabio Scotto di Santolo
8771fe79c8 Install LazyGit from official release on Fedora WSL 2026-08-27 18:57:11 +02:00
Fabio Scotto di Santolo
3badf14c60 Avoid package facts on Fedora WSL 2026-08-27 18:27:51 +02:00
Fabio Scotto di Santolo
31bc8d07de Add profile for Fedora WSL 2026-08-27 18:11:41 +02:00
Fabio Scotto di Santolo
304479a3f9 Make Emacs default font platform-aware (Linux/WSL) and drop Claude CLI agent files 2026-08-27 17:49:07 +02:00
189 changed files with 12664 additions and 1882 deletions

View File

@@ -1,19 +0,0 @@
{
"permissions": {
"allow": [
"Bash(xargs ls -la)",
"Bash(command -v pacman)",
"Bash(command -v paclist)",
"Bash(command -v pacinfo)",
"Bash(git -C /home/fscotto/AnsiblePlaybook add ansible/inventory/group_vars/arch.yml ansible/roles/services_systemd/tasks/main.yml)",
"Bash(git -C /home/fscotto/AnsiblePlaybook commit -m ' *)",
"Bash(git -C /home/fscotto/AnsiblePlaybook diff --stat)",
"Bash(git -C /home/fscotto/AnsiblePlaybook status --short)",
"Bash(git -C /home/fscotto/AnsiblePlaybook log --oneline --all -- ansible/inventory/host_vars/nymph.yml ansible/roles/profile_desktop_host/tasks/nymph.yml 'dotfiles/nymph/*')",
"Bash(git *)",
"Bash(wlogout --help)",
"Bash(xbps-query *)",
"Bash(xbps-install --dry-run gtklock)"
]
}
}

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
# DuckDNS runtime files contain a rendered Vault token and must stay on the host.
/dotfiles/server/duckdns/

463
AGENTS.md
View File

@@ -1,6 +1,6 @@
# AGENTS.md
Ansible-driven personal infrastructure repo for Fedora and Void desktops, FreeBSD transition targets, WSL, and an Ubuntu server.
Ansible-driven personal infrastructure repo for Fedora and Void desktops, Fedora IoT, WSL, a Rocky Linux 9 server, and an Atlas NAS.
## Source Of Truth
- Main orchestration: `ansible/site.yml`
@@ -14,8 +14,10 @@ Ansible-driven personal infrastructure repo for Fedora and Void desktops, FreeBS
- Current personal desktop: `ikaros = platform_fedora + role_personal_workstation + graphical_desktop + desktop_gnome`
- Current laptop: `nymph = platform_fedora + graphical_desktop + desktop_gnome`
- Void desktop profile is also the base for other future/reference hosts via `platform_void + graphical_desktop`
- Workstation: `deadalus` is Windows + WSL; Ansible target is `deadalus-wsl`
- Ubuntu server: `prometheus`
- Workstation: `deadalus` is Windows + Fedora WSL.
- Rocky server: `prometheus` belongs to `rocky_server`.
- NAS: `atlas` (Rocky Linux 9, reached through SSH)
- Always-on LAN node: `aegis` (Fedora IoT on Raspberry Pi 4, reached through SSH)
- Hosts intentionally belong to multiple groups; trust `ansible/site.yml` over hostname assumptions.
- Inventory axes are independent: `platform_*`, `role_*`, and `desktop_*`. Legacy `void` and `desktop` remain compatibility parents.
@@ -23,7 +25,13 @@ Ansible-driven personal infrastructure repo for Fedora and Void desktops, FreeBS
- Preserve layering `all -> platform -> role -> desktop -> host`.
- Keep `ansible/site.yml` small; orchestration belongs there, implementation belongs in roles.
- Prefer minimal, targeted edits. Preserve idempotency and existing ordering.
- All hosts use `ansible_connection: local`.
- Keep completed one-time cleanup operations out of the playbook. Execute them directly
with explicit authorization; retain only the ongoing desired-state configuration and
historical documentation, not permanent cleanup flags or tasks.
- Use Git Flow branch prefixes: `feature/` for new functionality, `bugfix/` for non-urgent fixes,
`hotfix/` for urgent production fixes, `release/` for release preparation, and `support/` for
maintained release lines. Do not use abbreviated prefixes such as `feat/`.
- Desktop and WSL hosts use `ansible_connection: local`; remote infrastructure hosts use SSH.
- Treat `secrets/` as sensitive. Never print secret values.
- Tmux plugins are bootstrapped by TPM on the host; the repo only keeps tmux config and custom helper scripts.
- Read the relevant role tasks, templates, vars, and deployed dotfiles before editing.
@@ -38,12 +46,60 @@ Ansible-driven personal infrastructure repo for Fedora and Void desktops, FreeBS
- Host-focused dry runs:
- Fedora desktop work: `ansible-playbook ansible/site.yml --limit ikaros --check --diff`
- Fedora laptop work: `ansible-playbook ansible/site.yml --limit nymph --check --diff`
- WSL dev: `ansible-playbook ansible/site.yml --limit deadalus-wsl --check --diff`
- WSL workstation dev: `ansible-playbook ansible/site.yml --limit deadalus --check --diff`
- Server: `ansible-playbook ansible/site.yml --limit prometheus --check --diff`
- Rocky server after activation: `ansible-playbook ansible/site.yml --limit <host> --check --diff`
- Atlas NAS: `ansible-playbook ansible/site.yml --limit atlas --check --diff`
- Aegis IoT: `ansible-playbook ansible/site.yml --limit aegis --check --diff`
- Aegis NFS client layer: `ansible-playbook ansible/site.yml --limit aegis --tags nfs --list-tasks`
- Aegis host DNS: `ansible-playbook ansible/site.yml --limit aegis --tags dns --check --diff`
- Focused checks:
- Emacs is disabled by default; temporary Emacs check: `ansible-playbook ansible/site.yml --limit <host> --tags emacs --check --diff -e emacs_enabled=true`
- AI coding agents: `ansible-playbook ansible/site.yml --limit <host> --tags ai_agents --check --diff`
- Mail bootstrap: `sh -n scripts/bootstrap_mail.sh` and `shellcheck scripts/bootstrap_mail.sh`
- Server compose render: `docker compose -f /opt/docker/server/docker-compose.yml config`
- Server NPM Quadlet: `systemctl status prometheus-npm.service`; the Compose fallback is retired.
- Explicit Prometheus legacy cleanup (destructive only without check mode):
`ansible-playbook ansible/site.yml --limit prometheus --tags server_legacy_cleanup --check --diff -e server_legacy_cleanup=true`
- Atlas media stack:
`ansible-playbook ansible/site.yml --limit atlas --tags storage,sharing,containers --check --diff`
- Atlas rootless Gitea staging (does not start Gitea):
`ansible-playbook ansible/site.yml --limit atlas --tags gitea --check --diff`
- Atlas canonical Gitea domain (restarts only Gitea on a real configuration change):
`ansible-playbook ansible/site.yml --limit atlas --tags gitea_public_domain --check --diff`
- Atlas iCloudPD storage and boot-started Quadlet:
`ansible-playbook ansible/site.yml --limit atlas --tags icloudpd --check --diff`
- Atlas explicit Gitea host-owner migration (live outage; never a normal run):
`ansible-playbook ansible/site.yml --limit atlas --tags gitea_owner_migration -e atlas_gitea_owner_migration=true`
- Atlas explicit isolated Gitea restore rehearsal (not part of normal runs):
`ansible-playbook ansible/site.yml --limit atlas --tags gitea_restore -e atlas_gitea_restore_test=true`
- Atlas final Gitea replacement gate (dry-run only until a stopped-source export is pulled):
`ansible-playbook ansible/site.yml --limit atlas --tags gitea_final_restore --check --diff -e atlas_gitea_final_restore=true`
- Prometheus final Gitea export helper (dry-run installs only; outage action remains opt-in):
`ansible-playbook ansible/site.yml --limit prometheus --tags gitea_final_export --check --diff`
- Gitea cutover network configuration before activation:
`ansible-playbook ansible/site.yml --limit prometheus --tags gitea_cutover,prometheus_backup --check --diff -e server_gitea_on_atlas=true`
and `ansible-playbook ansible/site.yml --limit atlas --tags gitea --check --diff`
- Atlas daily Navidrome music copy:
`ansible-playbook ansible/site.yml --limit atlas --tags music_sync --check --diff`
- Atlas network/share hardening:
`ansible-playbook ansible/site.yml --limit atlas --tags hardening,sharing --check --diff`
- Atlas ZFS snapshot retention and scrub timers:
`ansible-playbook ansible/site.yml --limit atlas --tags snapshots,scrub --check --diff`
- Atlas encrypted Borg backup:
`ansible-playbook ansible/site.yml --limit atlas --tags packages,borg --check --diff`
- Atlas Borg progress logging only:
`ansible-playbook ansible/site.yml --limit atlas --tags borg_logging --check --diff`
- Atlas manual offline USB backup and 45Drives Alerts reminder:
`ansible-playbook ansible/site.yml --limit atlas --tags usb_backup,usb_reminder --check --diff`
- Atlas pool, disk, capacity, temperature, and job monitoring:
`ansible-playbook ansible/site.yml --limit atlas --tags monitoring --check --diff`
- Atlas explicit post-restore SELinux relabeling:
`ansible-playbook ansible/site.yml --limit atlas --tags restorecon --check -e '{"atlas_restorecon_paths":["/zpool/archive"]}'`
- Prometheus/Aegis WireGuard gateway:
`ansible-playbook ansible/site.yml --limit prometheus,aegis --tags wireguard --check --diff`
- Prometheus NPM Quadlet steady state (does not perform a cutover):
`ansible-playbook ansible/site.yml --limit prometheus --tags npm_quadlet --check --diff`
- DuckDNS config only (skipped on Prometheus): `ansible-playbook ansible/site.yml --limit prometheus --tags duckdns --check --diff`
## Conventions
- Use FQCN Ansible modules.
@@ -75,18 +131,380 @@ The Void desktop package lists in `ansible/inventory/group_vars/void.yml` are ke
- `desktop_common_packages` — GUI infrastructure shared by the minimal desktop mode.
- `desktop_minimal_packages` — applications, integration components, and the `emptty` display manager.
- `desktop_sway_packages` — binaries specific to the Sway session.
`profile_packages` in the same file is cross-distro and is overridden by `group_vars/server.yml` and the workstation group vars; do not move desktop-specific Void entries through it.
`profile_packages` remains the shared package bucket for Void and Fedora profiles. Rocky uses
`rocky_profile_packages` so RPM-specific names do not leak back into the other platforms; do not move
desktop-specific Void entries through either bucket.
The dotfile vars follow the same split: `desktop_common_dotfiles` carries mode-independent content and `desktop_minimal_dotfiles` carries Thunar, Udiskie, and MIME defaults. `desktop_void_dotfiles` remains reserved for files that need the Void runtime.
## Workstation Notes
- `deadalus` is modeled as Windows + WSL; keep Linux dev automation on `deadalus-wsl`.
- Native Linux workstation groups remain available for future hosts but have no current host in the main inventory.
- `deadalus` is modeled as Windows + Fedora WSL and is the sole workstation target.
- Fedora WSL belongs to `platform_fedora`, `workstation_dev_fedora`, and the shared WSL layer. It must not receive Flatpak or Snap runtimes.
- Fedora WSL installs Mise from the official `jdxcode/mise` COPR and uses its pinned Temurin Java 11 JDK; update the declared Mise version deliberately.
- Windows applications are installed manually and are not managed from the WSL profile.
## Rocky Server Notes
- Prometheus disables DuckDNS provisioning with `server_duckdns_enabled: false`. Its updater,
log and five-minute cron entry were explicitly retired; the external DuckDNS name and Vault
token remain untouched. The completed one-time cleanup has no remaining playbook tasks.
- When enabled, DuckDNS is rendered by `profile_server` from host-local `server_duckdns_domain` and
`vault_duckdns_token`. Keep the rotated token in encrypted Vault or untracked local vars, never in
dotfiles. The private `~/duckdns/duck.sh` keeps the existing entrypoint; rendering uses `no_log`
and disables diffs. Provisioning does not execute the updater or change its external schedule.
- `rocky_server` is a child of both `platform_rocky` and `server`; `prometheus` is its active target.
- The target must already provide `server_username` with local sudo access before the profile runs.
- The Rocky profile installs Podman and podman-compose. Prometheus explicitly retires the legacy
Compose unit, files and final-export helper with `server_legacy_stack_retired: true`.
Its approved opt-in cleanup removed old application data on 2026-10-03; normal runs do not
delete data or recreate the retired files. On Prometheus, Nginx Proxy Manager is now the rootful
`prometheus-npm.service` Quadlet with a pinned image digest and the existing `/opt/npm/data` and
`/opt/npm/letsencrypt` bind mounts. The rootful `server_web` bridge remains `10.89.0.0/24`.
Gitea runs on Atlas; PostgreSQL and Navidrome are absent from the desired Prometheus stack.
Normal runs do not delete legacy data, update DNS, or perform an implicit cutover;
destructive cleanup requires its explicit tag and opt-in extra-var.
- Firewalld enables SSH, Cockpit (`9090/tcp`), HTTP and HTTPS. Nginx Proxy Manager publishes `80/tcp` and
`443/tcp`; bind its administration interface only to `127.0.0.1:81` and use `npm-tunnel` from Ikaros or Nymph.
Nextcloud remains disabled; do not provision `/srv/nextcloud` directories.
- `scripts/migrate_prometheus_data.sh` is the separate, source-host-run NPM/Gitea migration path. It dry-runs by
default and requires explicit source-stack quiescing before copying persistent Docker data with rsync.
- Atlas-only OpenZFS, NFS, Samba, and Syncthing stay selected through Atlas host variables and must not
leak into `rocky_server`. Cockpit plus its Navigator and Podman extensions are selected explicitly for
Prometheus through its host variables.
## Atlas NAS Notes
- `atlas` is a remote Rocky Linux 9 NAS. Keep its connection, LAN, pool and mountpoint values in
`host_vars/atlas.yml`. Bootstrap it once with `-e atlas_connection_username=<existing-admin>`;
subsequent runs use the dedicated Atlas account.
- The pool is normally pre-existing. A one-time bootstrap may create it only when `atlas_create_pool=true`
is explicitly supplied and `atlas_zpool_disks` contains exactly four real `/dev/disk/by-id/...` paths.
Never partition, force, destroy, roll back, or modify the vdev layout of an existing pool.
- `atlas_manage_storage`, `atlas_manage_sharing`, and `atlas_manage_firewall` are enabled in Atlas host vars as
the declared steady state; set one false only for a deliberate suspension. `atlas_manage_media_stack` remains false
until the future rootful Immich stack has its required Vault inputs and target validation.
- Atlas requires `vault_atlas_admin_password_hash` for Cockpit and, while sharing is enabled,
`vault_atlas_samba_password`. The future rootful media stack also requires
`vault_atlas_immich_db_password`. Never print these values.
- Atlas creates the complete declared hierarchy only under the verified existing or explicitly bootstrapped pool: `archive`,
`services`, `services/data`, `services/data/navidrome`, `services/data/syncthing`, `media`, `media/music`,
`media/photobook`, `backup`, `backup/hosts`, and `backup/hosts/prometheus`. `backup` has a `500G`
reservation covering its descendants. `archive` is the SMB-shared raw-data namespace; container state is never beneath it.
- The `immich` system account is fixed to UID/GID `1100`, has no login shell or `wheel` membership, and receives only
the `video` and `render` supplementary groups. Immich's rootful Quadlets run as `1100:1100`; Server and ML receive
`/dev/dri`, while the Photobook external library is read-only at `/external/photobook`.
- Atlas applies persistent kernel network hardening: redirects and source routes are rejected, martians logged, reverse-path filtering remains loose for WireGuard, and IPv4 forwarding is disabled. SSH permits only the declared administrator using public-key authentication; root login, passwords, agent and remote forwarding
are disabled, while local forwarding remains available for private administrative tunnels. Photobook is exported only to the configured Aegis IP with all access squashed to UID/GID
`1100`. Targeted SELinux is enforced persistently; a required reboot is reported but never initiated automatically. The primary LAN interface is assigned explicitly to the managed firewalld zone, and firewall rules are applied before NFS or SMB are started; their service state and TCP listeners are then verified. SMB3 exposes `Archive` to Vault-backed authorized accounts on mandatory encrypted, signed SMB3 over TCP/445 only and admits the configured LAN without host-specific exclusions.
- Atlas NPM and Immich share a rootful Podman network. NPM publishes HTTP/HTTPS, but its administration port remains
bound to `127.0.0.1:81`; do not expose it directly to the LAN or Internet.
- `profile_backend_phase1` temporarily runs rootless Navidrome and Syncthing on Atlas until Uranus replaces
them. It binds only to Atlas' LAN IP, never `wg0`; Navidrome and the Syncthing GUI admit only Aegis as
the source-NAT gateway, while native Syncthing ports admit the configured LAN. It initializes fresh
state only and never migrates or deletes source application data. The enabled rootless
`atlas-music-sync.timer` copies `/zpool/archive/Music` to `/zpool/media/music` daily at 00:45
Europe/Rome without deleting destination files; it requires both datasets to be mounted.
- `wireguard_overlay` manages `wg0` between Prometheus (`10.0.0.1`) and Aegis (`10.0.0.2`). It persists private
keys only on their respective hosts, exchanges only derived public keys through Ansible, and verifies a real peer
handshake. Prometheus opens `51820/udp`; Aegis is the LAN gateway. Its persistent IPv4 forwarding, narrowly scoped
WireGuard-to-LAN firewalld policy, and source masquerading permit Prometheus to reach LAN services without a static
route on the router. Prometheus includes `192.168.178.0/24` in Aegis' peer `AllowedIPs`; add the Uranus VIP there
when it is assigned. After a firewalld reload, restore Prometheus' rootful Podman networking with
`podman network reload --all` so the existing proxy stack retains container DNS.
## Atlas NAS TODO
Completed validation: the existing RAIDZ2 pool and datasets, SELinux, LAN firewall, SSH, Cockpit with
the selected 45Drives plugins, encrypted SMB3 `Archive`, the Aegis-only NFSv4 `photobook` export, and the
Prometheus--Aegis WireGuard gateway are operational. The gateway handshake, forwarding, source masquerading,
and TCP reachability to Atlas were verified. Temporary Navidrome and Syncthing are available through their
manual NPM Proxy Hosts; Syncthing uses `/data/Org` backed by the SMB-shared Archive dataset. Aegis has also
validated NFSv4.2 read, write, delete, and `all_squash` mapping to UID/GID `1100` end-to-end. The ZFS
snapshot timers are active; a recursive hourly snapshot and scheduled retention prune completed
successfully. The first monthly scrub remains a runtime check.
### Priority 1 - Data protection
- [x] Deploy Ansible-managed recursive ZFS snapshots with 24 hourly, 30 daily, 8 weekly, and 12 monthly
generations, plus a monthly scrub on the first Sunday at 03:00. The timers and first hourly snapshot were
verified on Atlas. Cockpit Scheduler is for visibility or manual operations only, and snapshot
rollback is never automated.
- [ ] Verify the first monthly ZFS scrub from its actual service result. Scheduled retention pruning
was observed on 2026-09-30; timer activation alone does not establish a successful scrub.
- [x] Activate and validate the encrypted offsite Borg backup to the Hetzner Storage Box. Atlas uses the
dedicated SSH identity, pinned ED25519 host key, Vault-backed `repokey` encryption, and a locked
non-login `borg` account with no sudo or supplementary groups. The initial snapshot-consistent backup,
Borg repository check, and temporary-directory restore completed successfully; the restored `Archive`
tree matched the live data, and temporary snapshots and mounts were removed. The exported recovery key
was copied offline. Daily backup retries and logging, 30 daily, 8 weekly and 12 monthly archives,
compaction, and monthly repository checks are enabled. Runs report a ZFS-based estimated percentage.
On 2026-09-30 a successful incremental run also removed the stale 2026-09-29 snapshot and its own
temporary snapshot after exit; the earlier `RuntimeDirectory` cleanup failure is resolved.
- [x] Populate `/zpool/archive` with the currently available data so offsite and offline backup tests run
against a representative load.
- [x] Evaluate Borg against the populated pool. The 2026-09-29 archive took 1 h 32 min for 2.18 TB
original / 2.04 TB compressed data, with 13.49 GB deduplicated size; retention and compaction
succeeded. On 2026-09-30 a subsequent incremental archive completed in about 22 seconds with
successful cleanup. The monitor reported 37% Storage Box quota used. These are observed runs, not
a guarantee of future duration or compression ratio.
- [x] Add the UUID-bound offline USB backup with versioned rsync, locking, capacity checks, verification,
safe unmounting and a tested restore procedure; never trigger it for an arbitrary USB disk. The
LUKS/ext4 identities were read-only verified; the manual service and 45Drives Alerts reminder timer were
deployed on Atlas. Interactive LUKS unlock is part of the manual service; only the reminder is
scheduled for the first Saturday of each month at 10:00 Europe/Rome via the existing 45Drives
notifier. A manual test produced an Alerts notification, not an email. The first USB attempt failed
on a `security.selinux` xattr and was interrupted; the xattr filter is deployed and the temporary
recursive snapshot and open LUKS mapper were cleaned up. A later run reported checksum verification
and published the USB version, but failed while removing host-namespace ZFS snapshot mounts. Those
exact mounts and snapshots were cleaned up. An `ExecStopPost` helper now removes only the named
temporary snapshot after the backup process exits. A new full run checksum-verified and published a
USB version; the service ended successfully, the mapper closed, no temporary USB snapshot remained,
and the pool was healthy. On 2026-09-25 an independent, read-only USB restore test copied one file from
the published `atlas/latest` version into `/var/tmp` and matched contents, owner, mode, size, mtime and
POSIX ACL. The temporary copy and mount were removed, the mapper closed, and the pool remained healthy.
- [x] Test restores independently from a ZFS snapshot, Borg, and the offline USB backup before relying on
any backup path. The earlier Borg temporary-directory restore passed. On 2026-09-25 a separate,
read-only ZFS snapshot test restored one file to `/var/tmp`, confirmed matching contents, ownership,
mode, mtime and ACL, then removed its temporary copy and on-demand mount. This is a file-level smoke
test, not full dataset recovery. An independent USB file restore passed on 2026-09-25 with matching
content and metadata; the later scaled OS-rebuild rehearsal is documented under Priority 2.
- [x] Add monitoring and alerting for pool health, scrub/resilver, SMART data, temperatures, free space,
snapshot/local-backup growth, Hetzner Storage Box quota, and failed maintenance or backup timers.
The half-hourly Atlas health monitor and systemd final-failure hooks are deployed. A live probe
found no issues; the service and timer succeeded, and a labelled 45Drives Alerts test notification
was submitted. Alerts are deduplicated; email delivery is not claimed. The Storage Box quota probe
runs `df -m` over the dedicated pinned-key SSH identity and does not open the Borg repository.
The failed-job hook was corrected to pass the literal systemd unit name; its expansion was verified
on Atlas, but a new real failure notification has not been deliberately triggered.
### Priority 2 - NAS operability and recovery
- [x] Document and test disaster recovery in `docs/atlas-recovery.md`: the operator confirmed Vault
and Borg recovery material is available offline; provisional targets are RPO 24h/RTO 72h. On
2026-09-30 an isolated small Rocky VM was rebuilt with the Atlas Ansible roles, imported its
preserved RAIDZ2 pool without force/rewind, and restored a file from the preserved snapshot;
the second Ansible run was idempotent. Earlier independent production ZFS, USB, and Borg file
restore tests remain separate evidence. A production-size full restore, unclean import, and
measured 24h/72h compliance are not claimed.
- [x] Define a controlled Rocky kernel/OpenZFS update and reboot procedure in
`docs/atlas-updates.md`. The first real change-window execution is not yet
validated; the procedure never reboots automatically or upgrades pool features.
- [x] Add the Atlas-initiated least-privilege Prometheus backup pull: Prometheus exposes only prepared
read-only dumps through a dedicated account and Atlas retains the private SSH key, pinned host key,
atomic pull, verification, retention and systemd service/timer. The dedicated key/account and unit
files are deployed; live read-only SSH, shell denial, and write denial were verified. On 2026-09-30
a manual export, Atlas pull, checksum verification, and temporary restore passed; both SQLite
databases passed integrity checks and a restored Git repository passed `git fsck`. Both daily
timers are enabled for 02:00/03:00 Europe/Rome. On 2026-10-01 their first scheduled export and
pull succeeded: Atlas verified the payload checksum and published `20261001T000001Z` as `latest`.
- [x] Decide whether a common SMB/NFS namespace is required: no. `Archive` (SMB) and `photobook` (NFS)
remain intentionally distinct; `docs/atlas-sharing-decision.md` records the decision. No ACL or export
change is authorized by this decision.
### Priority 3 - Service expansion
- [x] Populate `/zpool/media/music` and validate Navidrome. On 2026-09-30, 21,158 files
(93,937,810,350 regular-file bytes) were copied from `/zpool/archive/Music` using a temporary
ZFS snapshot; a checksum-based rsync dry run found no differences or extra files. Navidrome saw
all files through its read-only mount, completed a scan, indexed 18,168 tracks, and responded
over HTTP. Some imported playlists still reference obsolete Windows paths. The source was left
intact and the temporary snapshot was removed.
- [x] Schedule a daily, non-deleting copy from `Archive/Music` to the separate Navidrome music
dataset. The rootless `atlas-music-sync.timer` is enabled for 00:45 Europe/Rome; a manual
idempotent service run succeeded on 2026-10-01. The first scheduled run triggered at
00:45 CEST on 2026-10-02 and exited successfully (`Result=success`, status 0); the next
run is scheduled for 2026-10-03 00:45 CEST.
- [x] Design the staged Prometheus-to-Atlas Gitea migration in `docs/atlas-gitea-migration.md`.
The approved topology keeps NPM on Prometheus and moves HTTPS and public SSH (TCP/2222) together;
Gitea runs as an `admin`-owned rootless user Quadlet on Atlas with an internal `gitea` user.
The rootful-to-rootless data-layout
conversion passed an isolated restore rehearsal. The later partial cutover is tracked below.
- [x] Prepare the dedicated Atlas Gitea dataset, non-login UID/GID 1101 with a separate rootless Podman
sub-ID range, and disabled user Quadlet. On 2026-10-01 the targeted Ansible run and a second idempotent
run passed; the generated unit was inactive, with no staging HTTP/SSH listener. POSIX ACLs on only the
service-namespace parents grant this account traversal without access to sibling datasets.
- [x] Perform an isolated rootless restore rehearsal from the verified Prometheus backup. On 2026-10-01
the SHA-256-checked selective extraction and path/SSH conversion succeeded; SQLite `quick_check`
passed, all 33 repositories passed `git fsck`, and source/target public SSH host-key fingerprints
matched. The pinned rootless image answered HTTP and listened on internal SSH/2222 with
`--network none`; the temporary container was removed and the Quadlet stayed inactive. A second
restore run made no changes. This is a rehearsal copy, not the final consistent cutover copy.
- [x] Verify ZFS and Borg coverage of the staged Gitea dataset. On 2026-10-01 the managed recursive
hourly snapshot `atlas-auto-hourly-20261001T193401Z` included it, and the managed incremental
Borg archive `atlas-20261001T193420Z` included its database. A private one-file restore from
each independently matched the staged database and passed SQLite `quick_check`; temporary files
and snapshot mounts were removed, the Borg service ended successfully, and the pool was healthy.
- [x] Include the new Gitea dataset in a UUID-bound offline USB version and test a file restore
before accepting production writes. The operator's 2026-10-01 manual run published version
`20261001T201220Z-254397` successfully on 2026-10-02. Its Gitea database was restored to a
temporary directory from a read-only mount: contents, owner, group, mode, size, mtime and POSIX
ACL matched, and SQLite `quick_check` passed. Temporary files and mounts were removed, LUKS
was closed, and the pool remained healthy. A redundant run was stopped during verification;
its temporary snapshot was cleaned up and the service's resulting failed state was reset.
- [x] Install a separate opt-in final Gitea export helper on Prometheus. Its 2026-10-01 targeted
deployment and `bash -n` passed while Gitea and NPM stayed running. It refuses an active export
timer, stops only Gitea, verifies SQLite, publishes a checksum-verified Gitea-only version for
Atlas' existing pull, and leaves the source stopped on success. It was invoked on 2026-10-02
after the export timer was stopped; version `20261002T071525Z` was pulled and verified on Atlas.
- [x] Prepare the Atlas final-restore gate without replacing the rehearsal: it accepts only a
checksum-verified `gitea-cutover` export, refuses a running target, stages and validates the new
layout before replacing the marked rehearsal, and rolls back a failed swap. Synthetic success
and rollback tests passed on 2026-10-01. On 2026-10-02 the final gate replaced the rehearsal;
SQLite `quick_check`, all 33 repository `git fsck` checks, checksum and SSH host-key comparison passed.
- [x] Start the rootless Atlas Gitea Quadlet and move the primary HTTPS route. On 2026-10-02 Atlas
answered HTTP 200 through the Aegis gateway. NPM stayed on Prometheus; its variable upstream
required a managed Nginx `server_proxy.conf` override because runtime DNS ignores Compose
`extra_hosts`. The primary public HTTPS page and API returned 200, and `git ls-remote` succeeded
for a representative repository after NPM restart; the Navidrome and Syncthing Proxy Hosts also
responded. The source
Gitea container was removed from the desired Compose stack without deleting its data; the
Prometheus backup export timer resumed for NPM only. A post-cutover recursive ZFS snapshot and
encrypted Borg archive `atlas-20261002T073044Z` completed successfully.
- [x] Move the live Gitea Quadlet and dataset from the legacy host `gitea` account to `admin`
after a disposable snapshot-copy test of the pinned derived image. On 2026-10-02 the explicit
outage run stopped only legacy Gitea, made safety snapshot
`zpool/services/data/gitea@gitea-owner-migration-20261002T100104`, changed dataset ownership,
and validated loopback staging (HTTP 200, internal `gitea` UID/GID 1000, SQLite `quick_check`)
before promoting the `admin` Quadlet. Production LAN and public HTTPS returned 200; Navidrome
and Syncthing remained active, the pool was healthy, and the normal Gitea run changed nothing.
The old host account and data on Prometheus remain preserved; the old Atlas Quadlet and its
parent-dataset traverse ACL were removed. A subsequent normal run changed nothing.
- [x] Validate public Gitea SSH/2222 and an authenticated read from Ikaros. After the VPS
firewall was opened on 2026-10-02, TCP/2222 connected, the public ED25519 host-key
fingerprint matched Atlas, Gitea authenticated `fscotto` using the `ikaros` key, and
`git ls-remote` returned HEAD for `fscotto/infra.git` over public SSH.
- [x] Validate authenticated SSH pull and push. On 2026-10-02 the operator reported both
operations working through the public SSH endpoint; the earlier agent-run `git ls-remote`
remains the independent read-only check. The agent did not perform a test push.
- [x] Validate Gitea login and write via HTTPS. On 2026-10-03 the operator confirmed
authenticated web login and Git clone/pull/push through the public HTTPS endpoint. Do not
restart the stale source Gitea after Atlas has accepted writes.
- [ ] Design and deploy Nextcloud as another explicitly temporary Atlas service before Uranus. Give it
separate persistent application, database, and cache storage; keep credentials in Vault; publish it only
through NPM over the Prometheus--Aegis gateway; and define backup, upgrade, and eventual Uranus-migration
procedures before exposing user data. Do not deploy Nextcloud before the data-protection checklist is complete.
- [x] Move Gitea canonical HTTPS and SSH hostname to `git.fscotto.co` on
2026-10-03 through Ansible. Only Gitea restarted; second run changed nothing.
HTTPS and authenticated SSH reads returned the same repository HEAD.
The new NPM hostnames passed TLS/HTTP checks; old DuckDNS Proxy Hosts were
observed disabled. Details are in `docs/domain-fscotto-co.md`.
- [x] Confirm login on the new Gitea hostname and update remaining client remotes/integrations.
The operator confirmed completion on 2026-10-03; the agent did not perform a test push.
- [x] Remove obsolete DuckDNS NPM Proxy Hosts, unused certificates and the old upstream override.
The operator confirmed completion on 2026-10-03; no new agent runtime check was performed.
- [x] Review and remove completed one-time procedures from the playbook.
The operator confirmed completion on 2026-10-03.
- [x] Retire Prometheus' local DuckDNS updater on 2026-10-03 through Ansible:
the five-minute cron entry and private updater/log directory were removed.
Provisioning is disabled; repeat cleanup changed nothing. HTTPS services, private NPM
administration and the export timer stayed healthy. The external name and Vault token
remain untouched for possible future use on a local host.
- [ ] Keep `atlas_manage_media_stack` disabled until the future Immich deployment has validated `/dev/dri`,
container paths, and the required Vault database secret.
### Priority 4 - Optional workflows
- [x] Deploy the declared Atlas iCloudPD state dataset and inactive rootless `admin` Quadlet.
Photos belong under `/zpool/archive/Pictures/iCloudPD`; private config/MFA state belongs in
`zpool/services/data/icloudpd`. Photobook remains reserved for Immich. Ansible now renders
`icloudpd.conf` with the Apple ID from the existing Vault key, but does not store the password
or manage MFA. Automatic startup was approved on 2026-10-03; the Quadlet now
uses `WantedBy=default.target` and Ansible keeps the service running.
The isolated no-network layout test is documented in
`docs/atlas-icloudpd-migration.md`. On 2026-10-02 Atlas deployment and a second idempotent run
passed; no app config existed at deployment. A manual first start on 2026-10-02 generated
`icloudpd.conf`; an Ansible run then replaced it with a private mode-0600 Vault-backed template
and an idempotent second run. The image later expanded the config, so Ansible now seeds it
only when absent and maintains the declared fields. Its launcher requires `traceroute`; the
rootless Quadlet grants only `NET_RAW`, tested in isolation and after restart. The service
was subsequently initialized interactively; initial ingestion is tracked below.
- [x] Retire Aegis iCloudPD completely. The operator authorized deleting its Quadlet,
`/var/lib/icloudpd` data, and MFA state despite an unaudited container overlay. After two
interactive-sudo runs on 2026-10-02, the unit is `not-found`/`inactive`, the Quadlet and state
directory are absent, and AdGuard remains active. The temporary retirement tasks have since
been removed from the Aegis role; it no longer manages iCloudPD.
- [x] Validate Atlas iCloudPD authentication and initial ingestion. On 2026-10-03 the active
rootless service logged `All photos and videos have been downloaded` at 02:16 and reported
completion for the user. The destination held 11,658 files (86,020,430,015 bytes); the preceding 24h
logs showed download activity without authentication failures or errors. A later read-only check
found the service still active. This confirms the initial download, not the next daily cycle.
- [x] Declare HEIC decoding for Fedora graphical desktops without converting the originals on Atlas.
The Fedora role installs RPM Fusion Free with a pinned signing-key fingerprint and
`libheif-freeworld` on Ikaros and Nymph. The package was confirmed installed on Ikaros on
2026-10-03; Nymph deployment and an actual image-opening test were not observed.
- [ ] Validate Atlas iCloudPD filesystem/SELinux/SMB access, the next daily sync, ZFS/Borg/USB
backup inclusion, and isolated restore of photos and private state. A recursive hourly snapshot
of `zpool/archive` exists after ingestion, but no iCloudPD-specific backup version or restore
has been verified. The first monthly scrub remains a separate open data-protection check.
## Prometheus NPM Quadlet cutover
- [x] Stage a rootful NPM Quadlet using the exact running image and the existing data/certificate
mounts, bridge subnet, public HTTP/HTTPS ports, and loopback-only administration port.
The generated service depends on `server-web-network.service` and is wanted by `multi-user.target`.
- [x] Take and verify the stopped-source export before switching owners. Version
`20261003T091009Z` was pulled to Atlas and its NPM SQLite database checked in isolation.
- [x] Cut over NPM to `prometheus-npm.service` on 2026-10-03. The legacy Compose unit is inactive
and disabled; the Quadlet is active with zero recorded restarts. Public Gitea and Syncthing
HTTPS returned 200 with valid TLS, while public TCP/81 remained unreachable.
- [x] Validate the post-cutover backup path. The export and Atlas pull published
`20261003T091633Z`; checksum, SQLite `quick_check`, ten proxy hosts, six certificate records,
both Quadlet files were present, and the complete Let's Encrypt tree (70 regular files plus
12 symlinks) matched the live data. A targeted normal Ansible run changed nothing. Details and rollback
boundaries are in `docs/prometheus-npm-quadlet.md`.
- [x] Remove only unused Gitea, Navidrome and PostgreSQL images with opt-in
Ansible tasks on 2026-10-03. Second run changed nothing; NPM stayed active
with zero restarts, HTTP/HTTPS passed, backup timer and SSH proxy stayed active.
This image-only step preserved data and fallback; the later approved deletion is tracked below. Validation:
`ansible-playbook ansible/site.yml --limit prometheus --tags server_image_cleanup --check --diff -e server_legacy_image_cleanup=true`
- [x] Complete explicitly approved old-data and Compose fallback removal on 2026-10-03.
Backup paths and mount dependencies were reconciled before deletion; repeat cleanup changed
nothing. The normal Compose/template/helper check did not recreate retired files.
A separately approved manual export/pull published `20261003T112906Z`; checksum and isolated
SQLite restore passed with ten proxy hosts and both Quadlet definitions. NPM, primary HTTPS,
WireGuard, SSH proxy and backup timer remained healthy; existing backup archives were preserved.
- [x] Retire the unused secondary Gitea hostname `git.ov-ad3410.infomaniak.ch`
on 2026-10-03. Its NPM Proxy Host was already soft-deleted and had no
associated certificate. Its Ansible domain and runtime override were removed;
nginx -t and reload passed without restarting NPM. Primary HTTPS returned 200
with valid TLS. At that step only `git.fscotto.duckdns.org` remained declared;
the subsequent domain transition and operator-confirmed cleanup are tracked above.
- [ ] Observe the first scheduled export and Atlas pull after the cutover; the manual end-to-end
cycle passed, but the next unattended cycle has not yet occurred.
## Cerberus Management Node (Deferred)
`cerberus` is postponed until the office in the new house is physically set up. It is not an inventory
host and this section is a design and implementation backlog, not authorization to provision it early.
The planned node is a Lenovo ThinkCentre M700 Tiny with an Intel Core i3-6100T, 8 GB RAM, a 256 GB SSD,
and native 1 Gbps Ethernet. It will connect to a multi-input KVM switch using a passive DisplayPort-to-HDMI
cable, sharing the monitor and peripherals with Ikaros. Fedora Sericea (immutable Fedora with the Sway
Wayland compositor) is the intended OS. Cerberus is an isolated management plane: a dedicated Toolbox
environment will run Ansible for future `uranus` cluster provisioning. Rootless Podman will host Grafana,
Prometheus, and Loki. The 256 GB local SSD is the hot tier retaining metrics and logs for 30 days; scheduled,
validated exports of older historical data will use a dedicated Atlas NFS dataset as cold storage.
### Implementation plan
- [ ] Confirm the office, KVM switch, passive DisplayPort-to-HDMI path, shared monitor/peripherals, and native
1 Gbps Ethernet are physically operational before adding Cerberus to inventory.
- [ ] Install and update Fedora Sericea with Sway; document the immutable-host lifecycle and keep host changes
declarative rather than treating the base OS as a mutable workstation.
- [ ] Model Cerberus as its own host with independent platform, role, desktop, network, and storage inputs;
do not repurpose Ikaros variables or make it a Uranus cluster member.
- [ ] Provision an isolated Toolbox-based Ansible controller with the required collections and a reproducible
project checkout; define its least-privilege SSH access, known-host handling, and Vault workflow without
storing secrets in the image or repository.
- [ ] Define the explicit Uranus provisioning workflow from Cerberus, including inventory boundaries,
validation-only runs, and separate approval for any destructive cluster operation.
- [ ] Design rootless Podman/Quadlet services for Grafana, Prometheus, and Loki, including persistent local
state, service ownership, LAN exposure/authentication, resource limits, updates, and backups.
- [ ] Size and enforce a 30-day local hot-retention policy for metrics and logs on the 256 GB SSD; validate
actual disk growth and alert before capacity exhaustion.
- [ ] Create and validate a dedicated Atlas NFS cold-storage dataset and least-privilege export for Cerberus;
do not use a broad existing share or couple it to unrelated Atlas application state.
- [ ] Implement scheduled, idempotent exports of data older than 30 days to the Atlas NFS cold tier, with
locking, capacity checks, integrity verification, retention rules, failure monitoring, and a tested restore.
- [ ] Validate management-plane recovery: rebuild Cerberus, restore observability history from Atlas, and
confirm that Uranus provisioning can resume without depending on unreproducible local state.
## Coding Agent Notes
- Shared agent packages live in `ai_agents_npm_packages` in `ansible/inventory/group_vars/all.yml`.
- Shared agent definitions and lifecycle flags live in `ai_agents` in `ansible/inventory/group_vars/all.yml`.
- Shared agent dotfiles live in `ai_agents_dotfiles`; rendered configs live in `ai_agents_templates`.
- Desktop, native workstation, and WSL profiles consume the shared agent package list; do not duplicate package entries in profile-specific vars.
- `dotfiles_common` copies common dotfiles plus `ai_agents_dotfiles`, then renders `ai_agents_templates`.
- Every `ai_agents.<agent>` entry has independent `install_enabled`, `deploy_enabled`, and `uninstall_enabled` flags. Installation and removal must not both be true for the same agent; the common pre-task fails before changes when they conflict.
- Fedora, Void desktop, and WSL workstation profiles consume the shared agent definitions; do not duplicate package entries in profile-specific vars. IBM Bob on the workstation follows its own flags.
- `dotfiles_common` deploys `ai_agents_dotfiles` and renders `ai_agents_templates` only when deployment is enabled.
- Removal is limited to the managed npm packages and `/usr/local/bin/bob`; never remove agent dotfiles, instructions, credentials, or user data.
- Keep `.config/ai/` as the common instruction source; update agent-specific entrypoints to reference it rather than duplicating instruction text.
## Tooling Notes
@@ -100,3 +518,24 @@ The dotfile vars follow the same split: `desktop_common_dotfiles` carries mode-i
- Keep `README.md` and `AGENTS.md` aligned when workflows materially change.
- If you add a new operational area, also add the narrowest validation command for it.
- Call out checks you could not run and any follow-up verification needed.
## Aegis Fedora IoT Notes
- `aegis` is a remote Fedora IoT Raspberry Pi 4 node. Bootstrap it once with
`ansible/bootstrap/aegis.bu`; the remaining configuration is applied by `profile_aegis` over SSH.
- Fedora IoT is immutable. Do not add it to mutable Fedora package or shared dotfile roles.
- `profile_aegis` owns the `nfs-utils` and `wireguard-tools` rpm-ostree layers and reports the required reboot
without initiating it. `wireguard_overlay` then configures Aegis as the WireGuard LAN gateway with persistent IPv4
forwarding, a scoped inter-zone policy, and source masquerading. It also owns rootful Podman Quadlets, persistent container
state under `/var/lib`, the Podman auto-update timer, LAN-restricted firewalld rules, and SSH hardening. Keep
`aegis_lan_subnet`, `aegis_adguard_web_port`, and `aegis_network_connection_uuid` host-specific;
SSH permits only the declared
key-authenticated users, never root or password authentication. Keep Apple IDs and other
credentials in Vault and use `no_log` for their rendering.
- `aegis_adguard_web_port` defaults to `80`. The initial AdGuard Home wizard port `3000` is intentionally unmanaged: open and close it manually only while
completing initial setup. Disable the local systemd-resolved stub through `profile_aegis` before
AdGuard binds port 53; keep
`/etc/resolv.conf` linked to `/run/systemd/resolve/resolv.conf`. LAN clients may use AdGuard, but
Aegis must use the independent upstream DNS declared by `aegis_host_dns_servers` so Greenboot does
not depend on the AdGuard container during startup.
- Aegis iCloudPD has been retired and is no longer managed by this role. Its service, Quadlet,
data, and MFA state were removed with the operator's explicit authorization.

View File

@@ -1,70 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this repo is
Ansible-driven personal infrastructure as code for Fedora desktops (`ikaros`, `nymph`), WSL development (`deadalus-wsl`), and an Ubuntu server (`prometheus`). See `AGENTS.md` for topology and `README.md` for full profile descriptions.
## Validation commands
```bash
# Minimum before any change
ansible-playbook ansible/site.yml --syntax-check
# Dry-run per host
ansible-playbook ansible/site.yml --limit ikaros --check --diff
ansible-playbook ansible/site.yml --limit nymph --check --diff
ansible-playbook ansible/site.yml --limit deadalus-wsl --check --diff
ansible-playbook ansible/site.yml --limit prometheus --check --diff
# Linting
ansible-lint ansible/site.yml
ansible-lint ansible/roles
yamllint ansible/
# Tag-scoped dry-run
ansible-playbook ansible/site.yml --limit ikaros --tags <tag> --check --diff
# Shell scripts
sh -n scripts/bootstrap_mail.sh && shellcheck scripts/bootstrap_mail.sh
```
## Architecture
Configuration is composed in layers: `all → OS → profile → host`. All hosts use `ansible_connection: local`.
`ansible/site.yml` is the sole orchestration entry point — keep it thin. Implementation belongs in roles under `ansible/roles/`.
Variable layering:
- `ansible/inventory/group_vars/` — OS and profile defaults
- `ansible/inventory/host_vars/` — host-specific overrides (never put host overrides in group_vars)
Dotfiles under `dotfiles/` mirror the same layer split: `common/`, `desktop/`, `server/`, `workstation/`, then per-host directories (`ikaros/`, `nymph/`).
## Key conventions
- FQCN for all Ansible modules.
- Prefer declarative modules over `command`/`shell`; when shell is unavoidable, make idempotency explicit.
- YAML: start with `---`, 2-space indent, file modes quoted as `"0644"`, booleans as booleans.
- `no_log: true` on any task that handles secrets.
- Do not restart `emptty` in playbook tasks on active desktop hosts — do it manually via SSH/TTY.
## Void desktop package buckets (keep disjoint)
- `void_packages_base` — system runtime (init, kernel, audio core, networking, firewall, hw daemons)
- `desktop_common_packages` — WM-agnostic GUI infra (GTK theme, polkit, keyring, portals, flatpak, printing)
- `desktop_sway_packages` — Sway-specific binaries
- `profile_packages` — cross-distro; do not move desktop-only Void entries into it
Dotfile var split follows the same logic: `desktop_common_dotfiles` (WM-agnostic), `desktop_void_dotfiles` (Void runtime — turnstile services, bash fragments).
## Secrets
- `secrets/vault.yml` and `secrets/vault.local.yml` are loaded conditionally if present.
- `secrets/.vault_pass.gpg` is used automatically if present; falls back to `secrets/.vault_pass`.
- Never print vault values; always use `no_log: true` where secrets flow through tasks.
## AI agent dotfiles
Shared agent instructions live in `dotfiles/common/.config/ai/`. Agent-specific entrypoints reference that path rather than duplicating content. Shared npm packages are in `ai_agents_npm_packages` in `ansible/inventory/group_vars/all.yml`.

894
README.it.md Normal file
View File

@@ -0,0 +1,894 @@
# Infra — Personal Infrastructure as Code
> **English version:** [README.md](README.md)
Questo repository contiene la configurazione **Infrastructure as Code (IaC)** utilizzata per gestire e mantenere allineate diverse macchine personali tramite **Ansible**.
L'obiettivo è avere **una singola fonte di verità** per:
- configurazione delle macchine
- pacchetti installati
- servizi di sistema
- configurazioni utente (dotfiles)
Il repository consente di gestire più sistemi operativi e profili macchina mantenendo una struttura modulare, riproducibile e idempotente.
---
# Architettura del progetto
```text
infra/
├── ansible/
│ ├── ansible.cfg
│ ├── site.yml
│ ├── inventory/
│ │ ├── hosts.yml
│ │ ├── group_vars/
│ │ └── host_vars/
│ ├── templates/
│ └── roles/
│
├── dotfiles/
│ ├── common/
│ ├── desktop/
│ ├── fedora/
│ ├── server/
│ ├── workstation/
│ ├── workstation_dev_wsl/
│ └── nymph/
│
├── scripts/
├── secrets/
├── README.md
└── README.it.md
```
Il repository è diviso in due componenti principali:
| Componente | Scopo |
| ---------- | -------------------------------------- |
| ansible | provisioning e configurazione macchine |
| dotfiles | configurazioni utente versionate |
---
# Macchine gestite
Il repository modella attualmente host Fedora/GNOME, una workstation Fedora WSL, un server Rocky
Linux 9 e un NAS Rocky Linux 9. La composizione resta separata in assi indipendenti:
```text
common user environment
+ host-specific platform
+ role-specific software
+ independently selectable desktop
+ host hardware overrides
```
Matrice target:
| Host | Platform | Role | Desktop |
| ------------ | -------- | -------------------- | ------- |
| ikaros | Fedora | Personal workstation | GNOME |
| nymph | Fedora | Desktop laptop | GNOME |
| deadalus | Fedora WSL | Workstation dev | — |
| prometheus | Rocky Linux | Server | — |
| atlas | Rocky Linux | NAS | — |
Regola operativa:
```text
ikaros must be boring
nymph is allowed to break
```
`ikaros` usa Fedora Workstation/GNOME come desktop personale stabile; `nymph` usa lo stesso
target Fedora Workstation/GNOME come laptop. I gruppi legacy `void` e `desktop` restano alias di
compatibilita per eventuali host Void futuri mentre i nuovi assi sono
`platform_*`, `role_*` e `desktop_*`.
Nota sullo stato attuale del playbook principale:
- `ansible/site.yml` applica oggi in automatico Fedora/GNOME su `ikaros` e `nymph`
- `ansible/site.yml` applica il profilo Fedora WSL alla workstation `deadalus`
- `ansible/site.yml` applica il profilo server Rocky a `prometheus` con DNF, systemd, dotfiles server e firewalld
- `ansible/site.yml` applica il profilo NAS Rocky su `atlas` tramite SSH remoto
## Nodo pianificato e posticipato: Cerberus
`cerberus` e un nodo di management **posticipato**, in attesa dell'allestimento
fisico dell'ufficio nella nuova casa. Non e ancora presente nell'inventory e non
esistono ruoli o playbook che lo prendano come target.
L'hardware previsto e un Lenovo ThinkCentre M700 Tiny (Intel Core i3-6100T,
8 GB di RAM e SSD da 256 GB) con Ethernet nativa a 1 Gbps. Condividera monitor
e periferiche di Ikaros tramite uno switch KVM a ingressi multipli, usando un
cavo passivo DisplayPort-HDMI per il collegamento video. Il sistema operativo
previsto e Fedora Sericea, la variante Fedora immutabile con compositor Wayland
Sway.
Cerberus sara un management plane isolato: Ansible verra eseguito in un ambiente
Toolbox dedicato per il provisioning del futuro cluster `uranus`, anziche da
Ikaros o da un host non gestito. Lo stack di osservabilita rootless Podman
eseguira Grafana, Prometheus e Loki. L'SSD locale sara l'hot storage, con
metriche e log conservati per 30 giorni; esportazioni programmate trasferiranno
i dati storici piu vecchi su un dataset Atlas montato via NFS come cold storage.
Il piano di implementazione, con prerequisiti espliciti, e in `AGENTS.md`.
## Desktop
Target operativi:
- `ikaros`: Fedora Workstation + GNOME, desktop personale stabile/floating.
- `nymph`: Fedora Workstation + GNOME, laptop desktop con dotfiles desktop condivisi e GNOME lasciato al default Fedora.
Il profilo Void desktop resta disponibile come modello riutilizzabile per host
futuri e usa esclusivamente `desktop_environment: minimal`: Sway e il default,
mentre Niri si seleziona con il gruppo `desktop_niri`. GNOME e disponibile solo
sui target Fedora tramite `desktop_gnome`.
Lo stato attuale del profilo desktop include, tra le altre cose:
- dotfiles comuni e desktop
- sessioni Sway e Niri per eventuali host Void in modalita `minimal`
- `emptty` con default host-specific in modalita `minimal` e session file Wayland per `sway`
- pacchetti Void Linux e servizi runit; le liste pacchetti Void desktop sono separate per criterio:
- `void_packages_base` per il runtime sistema (init, kernel, audio core, networking, firewall, hw daemons)
- `desktop_common_packages` per l'infrastruttura condivisa
- `desktop_minimal_packages` per applicazioni GTK e `emptty`
- `desktop_sway_packages` per i binari specifici della sessione Sway
- `turnstile` per i servizi utente Void, incluso `ssh-agent`
- `ssh-agent` con socket stabile condiviso tra shell e SSH in `~/.local/state/ssh-agent/socket`
- Emacs usa una sola configurazione orientata a Org e authoring, condivisa da desktop Fedora/GNOME e workstation; Vim resta l'editor di sviluppo
- `tmux` con plugin gestiti da TPM al bootstrap del profilo desktop
- Flatpak con remoto Flathub
- GNOME Keyring e `udiskie` nella modalita minimale
- multi-monitor Void: sotto Sway è gestito da `kanshi`
---
## Workstation
La workstation `deadalus` usa Fedora in WSL sulla macchina Windows omonima, senza runtime Flatpak o Snap.
Il profilo è pensato per sviluppo e lavoro.
Nel modello Ansible usato qui, un singolo inventory host puo appartenere intenzionalmente a piu gruppi e quindi ricevere piu play nello stesso run: l'associazione non e `1 host = 1 play`, ma `host + gruppi = layering finale`.
Il profilo workstation e agganciato al playbook principale tramite:
- layer dev Fedora
- layer WSL dedicato per sviluppo con `systemd`
Lo stato attuale del profilo workstation include:
- installazione pacchetti base Fedora via dnf
- installazione e configurazione di Docker dal repository ufficiale
- installazione di Mise dal COPR ufficiale con JDK Eclipse Temurin Java 11 fissato
- gestione dei dotfiles workstation e rendering dei template dev condivisi
- preparazione di Fedora WSL con `systemd` per il toolchain di sviluppo
- attivazione del firewall `firewalld` sui target Fedora che dichiarano regole host-specifiche
Workflow WSL previsto:
1. avviare Fedora WSL almeno una volta e completare la creazione dell'utente Linux
2. installare Ansible dentro la distribuzione WSL
3. lanciare il playbook dalla distribuzione su `deadalus` per configurare l'ambiente dev locale
4. usare VS Code con le estensioni Remote (`WSL`, `SSH`, `Dev Containers`) dal lato Windows
Le applicazioni Windows sono installate e gestite manualmente; il profilo WSL non installa componenti di remoting Python per esse.
---
## Server
La migrazione dei servizi pubblici a `fscotto.co`, la gestione Ansible
degli URL Gitea e i passaggi ancora aperti per ritirare DuckDNS sono in
[`docs/domain-fscotto-co.md`](docs/domain-fscotto-co.md).
Sistema operativo:
- Rocky Linux 9
Configurazione:
- nessun ambiente grafico
Macchina:
- `prometheus`
Profilo orientato a servizi server e gestione di dotfiles dedicati.
Lo stato attuale del profilo server include:
- installazione pacchetti Rocky via DNF, EPEL e CRB
- installazione di Podman e podman-compose
- abilitazione dei servizi systemd dichiarati in inventory/group vars
- copia dei dotfiles server e rendering del Quadlet rootful `prometheus-npm.service` per Nginx Proxy
Manager; il vecchio fallback Compose è stato rimosso con autorizzazione esplicita
- attivazione di firewalld con SSH, Cockpit (`9090/tcp`), HTTP e HTTPS abilitati
- Syncthing escluso dal profilo server Rocky
Il 2026-10-03 la pulizia opt-in autorizzata ha rimosso dati e immagini precedenti di Gitea,
Navidrome e PostgreSQL, directory obsolete vuote, helper finale Gitea e fallback Compose NPM.
I servizi migrati restano su Atlas. `server_legacy_stack_retired: true` evita che i normali task
ricreino i residui; la cancellazione richiede `--tags server_legacy_cleanup` e
`-e server_legacy_cleanup=true`. NPM attivo e archivi di backup restano intatti.
Export, pull Atlas e restore SQLite isolato post-pulizia sono riusciti; il primo ciclo automatico
resta da osservare. Evidenze e confini del recovery:
[`docs/prometheus-npm-quadlet.md`](docs/prometheus-npm-quadlet.md).
Nginx Proxy Manager pubblica solo `80/tcp` e `443/tcp`; la sua interfaccia di amministrazione e
associata a `127.0.0.1:81` ed e raggiungibile da Ikaros o Nymph con l'alias Bash `npm-tunnel`.
Nextcloud resta disabilitato e il profilo non crea directory `/srv/nextcloud`.
La fase 1 su Atlas non modifica i dati persistenti NPM. I proxy host NPM usano gli upstream LAN
`http://192.168.178.55:4533` per Navidrome e `http://192.168.178.55:8384` per la GUI Syncthing;
Prometheus li raggiunge attraverso Aegis come gateway WireGuard. Solo la GUI web di Syncthing usa
NPM; il traffico di sincronizzazione resta sulle porte native esposte sulla LAN dichiarata.
Mantenere l'autenticazione Syncthing e una policy di accesso NPM adeguata.
### DuckDNS
`server_duckdns_enabled: false` disabilita il provisioning su Prometheus, che usa IP statico
e `fscotto.co`. Updater, log e cron ogni cinque minuti sono stati rimossi una sola volta;
non restano task o flag di pulizia. Il nome DuckDNS esterno e il token Vault restano invariati.
Sui server con `server_duckdns_enabled: true`, `profile_server` genera `~/duckdns/duck.sh` con permessi `0700`, mantenendo il percorso dello
script e `duck.log`. Definire `server_duckdns_domain` negli host vars del server e salvare il
**nuovo token rigenerato** in `vault_duckdns_token`, nel Vault cifrato `secrets/vault.yml`
(`ansible-vault edit secrets/vault.yml`) oppure negli override non versionati `secrets/vault.local.yml`.
Non committare lo script generato e non passare il token sulla riga di comando. Il rendering
nasconde output e diff sensibili; lo script verifica TLS e passa il token a curl tramite stdin.
Il playbook non esegue lo script e non modifica la sua schedulazione esterna.
```bash
ansible-playbook ansible/site.yml --limit prometheus --tags duckdns --check --diff
ansible-playbook ansible/site.yml --limit prometheus --tags duckdns
```
La cancellazione dalla cronologia non revoca il token: rigenerarlo sul pannello DuckDNS.
Dopo la bonifica, riclonare gli altri checkout senza unire nuovamente la vecchia storia;
salvare separatamente eventuali modifiche non committate senza copiare segreti.
### Migrazione dati
Dopo il provisioning Rocky, eseguire `scripts/migrate_prometheus_data.sh` **sul server Ubuntu
sorgente**. Lo script usa rsync, e in dry-run di default; richiede `--quiesce-source --execute` per
fermare lo stack sorgente e copiare in modo consistente soltanto i dati di Nginx Proxy Manager e
Gitea. Non sposta Navidrome o Syncthing, non avvia container, non cancella dati e non esegue il
cutover.
Utente del profilo server:
- il profilo usa `server_username`, `server_user_group` e `server_user_home` definiti in `ansible/inventory/group_vars/server.yml`
- per default `server_username` eredita `username`, ma puo essere sovrascritto per tutti gli host server via inventory oppure a runtime con extra vars
- esempio override da CLI:
```bash
ansible-playbook ansible/site.yml --limit prometheus -e server_username=myuser
```
- se necessario puoi passare anche:
```bash
ansible-playbook ansible/site.yml --limit prometheus -e server_username=myuser -e server_user_group=mygroup -e server_user_home=/srv/myuser
```
---
## NAS
`atlas` è un NAS Rocky Linux 9 raggiunto via SSH. Normalmente il pool esiste già e il profilo gestisce
solo i dataset figli. La creazione iniziale del RAIDZ2 richiede esplicitamente `atlas_create_pool=true`
e quattro percorsi `/dev/disk/by-id/...` verificati in `atlas_zpool_disks`. Il ruolo non partiziona,
forza, distrugge, ripristina né modifica il layout vdev di un pool esistente. I client Linux usano NFSv4,
quelli Windows/WSL SMB; l'accesso è limitato alla LAN configurata.
Per il primo avvio servono `vault_atlas_admin_password_hash`, `vault_atlas_samba_password` e
`vault_atlas_immich_db_password`; il primo è un hash compatibile con `/etc/shadow`, non una password
Cockpit in chiaro. Il bootstrap usa l'amministratore preesistente:
```bash
ansible-playbook ansible/site.yml --limit atlas \
-e atlas_connection_username=<existing-admin>
```
Le esecuzioni successive usano `atlas_admin_username`. Storage, condivisioni e firewall LAN sono
abilitati; prima dell'applicazione verificare pool, mountpoint, subnet e zona firewalld. La creazione
del pool è protetta da un gate esplicito e avviene solo se è assente. Atlas non fa più parte della VPN
WireGuard: la vecchia interfaccia è stata ritirata manualmente dopo la verifica del collegamento tra
Prometheus e Aegis. Le chiavi SSH autorizzate sono in file separati sotto
`~/.ssh/authorized_keys.d/`. `atlas_manage_media_stack` resta disabilitato finché `/dev/dri`, percorsi
dei container e segreto del database Immich non sono validati.
Sotto `zpool` Atlas crea `archive` (SMB), `services/data` con i dataset applicativi
`services/data/navidrome` e `services/data/syncthing`, `media`, `media/music`, `media/photobook` e
`backup/hosts/prometheus`. Archivio e applicazioni usano `zstd`; media, Syncthing e backup host usano
`lz4`. `backup` ha una riserva di `500G` che copre i discendenti. SELinux targeted è persistente;
l'eventuale riavvio necessario viene segnalato, non eseguito. Atlas assegna l'interfaccia primaria
alla zona firewalld gestita, rifiuta redirect e source route, registra i martian, mantiene il reverse-path
filter loose e disabilita il forwarding IPv4. SSH consente soltanto l'amministratore dichiarato con
chiave pubblica: root, password, agent forwarding e remote forwarding sono disabilitati, mentre il
forwarding locale resta disponibile per i tunnel amministrativi. SMB3 espone `Archive` agli account
autorizzati da Vault sulla LAN, solo su TCP/445 con cifratura e firma obbligatorie. NFSv4 espone
soltanto `media/photobook` all'IP di Aegis su TCP/2049, con `all_squash` verso UID/GID `1100`.
L'account di sistema `immich` usa UID/GID `1100`, non ha shell di login né gruppo `wheel` e riceve i
gruppi `video` e `render`. Lo stack Immich futuro prevede Quadlet rootful per Server, ML, cache,
PostgreSQL e NPM su una rete Podman comune. Immich gira come `1100:1100`, Server e ML ricevono
`/dev/dri` e Photobook è montato in sola lettura su `/external/photobook`. NPM pubblica `80` e `443`;
l'interfaccia amministrativa resta su `127.0.0.1:81`, raggiungibile via tunnel SSH.
Atlas ospita temporaneamente Navidrome e Syncthing rootless fino alla sostituzione con Uranus. I
servizi sono inizializzati **ex novo**, senza migrare lo stato precedente, rispettivamente sotto
`/zpool/services/data/navidrome` e `/zpool/services/data/syncthing`; la musica in
`/zpool/media/music` è stata popolata separatamente da `/zpool/archive/Music` il 2026-09-30;
Navidrome ha completato la scansione. Il timer rootless `atlas-music-sync.timer` copia i file nuovi
o modificati ogni giorno alle 00:45 Europe/Rome, senza eliminare quelli presenti solo nella
destinazione; entrambi i dataset ZFS devono essere montati. La prima esecuzione schedulata è
riuscita il 2026-10-02. Alcune playlist originali contengono
ancora vecchi percorsi Windows. I servizi sono vincolati all'indirizzo LAN di Atlas
(`192.168.178.55`), mai a WireGuard. `wireguard_overlay` collega invece Prometheus (`10.0.0.1`)
e Aegis (`10.0.0.2`): le chiavi private restano sui rispettivi host e Ansible scambia solo le pubbliche.
Prometheus apre `51820/udp`; Aegis inoltra soltanto il traffico overlay→LAN dichiarato e applica
source NAT, evitando interfacce VPN su Atlas/Uranus e route statiche sul router. Navidrome (`4533/tcp`)
e la GUI Syncthing (`8384/tcp`) ammettono solo Aegis, mentre le porte native Syncthing sono limitate
alla LAN. Dopo la verifica dei servizi, configurare manualmente i Proxy Host NPM verso
`http://192.168.178.55:4533` e `http://192.168.178.55:8384`. Il peer Prometheus include la LAN
negli `AllowedIPs`; aggiungere la VIP Uranus quando esisterà. Dopo il reload di firewalld, Ansible
ricarica le reti Podman rootful di Prometheus per conservare DNS e connettività del proxy.
La migrazione Gitea da Prometheus ad Atlas è descritta in
[`docs/atlas-gitea-migration.md`](docs/atlas-gitea-migration.md). Gitea usa un Quadlet rootless
di `admin` su un dataset dedicato; l'immagine derivata mantiene UID/GID 1000 ma chiama l'utente
interno `gitea`. NPM resta su Prometheus e l'HTTPS pubblico primario serve Atlas. L'SSH pubblico
su TCP/2222 autentica la chiave `ikaros` e un `git ls-remote` è riuscito; l'operatore ha
confermato pull e push SSH. Login e scrittura Git via HTTPS sono stati confermati il 2026-10-03. I dati sorgente restano
conservati su Prometheus senza avviarne il vecchio container.
Validare il gateway con:
```bash
ANSIBLE_LOCAL_TEMP=/tmp/ansible-local \
ansible-playbook ansible/site.yml --limit prometheus,aegis --tags wireguard --check --diff
```
La prima esecuzione reale WireGuard deve includere entrambi i peer. Se Aegis ha appena installato il
layer `wireguard-tools`, riavviarlo manualmente e rieseguire senza `--check`: il ruolo attende un
handshake effettivo.
Gli snapshot ZFS ricorsivi coprono l'intero pool: 24 orari al minuto 05, 30 giornalieri alle 00:15,
8 settimanali la domenica alle 01:00 e 12 mensili il primo giorno alle 02:00. La retention elimina
solo gli snapshot con prefisso gestito `atlas-auto` e non esegue rollback. Lo scrub OpenZFS mensile è
previsto la prima domenica alle 03:00; il timer settimanale incompatibile è disabilitato. Il primo
snapshot orario ricorsivo è riuscito e la pulizia pianificata della retention è stata osservata il
2026-09-30. Il primo scrub mensile richiede ancora una verifica a runtime.
```bash
ANSIBLE_LOCAL_TEMP=/tmp/ansible-local \
ansible-playbook ansible/site.yml --limit atlas --tags snapshots,scrub --check --diff
```
Il backup Borg cifrato usa il sub-account Hetzner `u660064-sub1`, il repository relativo `./borg-data`
e Borg remoto 1.4 su SSH porta 23. La chiave ED25519 del server è fissata; una chiave client dedicata
appartiene all'account `borg`, bloccato e senza login, sudo o gruppi supplementari. La chiave privata
resta in `/etc/atlas-borg`; la passphrase proviene da `vault_atlas_borg_passphrase` ed è resa in un
file `0600`. Solo il wrapper root crea snapshot e mount; avvia il client come `borg` con il minimo
accesso temporaneo in lettura, senza concedergli gestione ZFS o sudo.
Il backup giornaliero parte alle 04:30 con un ritardo casuale fino a 30 minuti. Crea uno snapshot ZFS
ricorsivo temporaneo e ricostruisce tutti i dataset sotto `/zpool` in un albero di bind mount in sola
lettura, per inserirli in un unico archivio coerente. Il wrapper smonta ricorsivamente l'albero privato;
un helper `ExecStopPost` mirato rimuove eventuali mount dello snapshot nel namespace host e lo snapshot
temporaneo dopo l'uscita del processo. Borg conserva 30 archivi giornalieri, 8 settimanali e 12
mensili, poi compatta il repository. Il controllo completo di metadati e repository si svolge il 15
di ogni mese alle 06:00. Le operazioni usano un lock comune, journal e retry systemd limitati. Le
nuove esecuzioni riportano al massimo una riga di avanzamento al minuto: percentuale **stimata**,
dataset, file elaborati e byte originali/compressi/deduplicati. Il denominatore è la somma dei
`logicalreferenced` ZFS dello snapshot, non un totale Borg: può superare il 100% e non comprende
retention, compattazione o controlli. Le righe di progresso non riportano i nomi dei file; eventuali
warning possono farlo. Seguire il job con `sudo journalctl -fu atlas-borg-backup.service`; modifiche
all'helper non cambiano un'esecuzione già avviata.
Attivazione iniziale esplicita:
1. Inserire una passphrase unica in `secrets/vault.yml` con `ansible-vault edit`.
2. Generare e mostrare solo la chiave pubblica con
`ansible-playbook ansible/site.yml --limit atlas --tags borg_key`.
3. Installarla nel sub-account Hetzner, poi applicare con
`ansible-playbook ansible/site.yml --limit atlas --tags packages,borg`.
4. Copiare `secrets/recovery/atlas-borg-repokey.export` su un supporto davvero offline: la copia
locale ignorata da Git non è di per sé un backup offline.
Il ruolo inizializza solo un repository `repokey` assente, non accetta password SSH né host key non
fissate e non avvia manualmente il primo backup. Validazione:
```bash
ANSIBLE_LOCAL_TEMP=/tmp/ansible-local \
ansible-playbook ansible/site.yml --limit atlas --tags packages,borg --check --diff
```
L'attivazione iniziale è riuscita: backup e controllo del repository, restore completo in una
directory temporanea confrontato con l'albero `Archive`, esportazione offline della chiave di recupero
e pulizia di snapshot/mount temporanei. Il 2026-09-25 un test separato da snapshot ZFS giornaliero ha
copiato un file di `/zpool/archive` in `/var/tmp`, verificando contenuto, proprietario, modalità,
mtime e ACL POSIX; copia e mount temporanei sono stati rimossi senza interrompere Borg. Non è un test
di ripristino dell'intero dataset.
L'archivio del pool popolato del 2026-09-29 ha richiesto 1 h 32 min per 2,18 TB originali / 2,04 TB
compressi, con 13,49 GB di dimensione deduplicata. Retention e compattazione sono riuscite, ma un
errore di permessi su `RuntimeDirectory` ha impedito la pulizia dello snapshot dopo il job. Dopo la
correzione, l'archivio incrementale del 2026-09-30 è terminato in circa 22 secondi, ha rimosso lo
snapshot residuo e quello corrente ed è terminato con stato 0. Il monitor ha rilevato il 37% della
quota Storage Box utilizzata. Questi risultati non predicono durata o compressione dei prossimi run.
Il backup USB offline è distribuito come **servizio solo manuale** (`atlas_manage_usb_backup: true`):
Ansible non formatta, sblocca, monta né avvia automaticamente il disco. Il disco esistente è stato
verificato in sola lettura il 2026-09-23: UUID LUKS `577b3c43-ea37-4611-81a9-39d555cdfbd4`,
UUID ext4 interno `758e2d2e-a427-4797-aad9-39c3a9f17c7e`, mapper `zpool-backup`. All'ispezione
era montato in `/mnt/zpool-backup`; il servizio richiede invece che il mapper **non sia montato** prima
dell'avvio. Se serve, `systemd-ask-password` chiede interattivamente la passphrase LUKS tramite
l'agente di `systemctl start` e la passa direttamente a `cryptsetup`, senza salvarla, esporla negli
argomenti o memorizzarla nella cache. Lo script monta il disco privatamente, crea uno snapshot ZFS
ricorsivo, copia tutti i dataset in `atlas/snapshots/<timestamp>/` con `rsync --link-dest`, verifica
con un dry-run basato sui checksum, aggiorna atomicamente `atlas/latest`, smonta e chiude LUKS. Un
errore non sostituisce `latest` né cancella versioni complete precedenti. Borg e USB possono operare
contemporaneamente su snapshot distinti, ma la lettura concorrente può ridurre il throughput.
La copia USB conserva le ACL ma non gli attributi estesi generici, compreso `security.selinux`: la
policy della destinazione deve ricreare le etichette dopo un restore. Per un percorso esplicito:
```bash
ansible-playbook ansible/site.yml --limit atlas --tags restorecon \
-e '{"atlas_restorecon_paths":["/zpool/archive"]}'
```
Il task accetta solo percorsi sotto la radice del pool Atlas, esegue `restorecon -RFv` solo su quelli
indicati ed è altrimenti inattivo; non va lanciato sull'intero pool durante i run ordinari. Le vecchie
versioni USB non vengono eliminate automaticamente senza una retention deliberata. Il controllo di
capacità include il trasferimento stimato e una riserva libera di 10 GiB. Dopo un backup riuscito,
scollegare fisicamente il disco per renderlo davvero offline.
Validare la configurazione senza avviare il backup e, separatamente, un eventuale relabel pianificato:
```bash
ANSIBLE_LOCAL_TEMP=/tmp/ansible-local \
ansible-playbook ansible/site.yml --limit atlas --tags usb_backup,usb_reminder --check --diff
ansible-playbook ansible/site.yml --limit atlas --tags restorecon --check \
-e '{"atlas_restorecon_paths":["/zpool/archive"]}'
```
Prima dell'avvio manuale smontare in sicurezza `/mnt/zpool-backup`, se ancora montato. Con il mapper
chiuso, `sudo systemctl start atlas-usb-backup.service` chiede la passphrase e avvia il backup; né la
password LUKS né un keyfile vanno in Ansible. Seguire con
`sudo journalctl -fu atlas-usb-backup.service`. **Non esiste un timer di backup USB.** Soltanto
`atlas-usb-reminder.timer` è schedulato il primo sabato del mese alle 10:00 `Europe/Rome`: invia un
promemoria al notifier 45Drives Houston, senza avviare il backup. Un test manuale ha prodotto una
notifica in 45Drives Alerts, **non un'email**; il log conferma l'invio della notifica, non la consegna
di posta. Il primo evento pianificato era il 2026-10-03 alle 10:00 CEST. Controllare timer e risultato
con `systemctl list-timers atlas-usb-reminder.timer` e in 45Drives Alerts.
Il primo tentativo USB del 2026-09-23 fallì su `security.selinux` e, dopo l'interruzione, lasciò
snapshot e mapper aperti. Applicato il filtro rsync, furono rimossi lo snapshot fallito, il mapper
smontato e lo stato failed; non rimase una copia valida di quel tentativo. Un run del 2026-09-24
pubblicò una versione verificata ma fallì nella distruzione dello snapshot a causa di mount
`.zfs/snapshot` aperti nel namespace host. Dopo la pulizia non forzata, è stato aggiunto un helper
`ExecStopPost` mirato e testato con uno snapshot usa-e-getta. Un run successivo del 2026-09-24 ha
verificato i checksum, pubblicato la versione ed è terminato con successo: mapper chiuso, nessuno
snapshot USB temporaneo e pool sano. Il 2026-09-25 un test di restore indipendente ha aperto il disco
in sola lettura, montato ext4 con `ro,noload`, copiato un file di 5.707.945 byte da `atlas/latest` in
una directory vuota sotto `/var/tmp` e confrontato contenuto, proprietario, modalità, dimensione,
mtime e ACL POSIX. Il test ha rimosso copia e mount temporanei, chiuso LUKS e lasciato il pool sano
mentre Borg continuava. È un test su file, non un esercizio completo di disaster recovery.
Il monitoraggio Atlas è eseguito ogni 30 minuti da `atlas-health-monitor.timer`. Sonde in sola
lettura controllano stato/errori del pool e dei vdev, scrub/resilver, SMART dei quattro dischi del
pool e dell'NVMe di sistema, temperature dei dischi e CPU, spazio di sistema/pool/snapshot, crescita
di `zpool/backup` e quota Hetzner tramite `df -m` via SSH con l'account `borg` e la chiave fissata.
La query remota non apre il repository Borg né il suo lock. Gli alert di crescita richiedono una
baseline di circa 24 ore. Sono controllati anche attivazione e freschezza dei timer; hook systemd
`OnFailure` segnalano errori di snapshot, scrub, Borg, USB, promemoria e monitoraggio. Il monitor non
riavvia Borg; avvisa solo se un run supera 14 giorni. Soglie e percorsi stabili dei dischi sono nelle
variabili host. Gli avvisi usano 45Drives Houston con deduplicazione; **la consegna email non è stata
verificata**. Il controllo live del 2026-09-25 non ha trovato problemi e ha inviato una notifica di
prova. Il 2026-09-30 il monitor ha rilevato zero problemi e una quota Storage Box occupata al 37%.
L'hook per i job falliti ora passa il nome letterale della unità systemd; l'espansione è stata
verificata senza inviare un falso allarme.
```bash
ansible-playbook ansible/site.yml --limit atlas --tags monitoring --check --diff
sudo /usr/local/libexec/atlas-health-monitor --dry-run
sudo journalctl -u atlas-health-monitor.service -n 100 --no-pager
systemctl list-timers atlas-health-monitor.timer
```
`--dry-run` non invia alert e non modifica lo stato del monitor. Un controllo reale si avvia con
`sudo systemctl start atlas-health-monitor.service`, senza avviare servizi di backup. Per una prova
etichettata di 45Drives Alerts usare
`sudo /usr/local/libexec/atlas-health-monitor --test-notification`.
### Timer systemd di Atlas
Tutti i dieci timer gestiti sono abilitati. Gli orari sono locali ad Atlas (`Europe/Rome`); Borg e
monitoraggio aggiungono il ritardo casuale indicato. Tutti hanno `Persistent=true`: un evento perso
viene recuperato quando il timer torna attivo.
| Timer | Pianificazione (`OnCalendar`) | Azione |
| --- | --- | --- |
| `atlas-zfs-snapshot-hourly.timer` | `*-*-* *:05:00` — ogni ora al minuto 05 | Snapshot ricorsivo orario e retention |
| `atlas-zfs-snapshot-daily.timer` | `*-*-* 00:15:00` — ogni giorno alle 00:15 | Snapshot ricorsivo giornaliero e retention |
| `atlas-zfs-snapshot-weekly.timer` | `Sun *-*-* 01:00:00` — domenica alle 01:00 | Snapshot ricorsivo settimanale e retention |
| `atlas-zfs-snapshot-monthly.timer` | `*-*-01 02:00:00` — primo giorno del mese alle 02:00 | Snapshot ricorsivo mensile e retention |
| `zfs-scrub-monthly@zpool.timer` | `Sun *-*-01..07 03:00:00` — prima domenica alle 03:00 | Scrub ZFS |
| `atlas-borg-backup.timer` | `*-*-* 04:30:00` — ogni giorno alle 04:30, più 0–30 min casuali | Backup cifrato offsite |
| `atlas-borg-check.timer` | `*-*-15 06:00:00` — giorno 15 alle 06:00, più 0–30 min casuali | Controllo repository Borg |
| `atlas-usb-reminder.timer` | `Sat *-*-01..07 10:00:00 Europe/Rome` — primo sabato alle 10:00 | Solo promemoria 45Drives Alerts |
| `atlas-health-monitor.timer` | `*:0/30` — ogni mezz'ora, più 0–5 min casuali | Controlli di salute in sola lettura |
| `atlas-prometheus-pull.timer` | `*-*-* 03:00:00 Europe/Rome` — ogni giorno alle 03:00 | Pull e verifica del backup preparato su Prometheus |
`atlas-usb-backup.service` **non ha timer** e va avviato manualmente. Il timer del fornitore
`zfs-scrub-weekly@zpool.timer` è disabilitato a favore dello scrub mensile. Il timer di preparazione
su Prometheus è attivo alle 02:00 Europe/Rome; il primo ciclo pianificato è riuscito il 2026-10-01.
Un export, pull e ripristino temporaneo post-cutover NPM Quadlet sono riusciti il 2026-10-03;
il primo ciclo pianificato dopo quel cutover resta da osservare. Durante un backup Borg attivo,
`systemctl list-timers` può mostrare `-` per il prossimo evento senza che il timer sia disabilitato.
Per vedere la pianificazione corrente: `systemctl list-timers --all` su Atlas.
Nextcloud è previsto come servizio temporaneo su Atlas prima di Uranus, ma solo dopo la validazione
della protezione dei dati: richiede storage applicativo, database e cache separati, segreti Vault,
pubblicazione solo tramite NPM e Aegis, procedure di backup, aggiornamento e migrazione. Non
distribuirlo prima di completare la checklist di protezione dei dati.
Atlas è la destinazione dichiarata per iCloudPD. Ansible gestisce dataset, Quadlet rootless e
`icloudpd.conf` privato con Apple ID dal Vault: foto in `/zpool/archive/Pictures/iCloudPD`,
stato in `zpool/services/data/icloudpd`. Il primo avvio è stato manuale; password e MFA restano
gestiti interattivamente, senza avvio automatico al boot. L'inizializzazione è stata completata e
il download iniziale di foto e video è terminato il 2026-10-03. Su Aegis
il servizio, il Quadlet e `/var/lib/icloudpd` sono stati rimossi e verificati; il playbook Aegis
non contiene più task iCloudPD. L'accesso SMB e il ripristino dai backup dei nuovi dati restano
da verificare. L'export NFS Photobook resta
invariato. Dettagli in [`docs/atlas-icloudpd-migration.md`](docs/atlas-icloudpd-migration.md).
Il primo ciclo pianificato del backup di Prometheus e una prova di disaster recovery a dimensione reale
restano da verificare. Il 2026-09-30 una VM Rocky isolata ha superato ricostruzione OS con Ansible,
import del pool RAIDZ2 fittizio e ripristino da snapshot; RPO 24 ore/RTO 72 ore restano obiettivi
provvisori, non tempi misurati. Dettagli e limiti sono in `docs/atlas-recovery.md`. Il backlog
prioritizzato è in `AGENTS.md`.
---
# Composizione della configurazione
Emacs è abilitato sui profili Fedora/GNOME e workstation; la configurazione canonica è distribuita da `dotfiles_common`, con Org in `~/Org/`, template versionati e export PDF/HTML/Markdown/DOCX/ODT. Per abilitarlo temporaneamente su un altro profilo:
```bash
ansible-playbook ansible/site.yml --limit <host> --tags emacs -e emacs_enabled=true
```
La configurazione finale di una macchina è ottenuta combinando più livelli.
```text
common configuration
+ platform configuration
+ role configuration
+ desktop configuration
+ host overrides
```
Esempi correnti:
```text
ikaros -> common + platform_fedora + role_personal_workstation + graphical_desktop + desktop_gnome + ikaros
nymph -> common + platform_fedora + graphical_desktop + desktop_gnome + nymph
deadalus -> common + platform_fedora + workstation_dev_fedora + workstation_dev_wsl + deadalus
```
Questo approccio consente di:
- mantenere configurazioni condivise
- applicare override specifici per host
- evitare duplicazioni
- riutilizzare il profilo Void corrente su un host futuro assegnandolo a
`platform_void + graphical_desktop + desktop_sway`
---
# Ruoli Ansible
I principali ruoli attualmente presenti sono:
| Role | Descrizione |
| ------------------------- | ----------------------------------- |
| base | configurazione base comune |
| packages_void | installazione pacchetti su Void |
| packages_fedora | installazione pacchetti su Fedora |
| packages_rocky | installazione pacchetti su Rocky Linux 9 |
| services_runit | gestione servizi runit |
| services_systemd | gestione servizi systemd |
| profile_desktop_common | bootstrap desktop Void condiviso |
| profile_desktop_gnome | dotfiles desktop condivisi per Fedora/GNOME |
| profile_desktop_sway | sessione desktop sway / SwayFX (Wayland) |
| profile_desktop_niri | sessione desktop Niri su Void (Wayland) |
| profile_desktop_host | override desktop specifici per host |
| profile_personal_workstation | layer stabile per workstation personale |
| profile_workstation_dev_common | configurazione dev workstation condivisa |
| profile_workstation_dev_wsl | configurazione WSL condivisa per sviluppo |
| profile_server | configurazione server |
| profile_atlas | configurazione NAS Rocky Linux 9 |
| profile_backend_phase1 | Navidrome e Syncthing rootless su Atlas |
| wireguard_overlay | overlay WireGuard Prometheus/Atlas |
| dotfiles_common | distribuzione dotfiles comuni |
| dotfiles | distribuzione configurazioni utente |
---
# Stato attuale del playbook principale
Il playbook `ansible/site.yml` e attualmente composto da blocchi per asse:
```text
all -> dotfiles_common
platform_void -> packages_void + services_runit
platform_void & graphical_desktop -> profile_desktop_common + profile_desktop_sway + profile_desktop_niri + profile_desktop_host
platform_fedora -> packages_fedora + services_systemd
platform_rocky -> packages_rocky + services_systemd
wireguard_overlay -> wireguard_overlay (dopo platform_rocky)
atlas -> profile_atlas
role_backend_phase1 -> profile_backend_phase1 (dopo atlas)
platform_fedora & role_personal_workstation -> profile_personal_workstation
platform_fedora & desktop_gnome -> profile_desktop_gnome
workstation_dev_fedora -> profile_workstation_dev_common
workstation_dev_wsl -> profile_workstation_dev_wsl (dopo platform_fedora + workstation_dev_fedora)
rocky_server -> dotfiles_common + profile_server (dopo platform_rocky)
```
Questo significa che, allo stato attuale:
- `ikaros` riceve Fedora Workstation/GNOME come target desktop personale stabile
- `nymph` riceve Fedora Workstation/GNOME come target laptop
- il profilo Void resta selezionabile tramite `platform_void + graphical_desktop` per host futuri
- `deadalus` riceve il profilo Fedora WSL tramite play dev dedicati
- il server Rocky (`prometheus`) e gestito con pacchetti, servizi, dotfiles server e firewalld
- il NAS Rocky (`atlas`) usa un pool ZFS gia esistente, condivisioni NFSv4/SMB limitate alla LAN e Cockpit/45Drives
- NPM è un Quadlet rootful su Prometheus, mentre Gitea, Navidrome e Syncthing sono Quadlet
rootless su Atlas; il fallback Compose server è stato rimosso
# Dotfiles
La directory `dotfiles/` contiene le configurazioni utente versionate.
```text
dotfiles/
├── common
├── desktop
├── server
├── fedora
├── workstation
├── workstation_dev_wsl
└── nymph
```
Le configurazioni sono applicate tramite Ansible e organizzate per livelli:
| Livello | Scopo |
| ------- | -------------------------------- |
| common | configurazioni condivise |
| profile | configurazioni per tipo macchina |
| host | override specifici |
---
# Requisiti
Per utilizzare il repository sono necessari:
- Python 3
- Ansible
- `ansible-lint`
- `yamllint`
- `shellcheck`
- collection definite in `ansible/collections/requirements.yml`
- accesso locale o SSH alle macchine target, in base a come e definito l'inventory
Installazione base:
```bash
python3 -m pip install ansible ansible-lint yamllint shellcheck-py
ansible-galaxy collection install -r ansible/collections/requirements.yml
```
Gestione segreti:
- il repository supporta il caricamento opzionale di `secrets/vault.yml`
- il repository supporta anche `secrets/vault.local.yml` per override locali non versionati
- `secrets/vault.yml.example` funge da template/esempio
- se `secrets/vault.yml` non e presente, il playbook continua comunque senza caricare variabili locali opzionali
- se `secrets/.vault_pass.gpg` esiste viene usato automaticamente per sbloccare i vault tramite `gpg`; in alternativa resta supportato `secrets/.vault_pass` come fallback legacy locale; se nessuno dei due file esiste Ansible richiede la password in modo interattivo
---
# Utilizzo
Eseguire il playbook principale:
```bash
ansible-playbook ansible/site.yml
```
Allo stato attuale questo comando:
- distribuisce i dotfiles comuni a tutti gli host
- per `platform_void` applica pacchetti Void e servizi runit
- per `platform_void + graphical_desktop` applica bootstrap desktop condiviso, sessioni Sway/Niri e override specifici per host
- per `platform_fedora` applica pacchetti Fedora e servizi systemd a `ikaros`, `nymph` e `deadalus`
- per `platform_fedora & role_personal_workstation` applica il layer personale a `ikaros`
- per `platform_fedora & desktop_gnome` applica il profilo GNOME a `ikaros` e `nymph`
- per `workstation_dev_wsl` applica i tweak WSL dopo il layer Fedora a `deadalus`, escludendo Flatpak e Snap
- per `platform_rocky` applica pacchetti Rocky e servizi systemd ad `atlas` e `prometheus`; quindi applica il profilo NAS ad `atlas` e il profilo server a `prometheus`
- non riavvia automaticamente il display manager
- carica `secrets/vault.yml` solo se presente
- carica `secrets/vault.local.yml` solo se presente, dopo `vault.yml`, cosi gli override locali hanno precedenza
Per validare prima di applicare:
```bash
ansible-playbook ansible/site.yml --syntax-check
ansible-playbook ansible/site.yml --limit ikaros,nymph --check --diff
ansible-playbook ansible/site.yml --limit ikaros --check --diff
ansible-playbook ansible/site.yml --limit nymph --check --diff
ansible-playbook ansible/site.yml --limit deadalus --check --diff
ansible-playbook ansible/site.yml --limit prometheus --check --diff
ansible-playbook ansible/site.yml --limit atlas --check --diff
ansible-lint ansible/site.yml
ansible-lint ansible/roles
yamllint ansible/
```
Per testare un override dell'utente server senza modificare l'inventory:
```bash
ansible-playbook ansible/site.yml --limit prometheus --check --diff -e server_username=myuser
```
Per validazioni piu mirate:
```bash
ansible-playbook ansible/site.yml --limit <host> --tags <tag1>,<tag2> --check --diff
ansible-playbook ansible/site.yml --limit <host> --start-at-task "<task name>" --check --diff
ansible-lint ansible/roles/<role>
yamllint ansible/path/to/file.yml
ansible-playbook ansible/site.yml --limit prometheus --tags server_legacy_cleanup --check --diff -e server_legacy_cleanup=true
ansible-playbook ansible/site.yml --limit atlas --tags storage,sharing,containers --check --diff
ansible-playbook ansible/site.yml --limit atlas --tags backend_phase1 --check --diff
ansible-playbook ansible/site.yml --limit atlas --tags music_sync --check --diff
```
## Tag supportati dal playbook
Per vedere l'elenco reale aggiornato dei tag disponibili:
```bash
ansible-playbook ansible/site.yml --list-tags
```
Allo stato attuale `ansible/site.yml` espone questi tag:
| Tag | Scopo | Ambito principale |
| --- | --- | --- |
| `always` | pre-task sempre eseguiti, inclusi caricamento vault e validazioni preliminari | common |
| `ai_agents` | installazione agenti AI condivisi | Fedora, WSL |
| `atlas` | account, storage, condivisioni e container Atlas | NAS Atlas |
| `backend_phase1` | Quadlet rootless Navidrome e Syncthing | NAS Atlas |
| `containers` | Quadlet rootful Atlas | NAS Atlas |
| `dotfiles` | distribuzione/configurazione dotfiles | tutti i profili |
| `dotfiles:common` | dotfiles comuni condivisi | common, workstation, server |
| `dotfiles:desktop` | dotfiles desktop | desktop Void, Fedora/GNOME |
| `dotfiles:host` | override host-specifici desktop | desktop Void |
| `dotfiles:server` | dotfiles dedicati al profilo server | server |
| `dotfiles:workstation` | dotfiles dedicati alle workstation | personal workstation, WSL |
| `emptty` | gestione display manager `emptty` | desktop Void |
| `display-manager` | gestione del display manager `emptty` | desktop Void |
| `emacs` | configurazione Emacs condivisa e dipendenze di authoring | desktop Fedora/GNOME e workstation |
| `fonts` | installazione font | Fedora |
| `fzf` | configurazione FZF | dotfiles comuni |
| `git` | configurazione Git e GPG desktop | Fedora/GNOME, desktop Void |
| `gnome` | configurazione host GNOME | Fedora/GNOME desktop |
| `immich` | account e Quadlet Immich | NAS Atlas |
| `sway` | sessione/configurazione sway / SwayFX (Wayland) | desktop Void |
| `niri` | sessione/configurazione Niri (Wayland) | desktop Void |
| `npm` | installazione pacchetti npm globali | Fedora/GNOME, desktop Void, WSL |
| `nvidia` | componenti NVIDIA desktop | desktop Void |
| `packages` | installazione e aggiornamento pacchetti | tutti i profili |
| `podman` | integrazione Podman Compose e Quadlet rootless | server |
| `portal` | configurazione xdg-desktop-portal | desktop Void |
| `services` | gestione servizi runit/systemd | tutti i profili |
| `sharing` | condivisioni NFSv4 e SMB3 | NAS Atlas |
| `storage` | dataset ZFS figli | NAS Atlas |
| `theme` | configurazione del tema GTK/Qt | desktop Void |
| `tmux` | configurazione e plugin tmux | desktop Fedora/Void, WSL |
| `vim` | configurazione Vim | dotfiles comuni |
| `wireguard` | overlay WireGuard Prometheus/Atlas | Prometheus, NAS Atlas |
| `wsl` | bootstrap e configurazione WSL | WSL |
Esempi pratici:
```bash
ansible-playbook ansible/site.yml --limit nymph --tags dotfiles:desktop,gnome --check --diff
ansible-playbook ansible/site.yml --limit ikaros --tags gnome --check --diff
ansible-playbook ansible/site.yml --limit prometheus --tags services,dotfiles:server --check --diff
```
---
# Bootstrap di una nuova macchina
Una nuova macchina può essere inizializzata con i seguenti passaggi:
```bash
git clone <repo>
cd <repo-dir>
ansible-galaxy collection install -r ansible/collections/requirements.yml
ansible-playbook ansible/site.yml
```
Dopo l'esecuzione del playbook la macchina verra configurata secondo il profilo definito e i ruoli attualmente orchestrati.
Per aggiungere un nuovo host Void che riusa il profilo desktop preservato:
1. aggiungere l'host a `platform_void`;
2. aggiungerlo a `graphical_desktop`;
3. usare Sway, oppure aggiungerlo a `desktop_niri` per selezionare Niri;
4. lasciare eventuali dettagli hardware in `host_vars/<host>.yml`.
I gruppi legacy `void` e `desktop` sono parent di compatibilita, quindi un host
in `platform_void` e `graphical_desktop` continua a ricevere anche le variabili
Void e desktop esistenti.
Per il flusso mail desktop esiste inoltre uno script dedicato:
```bash
scripts/bootstrap_mail.sh
```
Lo script si occupa del bootstrap dei secret nel keyring, del primo sync con `mbsync` e dell'inizializzazione di `mu` usando la configurazione mail generata dai template.
Se modifichi questo script, valida almeno con:
```bash
sh -n scripts/bootstrap_mail.sh
shellcheck scripts/bootstrap_mail.sh
```
---
# Filosofia del progetto
Il repository segue alcuni principi chiave:
- Infrastructure as Code
- configurazione dichiarativa
- idempotenza
- ambienti riproducibili
- separazione tra configurazione sistema e configurazione utente
Questo consente di ricreare qualsiasi macchina partendo esclusivamente dal repository.
---
# Roadmap
Possibili evoluzioni future:
- hardening sicurezza server
- configurazione backup
- testing automatico playbook
- integrazione CI
- supporto ad altre distribuzioni Linux
---
# Licenza
Questo progetto è distribuito sotto licenza **LGPL-3.0**.

1015
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,57 @@
# One-time bootstrap for Fedora IoT on Aegis (Raspberry Pi 4).
# Generate only: ./generate-aegis-ign.sh
# Generate and write an SD card: ./generate-aegis-ign.sh --write IMAGE DEVICE
# The write mode uses arm-image-installer with the RPi4 target and embeds
# config.ign; it prompts for Wi-Fi credentials unless supplied through its
# WIFI_SSID and WIFI_PASS environment variables.
# For WiFi, the UEFI 'System Table Selection' must be DeviceTree (Esc at boot).
variant: fiot
version: 1.0.0
passwd:
users:
- name: admin
password_hash: "$6$bNDsU0XC5BxNJS5U$ENDGpdOUPJM3wcXBgNESCQkOqqQ9gN72/xEQoJsAI6QdsaY6mD4G5DBVIv7nHwHQOP35IH1oGRl.Uy3R2iUYQ1"
groups:
- wheel
ssh_authorized_keys:
- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINrIxXjA3ffPwziKGR5gzc4gAoBehQPlnEMcXF4Wl0ZS ikaros"
- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEH/7GJfGt0ZVmKeEzceoFkFkeCXFryKK9vAbaip+HCx nymph"
- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA95wYlzpfN3rjUhpMeP4KHn8I6ZrjQXoDTgwgRIa++b siren"
systemd:
units:
- name: sshd.service
enabled: true
- name: rpm-ostree-install-wifi.service
enabled: true
contents: |
[Unit]
Description=Layer WiFi packages (driver, firmware, NetworkManager-wifi)
Wants=network-online.target
After=network-online.target
Before=zincati.service
ConditionPathExists=!/var/lib/%N.stamp
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/rpm-ostree install -y --allow-inactive NetworkManager-wifi NetworkManager-wwan wpa_supplicant wireless-regdb iw brcmfmac-firmware
ExecStart=/bin/touch /var/lib/%N.stamp
ExecStart=/bin/systemctl --no-block reboot
[Install]
WantedBy=multi-user.target
storage:
files:
- path: /etc/hostname
mode: 0644
contents:
inline: |
aegis
- path: /etc/sysctl.d/99-ip-forward.conf
mode: 0644
contents:
inline: |
net.ipv4.ip_forward = 1
links:
- path: /etc/localtime
target: ../usr/share/zoneinfo/Europe/Rome

View File

@@ -0,0 +1,147 @@
#!/usr/bin/env sh
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
BUTANE_IMAGE=${BUTANE_IMAGE:-quay.io/coreos/butane:release}
BUTANE_SOURCE=${BUTANE_SOURCE:-"$SCRIPT_DIR/aegis.bu"}
IGNITION_OUTPUT=${IGNITION_OUTPUT:-"$SCRIPT_DIR/config.ign"}
SSH_PUBLIC_KEY=${SSH_PUBLIC_KEY:-"$HOME/.ssh/id_ed25519.pub"}
WIFI_SECURITY=${WIFI_SECURITY:-wpa-psk}
usage() {
cat <<'USAGE'
Usage:
generate-aegis-ign.sh
generate-aegis-ign.sh --write IMAGE DEVICE
Environment overrides:
BUTANE_IMAGE Butane container image (default: quay.io/coreos/butane:release)
BUTANE_SOURCE Butane source path (default: aegis.bu beside this script)
IGNITION_OUTPUT Ignition output path (default: config.ign beside this script)
SSH_PUBLIC_KEY SSH public key passed to arm-image-installer
WIFI_SSID Wi-Fi SSID; prompted if unset in --write mode
WIFI_PASS Wi-Fi password; prompted if unset in --write mode
WIFI_SECURITY Wi-Fi security type (default: wpa-psk)
USAGE
}
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
printf 'Error: required command not found: %s\n' "$1" >&2
exit 1
fi
}
read_required() {
prompt=$1
value=$2
if [ -z "$value" ]; then
printf '%s' "$prompt" >&2
IFS= read -r value
fi
if [ -z "$value" ]; then
printf '%s\n' 'Error: a value is required.' >&2
exit 1
fi
printf '%s' "$value"
}
read_secret() {
value=$1
if [ -z "$value" ]; then
printf '%s' 'Wi-Fi password: ' >&2
stty -echo
IFS= read -r value
stty echo
printf '\n' >&2
fi
if [ -z "$value" ]; then
printf '%s\n' 'Error: a value is required.' >&2
exit 1
fi
printf '%s' "$value"
}
write_image=false
case $# in
0)
;;
3)
if [ "$1" != '--write' ]; then
usage >&2
exit 2
fi
write_image=true
IMAGE=$2
DEVICE=$3
;;
*)
usage >&2
exit 2
;;
esac
require_command podman
if [ ! -f "$BUTANE_SOURCE" ]; then
printf 'Error: Butane source not found: %s\n' "$BUTANE_SOURCE" >&2
exit 1
fi
OUTPUT_DIR=$(dirname "$IGNITION_OUTPUT")
if [ ! -d "$OUTPUT_DIR" ]; then
printf 'Error: output directory not found: %s\n' "$OUTPUT_DIR" >&2
exit 1
fi
umask 077
TEMP_OUTPUT=$(mktemp "$OUTPUT_DIR/.config.ign.XXXXXX")
trap 'rm -f "$TEMP_OUTPUT"' EXIT HUP INT TERM
podman run --rm -i "$BUTANE_IMAGE" --strict < "$BUTANE_SOURCE" > "$TEMP_OUTPUT"
mv "$TEMP_OUTPUT" "$IGNITION_OUTPUT"
trap - EXIT HUP INT TERM
printf 'Generated Ignition config: %s\n' "$IGNITION_OUTPUT"
if [ "$write_image" = false ]; then
exit 0
fi
if [ ! -f "$IMAGE" ]; then
printf 'Error: image not found: %s\n' "$IMAGE" >&2
exit 1
fi
if [ ! -b "$DEVICE" ]; then
printf 'Error: target is not a block device: %s\n' "$DEVICE" >&2
exit 1
fi
if [ ! -f "$SSH_PUBLIC_KEY" ]; then
printf 'Error: SSH public key not found: %s\n' "$SSH_PUBLIC_KEY" >&2
exit 1
fi
require_command arm-image-installer
WIFI_SSID=$(read_required 'Wi-Fi SSID: ' "${WIFI_SSID:-}")
WIFI_PASS=$(read_secret "${WIFI_PASS:-}")
printf 'Writing %s to %s.\n' "$IMAGE" "$DEVICE" >&2
sudo arm-image-installer \
--image="$IMAGE" \
--target=rpi4 \
--media="$DEVICE" \
--ignition="$IGNITION_OUTPUT" \
--addkey="$SSH_PUBLIC_KEY" \
--resizefs \
--wifi-ssid="$WIFI_SSID" \
--wifi-pass="$WIFI_PASS" \
--wifi-security="$WIFI_SECURITY"

View File

@@ -1,8 +0,0 @@
---
# Optional inventory overlay for VM tests. It is not loaded by ansible.cfg.
all:
children:
platform_freebsd:
hosts:
freebsdtest:
ansible_connection: local

View File

@@ -90,40 +90,82 @@ common_dotfiles:
dest: .config/bat/
mode: preserve
ai_agents_npm_packages:
- name: "opencode-ai"
state: latest
- name: "@anthropic-ai/claude-code"
state: latest
- name: "@openai/codex"
state: latest
- name: "@google/gemini-cli"
state: latest
- name: "@githubnext/github-copilot-cli"
state: latest
# AI coding-agent lifecycle controls are independent for every agent.
# Installation and removal are mutually exclusive per agent; the common
# pre-task enforces this before any changes are made.
codex_relay_install_enabled: false
ai_agents_enabled: true
ai_agents:
opencode:
npm_package: opencode-ai
install_enabled: true
deploy_enabled: true
uninstall_enabled: false
claude_code:
npm_package: "@anthropic-ai/claude-code"
install_enabled: false
deploy_enabled: false
uninstall_enabled: true
codex:
npm_package: "@openai/codex"
install_enabled: true
deploy_enabled: true
uninstall_enabled: false
codex_relay:
npm_package: codex-relay
install_enabled: "{{ codex_relay_install_enabled }}"
deploy_enabled: false
uninstall_enabled: false
gemini_cli:
npm_package: "@google/gemini-cli"
install_enabled: true
deploy_enabled: true
uninstall_enabled: false
github_copilot:
npm_package: "@github/copilot"
install_enabled: false
deploy_enabled: false
uninstall_enabled: true
ibm_bob:
install_enabled: true
deploy_enabled: false
uninstall_enabled: false
ai_agents_deploy_enabled_names: >-
{{ ai_agents | dict2items | selectattr('value.deploy_enabled') | map(attribute='key') | list }}
ai_agents_dotfiles:
- name: AI common config
src: .config/ai/
dest: .config/ai/
mode: preserve
agents:
- opencode
- codex
- gemini_cli
- name: Gemini CLI config
src: .gemini/
dest: .gemini/
mode: preserve
agents:
- gemini_cli
- name: OpenCode config
src: .config/opencode/
dest: .config/opencode/
mode: preserve
agents:
- opencode
- name: Claude Code memory
src: .claude/
dest: .claude/
mode: preserve
agents:
- claude_code
ai_agents_templates:
- name: Codex config
src: .codex/config.toml.j2
dest: .codex/config.toml
mode: "0644"
agents:
- codex

View File

@@ -31,8 +31,7 @@ desktop_binary_tools: []
desktop_npm_packages: >-
{{
ai_agents_npm_packages
+ [
[
{'name': '@mermaid-js/mermaid-cli', 'state': 'latest'},
{'name': 'vscode-langservers-extracted', 'state': 'latest'}
]
@@ -225,8 +224,8 @@ desktop_flatpak_packages:
- com.github.tchx84.Flatseal
- com.spotify.Client
- io.github.flattool.Warehouse
- org.onlyoffice.desktopeditors
- org.telegram.desktop
- org.mozilla.Thunderbird
desktop_flatpak_extensions:
- org.gtk.Gtk3theme.Yaru-Blue-dark//stable

View File

@@ -11,6 +11,7 @@ fedora_desktop_packages:
# Fedora equivalents of the development/tooling packages previously pulled by
# the Void desktop profile.
- 7zip
- arm-image-installer
- nodejs-bash-language-server
- bluez
- bridge-utils
@@ -21,6 +22,7 @@ fedora_desktop_packages:
- croc
- delve
- dnsmasq
- evolution
- fastfetch
- poppler-glib
- poppler-utils
@@ -30,9 +32,9 @@ fedora_desktop_packages:
- golang
- golangci-lint
- gopls
- gnome-shell-extension-gsconnect
- hunspell
- libvirt
- libreoffice
- liberation-fonts
- libasan
- libubsan
@@ -50,10 +52,12 @@ fedora_desktop_packages:
- qemu-tools
- rclone
- remmina
- rpi-imager
- ruff
- rustup
- rsync
- shfmt
- seahorse
- syncthing
- tealdeer
- terminus-fonts
@@ -80,7 +84,5 @@ fedora_flatpak_packages:
- be.alexandervanhee.gradia
- ch.protonmail.protonmail-bridge
- com.spotify.Client
- org.mozilla.thunderbird
- org.onlyoffice.desktopeditors
- org.telegram.desktop
fedora_npm_packages: "{{ ai_agents_npm_packages }}"

View File

@@ -9,15 +9,10 @@ fedora_packages_base:
- jq
- make
- nodejs
- openssl
- ripgrep
fedora_manage_docker_repo: true
fedora_manage_google_chrome_repo: true
fedora_manage_intellij_repo: true
fedora_manage_vscode_repo: true
fedora_intellij_copr_owner: coder966
fedora_intellij_copr_project: intellij-idea-ultimate
fedora_intellij_package_name: intellij-idea-ultimate
fedora_emacs_packages:
- emacs-pgtk
@@ -53,7 +48,7 @@ fedora_dotfiles:
workstation_firewall_backend: firewalld
fedora_workstation_dev_packages:
fedora_dev_packages:
- croc
- distrobox
- gnupg
@@ -62,35 +57,15 @@ fedora_workstation_dev_packages:
- openssh-clients
- shellcheck
workstation_host_linux_packages_fedora:
- code
- croc
- firewall-config
- gnome-extensions-app
- gnome-shell-extension-appindicator
- gnome-shell-extension-just-perfection
- gnome-shell-extension-no-overview
- gnome-tweaks
- libreoffice
- meld
- NetworkManager-openconnect-gnome
- pcsc-lite
- pinentry-gnome3
- podman
- podman-compose
- rclone
- yubikey-manager
fedora_flatpak_remote_name: flathub
fedora_flatpak_remote_url: https://dl.flathub.org/repo/flathub.flatpakrepo
workstation_flatpak_remote_name: "{{ fedora_flatpak_remote_name }}"
workstation_flatpak_remote_url: "{{ fedora_flatpak_remote_url }}"
workstation_flatpak_packages:
- com.getpostman.Postman
- com.spotify.Client
- com.github.jeromerobert.pdfarranger
- com.github.xournalpp.xournalpp
- io.dbeaver.DBeaverCommunity
- org.mozilla.Thunderbird
- org.telegram.desktop
chatgpt_desktop_enabled: false
chatgpt_desktop_supported_fedora_versions:
- "43"
- "44"
chatgpt_desktop_supported_architectures:
- x86_64
- aarch64
chatgpt_desktop_rpm_gpg_key: RPM-GPG-KEY-chatgpt-3BFA0E4AE8B8CC16A2D9BA684A3B4A566C4660E4.asc
chatgpt_desktop_rpm_repository: https://persistent.oaistatic.com/codex-app-prod/linux/rpm/$basearch

View File

@@ -0,0 +1,2 @@
---
# Fedora IoT is immutable: do not attach the mutable Fedora package roles.

View File

@@ -1,55 +0,0 @@
---
platform_profile: freebsd
platform_family: freebsd
platform_package_manager: pkg
platform_service_manager: rc
ansible_python_interpreter: /usr/local/bin/python3
vim_plugin_distro_packages:
- fzf
vim_plugin_source_plugins:
- name: fzf
repo: https://github.com/junegunn/fzf.git
version: v0.72.0
- name: fzf.vim
repo: https://github.com/junegunn/fzf.vim.git
version: 34a564c81f36047f50e593c1656f4580ff75ccca
- name: vim-fugitive
repo: https://github.com/tpope/vim-fugitive.git
version: 3b753cf8c6a4dcde6edee8827d464ba9b8c4a6f0
effective_user_group: "{{ user_group }}"
effective_user_home: "/home/{{ effective_username }}"
user_home: "/home/{{ username }}"
freebsd_packages_base:
- bash
- bash-completion
- bat
- curl
- fzf
- git
- jq
- python3
- ripgrep
- rsync
- tmux
- tree
- unzip
- vim
- wget
- zip
- zoxide
freebsd_development_packages:
- clang
- cmake
- gdb
- go
- gmake
- node
- npm
- pkgconf
- py311-pip
- rust
- shellcheck

View File

@@ -0,0 +1,55 @@
---
platform_profile: rocky
platform_family: redhat
platform_package_manager: dnf
platform_service_manager: systemd
rocky_openzfs_release_rpm: https://zfsonlinux.org/epel/zfs-release-3-0.el9.noarch.rpm
rocky_openzfs_gpg_key_url: >-
https://raw.githubusercontent.com/zfsonlinux/zfsonlinux.github.com/master/zfs-release/RPM-GPG-KEY-openzfs-key2
rocky_openzfs_gpg_key_fingerprint: 7DC7 299D CF7C 7FD9 CD87 701B A599 FD5E 9DB8 4141
rocky_syncthing_version: 2.1.3
rocky_syncthing_archive_checksum: sha256:f929eb8e5b72a85543eeeefb2c38f34a68e0c530e70758a2905b78840c76602c
rocky_syncthing_archive_url: >-
{{
'https://github.com/syncthing/syncthing/releases/download/v'
~ rocky_syncthing_version
~ '/syncthing-linux-amd64-v'
~ rocky_syncthing_version
~ '.tar.gz'
}}
rocky_manage_openzfs_repo: false
rocky_manage_syncthing_binary: false
rocky_manage_podman: false
rocky_manage_github_cli_repo: false
rocky_github_cli_repo_url: https://cli.github.com/packages/rpm/gh-cli.repo
rocky_github_cli_repo_file: /etc/yum.repos.d/gh-cli.repo
enabled_services:
- firewalld
rocky_common_packages:
- bash-completion
- bat
- fzf
- git
- jq
- tree
- unzip
- vim-enhanced
- wget
- zip
- zoxide
rocky_packages_base:
- dnf-plugins-core
- firewalld
- openssh-server
- rsync
- selinux-policy-targeted
- sudo
rocky_profile_packages: []
rocky_podman_packages: []

View File

@@ -0,0 +1,42 @@
---
rocky_manage_podman: true
rocky_manage_github_cli_repo: true
rocky_profile_packages:
- avahi
- curl
- dmidecode
- dosfstools
- gcc
- gcc-c++
- git-delta
- gh
- gnupg2
- htop
- make
- nmap-ncat
- nodejs
- parted
- pciutils
- pinentry
- ranger
- ripgrep
rocky_podman_packages:
- podman
- podman-compose
enabled_services:
- firewalld
- podman.socket
server_firewall_backend: firewalld
server_firewalld_zone: public
server_firewalld_services:
- ssh
- cockpit
- http
- https
server_firewalld_ports: []
server_sshd_service_name: sshd
server_compose_selinux_mount_option: Z

View File

@@ -13,12 +13,4 @@ personal_workstation_directories:
- path: "{{ user_home }}/Remotes"
mode: "0755"
personal_workstation_dotfiles:
- src: .gitignore_global
dest: .gitignore_global
mode: "0644"
- src: .themes.gitignore
dest: .themes.gitignore
mode: "0644"
personal_workstation_flatpak_packages: []

View File

@@ -6,7 +6,12 @@ effective_username: "{{ server_username }}"
effective_user_group: "{{ server_user_group }}"
effective_user_home: "{{ server_user_home }}"
server_container_stack_dir: /opt/docker/server
ai_agents_enabled: false
server_npm_quadlet_stage: false
server_npm_quadlet_cutover: false
server_legacy_stack_retired: false
server_legacy_cleanup: false
server_duckdns_enabled: true
ai_agents: {}
vim_plugins_enabled: false
profile_packages:
@@ -25,6 +30,9 @@ profile_packages:
- rsync
server_dotfiles:
- src: .bashrc.d/20-editor-server.sh
dest: .bashrc.d/20-editor-server.sh
mode: "0644"
- src: .gnupg/gpg-agent.conf
dest: .gnupg/gpg-agent.conf
mode: "0600"
@@ -34,9 +42,6 @@ server_dotfiles:
- src: .themes.gitignore
dest: .themes.gitignore
mode: "0644"
- src: duckdns/
dest: duckdns/
mode: preserve
server_templates:
- src: server/.gitconfig.j2
@@ -50,6 +55,10 @@ server_templates:
no_log: true
server_directories:
- path: "{{ server_user_home }}/.bashrc.d"
owner: "{{ server_username }}"
group: "{{ server_user_group }}"
mode: "0755"
- path: "{{ server_user_home }}/.gnupg"
owner: "{{ server_username }}"
group: "{{ server_user_group }}"
@@ -58,14 +67,6 @@ server_directories:
owner: root
group: root
mode: "0755"
- path: /opt/navidrome/data
owner: "{{ server_username }}"
group: "{{ server_user_group }}"
mode: "0755"
- path: /opt/music
owner: "{{ server_username }}"
group: "{{ server_user_group }}"
mode: "0755"
- path: /opt/npm/data
owner: root
group: root
@@ -74,53 +75,47 @@ server_directories:
owner: root
group: root
mode: "0755"
- path: /opt/postgres/data
owner: root
group: root
mode: "0755"
- path: /opt/gitea/data
owner: root
group: root
mode: "0755"
- path: /opt/syncthing/config
owner: root
group: root
mode: "0755"
- path: /srv/syncthing
owner: root
group: root
mode: "0755"
- path: /srv/syncthing/data
owner: "1000"
group: "1000"
mode: "0755"
- path: /srv/nextcloud
owner: root
group: root
mode: "0755"
- path: /srv/nextcloud/data
owner: root
group: root
mode: "0755"
server_ufw_rules:
- rule: allow
name: OpenSSH
- rule: allow
port: "22000"
proto: tcp
comment: Syncthing sync traffic
- rule: allow
port: "22000"
proto: udp
comment: Syncthing QUIC sync traffic
- rule: allow
port: "21027"
proto: udp
comment: Syncthing local discovery
server_sshd_settings:
PermitRootLogin: "no"
server_sshd_allow_users:
- "{{ server_username }}"
server_backup_export_enabled: false
server_backup_username: prometheus-backup
server_backup_public_key_name: atlas-pull
server_backup_export_root: /var/lib/prometheus-backup-export
server_backup_rrsync_path: /usr/share/doc/rsync/support/rrsync
server_backup_export_calendar: "*-*-* 02:00:00 Europe/Rome"
server_backup_export_start_timer: false
# Explicit Gitea cutover helper: installed separately from any outage action.
server_gitea_cutover_tools_enabled: false
server_gitea_final_export: false
server_gitea_on_atlas: false
server_gitea_atlas_address: "{{ hostvars['atlas'].ansible_host }}"
server_gitea_npm_domains: []
server_gitea_ssh_public_port: 2222
server_gitea_ssh_target_port: 2222
server_backup_export_source_keep: 3
server_backup_export_paths: >-
{{ ['opt/npm/data', 'opt/npm/letsencrypt']
+ ([] if server_gitea_on_atlas | bool else ['opt/gitea/data', 'home/git/.ssh'])
+ ([] if server_legacy_stack_retired | bool else
['opt/docker/server/docker-compose.yml',
'etc/systemd/system/podman-compose-server.service'])
+ (['etc/containers/systemd/prometheus-npm.container',
'etc/containers/systemd/server-web.network']
if server_npm_quadlet_stage | bool else [])
+ ['etc/ssh/sshd_config', 'etc/ssh/sshd_config.d',
'etc/firewalld', 'etc/wireguard/wg0.conf'] }}
server_backup_export_excludes: >-
{{ ['opt/npm/data/logs']
+ ([] if server_gitea_on_atlas | bool else
['opt/gitea/data/gitea/log', 'opt/gitea/data/gitea/tmp',
'opt/gitea/data/gitea/sessions', 'opt/gitea/data/gitea/indexers']) }}
server_ssh_authorized_keys: []
server_ssh_authorized_key_directory: "{{ server_user_home }}/.ssh/authorized_keys.d"

View File

@@ -1,37 +0,0 @@
---
ubuntu_packages_base:
- curl
- ufw
- htop
- build-essential
- nodejs
- git-delta
- jq
- ripgrep
ubuntu_manage_docker_repo: true
ubuntu_emacs_packages:
- emacs
- pandoc
- latexmk
- texlive-luatex
- texlive-latex-base
- texlive-latex-recommended
- texlive-fonts-recommended
ubuntu_docker_packages:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-buildx-plugin
- docker-compose-plugin
enabled_services:
- ufw
- docker
ubuntu_dotfiles:
- src: .bashrc.d/
dest: .bashrc.d/
mode: preserve

View File

@@ -183,6 +183,7 @@ profile_packages:
- deluge-gtk
- delve
- dnsmasq
- evolution
- poppler-glib
- poppler-utils
- fontconfig-devel
@@ -193,7 +194,6 @@ profile_packages:
- hunspell
- libvirt
- libspa-bluetooth
- libreoffice
- liberation-fonts-ttf
- libX11-devel
- libXft-devel

View File

@@ -1,5 +1,3 @@
---
workstation_manage_opencode: true
workstation_manage_ibm_bob: true
workstation_ibm_bob_install_url: "https://bob.ibm.com/download/bobshell.sh"
workstation_npm_packages: "{{ ai_agents_npm_packages + [] }}"

View File

@@ -1,14 +1,6 @@
---
emacs_enabled: true
workstation_dev_packages:
- distrobox
- gnupg
- gpg-agent
- libfido2-1
- openssh-client
- shellcheck
workstation_user_directories:
- path: "{{ user_home }}/.config"
mode: "0755"

View File

@@ -3,27 +3,30 @@ enabled_services:
- docker
workstation_dev_wsl_packages:
- lazygit
- buildah
- helm
# Fedora ships the kubectl client binary in the kubernetes-client package.
- kubernetes-client
- mise
- pinentry-curses
- python3-pip
- tmux
workstation_dev_wsl_excluded_packages:
- alacritty
- pcscd
- pinentry-gnome3
- podman
- podman-compose
- ufw
- yubikey-manager
# Java 11, Java 25 and Maven are managed by Mise on this Fedora WSL profile.
# Keep their versions pinned; update them deliberately.
workstation_mise_java_version: temurin-11.0.31+11
workstation_mise_java_25_version: 25.0.2
workstation_mise_maven_version: 3.9.16
workstation_is_wsl: true
workstation_manage_google_chrome: false
workstation_manage_croc: true
workstation_croc_version: v11.0.3
workstation_removed_snap_packages: []
workstation_snap_packages: []
workstation_dev_wsl_python_packages: []
workstation_wsl_systemd_enabled: true
workstation_dev_wsl_dotfiles:
- src: .bashrc.d/30-mise.sh
dest: .bashrc.d/30-mise.sh
mode: "0644"
- src: .config/mise/
dest: .config/mise/
mode: preserve
- src: .gnupg/gpg-agent.conf
dest: .gnupg/gpg-agent.conf
mode: "0600"

View File

@@ -1,47 +0,0 @@
---
enabled_services:
- docker
workstation_dev_wsl_packages:
- lazygit
- pinentry-curses
- python3-pip
workstation_dev_wsl_excluded_packages:
- alacritty
- pcscd
- pinentry-gnome3
- podman
- podman-compose
- ufw
- yubikey-manager
workstation_is_wsl: true
workstation_manage_google_chrome: false
<<<<<<< HEAD
workstation_removed_snap_packages: []
workstation_snap_packages:
- name: glab
classic: true
workstation_dev_wsl_python_packages: []
||||||| parent of cb83ed1 (Update gitconfig on WSL)
workstation_removed_snap_packages: []
workstation_snap_packages:
- name: glab
classic: true
workstation_dev_wsl_python_packages:
- pypsrp
- pyspnego
=======
workstation_manage_glab: true
workstation_glab_version: "1.103.0"
workstation_removed_snap_packages:
- glab
workstation_snap_packages: []
workstation_dev_wsl_python_packages:
- pypsrp
- pyspnego
>>>>>>> cb83ed1 (Update gitconfig on WSL)
workstation_wsl_systemd_enabled: true
workstation_dev_wsl_dotfiles:
- src: .gnupg/gpg-agent.conf
dest: .gnupg/gpg-agent.conf
mode: "0600"

View File

@@ -1,75 +0,0 @@
---
emacs_enabled: true
workstation_host_linux_packages:
- croc
- gnome-shell-extension-manager
- gnome-shell-extensions
- gnome-tweaks
- gufw
- libreoffice
- meld
- network-manager-openconnect-gnome
- pcscd
- pinentry-gnome3
- podman
- podman-compose
- yubikey-manager
workstation_host_linux_dotfiles:
- src: .gnupg/gpg-agent.conf
dest: .gnupg/gpg-agent.conf
mode: "0600"
workstation_firewall_backend: ufw
workstation_manage_google_chrome: true
workstation_removed_snap_packages:
- firefox
workstation_snap_packages:
- name: intellij-idea-ultimate
classic: true
channel: latest/stable
- name: postman
classic: false
channel: latest/stable
- name: thunderbird
classic: false
channel: latest/stable
- name: code
classic: true
channel: latest/stable
- name: xournalpp
classic: false
channel: latest/stable
- name: pdfarranger
classic: false
channel: latest/stable
- name: spotify
classic: false
channel: latest/stable
- name: telegram-desktop
classic: false
channel: latest/stable
- name: dbeaver-ce
classic: true
channel: latest/stable
workstation_gnome_extensions:
- id: 9308
uuid: bluetooth-battery-monitor@v8v88v8v88.com
version_tag: 68559
enabled: true
- id: 1401
uuid: bluetooth-quick-connect@bjarosze.gmail.com
version_tag: 65323
enabled: true
- id: 6099
uuid: paperwm@paperwm.github.com
version_tag: 68525
enabled: true
workstation_disabled_gnome_extensions:
- tiling-assistant@ubuntu.com
- ubuntu-dock@ubuntu.com

View File

@@ -0,0 +1,44 @@
---
ansible_host: aegis
ansible_connection: ssh
ansible_user: pi
ansible_become: true
ansible_python_interpreter: /usr/bin/python3
# Avoid PTY framing around module JSON on this remote Fedora IoT host.
ansible_ssh_use_tty: false
aegis_lan_subnet: 192.168.178.0/24
aegis_adguard_web_port: 80
aegis_network_connection_name: FRITZ!Box 7530 NR
aegis_network_connection_uuid: a52fda3d-3eb6-351f-bf04-753edcb76944
aegis_host_dns_servers:
- 192.168.178.1
aegis_host_dns_search_domains:
- fritz.box
# Aegis is the internal WireGuard endpoint and NAT gateway for the LAN.
wireguard_overlay_enabled: true
wireguard_address: 10.0.0.2/24
wireguard_enable_ipv4_forwarding: true
wireguard_reload_rootful_podman_networks: true
wireguard_forwarding_policies:
- name: aegis-wireguard-to-lan
ingress_zone: wireguard
egress_zone: public
source: 10.0.0.0/24
destination: "{{ aegis_lan_subnet }}"
masquerade: true
wireguard_peers:
- name: prometheus
host: prometheus
endpoint: "{{ hostvars['prometheus']['ansible_host'] }}:{{ hostvars['prometheus']['wireguard_listen_port'] }}"
allowed_ips:
- 10.0.0.0/24
persistent_keepalive: 25
aegis_ssh_authorized_keys:
- name: ikaros
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINrIxXjA3ffPwziKGR5gzc4gAoBehQPlnEMcXF4Wl0ZS ikaros"
- name: nymph
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEH/7GJfGt0ZVmKeEzceoFkFkeCXFryKK9vAbaip+HCx nymph"
- name: siren
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA95wYlzpfN3rjUhpMeP4KHn8I6ZrjQXoDTgwgRIa++b siren"

View File

@@ -0,0 +1,214 @@
---
# Atlas declares its intended steady state; set a feature flag false only for a deliberate suspension.
hostname: atlas
ansible_host: 192.168.178.55
ansible_user: "{{ atlas_connection_username }}"
ansible_port: 22
ansible_ssh_private_key_file: /home/fscotto/.ssh/id_ed25519
atlas_admin_username: admin
atlas_connection_username: "{{ atlas_admin_username }}"
atlas_admin_group: "{{ atlas_admin_username }}"
atlas_admin_home: "/home/{{ atlas_admin_username }}"
atlas_admin_uid: 1000
atlas_admin_gid: 1000
atlas_admin_ssh_keys:
- name: ikaros
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINrIxXjA3ffPwziKGR5gzc4gAoBehQPlnEMcXF4Wl0ZS ikaros"
- name: nymph
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEH/7GJfGt0ZVmKeEzceoFkFkeCXFryKK9vAbaip+HCx nymph"
- name: siren
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA95wYlzpfN3rjUhpMeP4KHn8I6ZrjQXoDTgwgRIa++b siren"
atlas_admin_password_hash: "{{ vault_atlas_admin_password_hash | default('!') }}"
atlas_ssh_allow_tcp_forwarding: local
atlas_lan_subnet: 192.168.178.0/24
atlas_aegis_ip: 192.168.178.54
atlas_manage_firewall: true
atlas_firewalld_zone: public
atlas_zfs_pool: zpool
# Populate only for the first pool bootstrap with four real persistent disk paths.
# Confirmed empty 4 TB IronWolf data disks; the NVMe system disk is intentionally excluded.
atlas_zpool_disks:
- /dev/disk/by-id/ata-ST4000VN006-3CW104_WW6AC1XM
- /dev/disk/by-id/ata-ST4000VN006-3CW104_WW6A73T2
- /dev/disk/by-id/ata-ST4000VN006-3CW104_WW6A6VJK
- /dev/disk/by-id/ata-ST4000VN006-3CW104_WW6AC1LM
atlas_create_pool: false
atlas_zfs_dataset_archive: archive
atlas_zfs_dataset_services: services
atlas_zfs_dataset_app_data: services/data
atlas_zfs_dataset_navidrome: services/data/navidrome
atlas_zfs_dataset_syncthing: services/data/syncthing
atlas_zfs_dataset_media: media
atlas_zfs_dataset_music: media/music
atlas_zfs_dataset_backup: backup
atlas_zfs_dataset_host_backups: backup/hosts
atlas_zfs_dataset_backup_prometheus: backup/hosts/prometheus
atlas_zfs_backup_reservation: 500G
atlas_zfs_dataset_photobook: media/photobook
atlas_mount_root: /zpool
atlas_manage_storage: true
# Rootless Gitea was restored from the stopped-source export before production activation.
atlas_manage_gitea: true
atlas_gitea_production_enabled: true
atlas_gitea_public_domain: git.fscotto.co
atlas_prometheus_pull_start_timer: true
atlas_manage_zfs_snapshots: true
atlas_zfs_snapshot_prefix: atlas-auto
atlas_zfs_snapshot_policies:
- name: hourly
calendar: "*-*-* *:05:00"
keep: 24
- name: daily
calendar: "*-*-* 00:15:00"
keep: 30
- name: weekly
calendar: "Sun *-*-* 01:00:00"
keep: 8
- name: monthly
calendar: "*-*-01 02:00:00"
keep: 12
atlas_manage_zfs_scrub: true
atlas_zfs_scrub_calendar: "Sun *-*-01..07 03:00:00"
atlas_manage_borg_backup: true
atlas_borg_repository_host: u660064-sub1.your-storagebox.de
atlas_borg_repository_user: u660064-sub1
atlas_borg_repository_port: 23
atlas_borg_repository_path: ./borg-data
atlas_borg_remote_path: borg-1.4
# Verified against Hetzner's published ED25519 fingerprint on 2026-09-17:
# SHA256:XqONwb1S0zuj5A1CDxpOSuD2hnAArV1A3wKY7Z3sdgM
atlas_borg_host_key: >-
[u660064-sub1.your-storagebox.de]:23 ssh-ed25519
AAAAC3NzaC1lZDI1NTE5AAAAIICf9svRenC/PLKIL9nk6K/pxQgoiFC41wTNvoIncOxs
atlas_borg_backup_calendar: "*-*-* 04:30:00"
atlas_borg_check_calendar: "*-*-15 06:00:00"
atlas_borg_randomized_delay: 30m
atlas_borg_keep_daily: 30
atlas_borg_keep_weekly: 8
atlas_borg_keep_monthly: 12
atlas_manage_usb_backup: true
# Read-only lsblk verification on Atlas, 2026-09-23. Never store the LUKS password here.
atlas_usb_backup_luks_uuid: 577b3c43-ea37-4611-81a9-39d555cdfbd4
atlas_usb_backup_fs_uuid: 758e2d2e-a427-4797-aad9-39c3a9f17c7e
atlas_usb_backup_mapper_name: zpool-backup
atlas_manage_usb_reminder: true
atlas_usb_reminder_calendar: "Sat *-*-01..07 10:00:00 Europe/Rome"
atlas_manage_monitoring: true
atlas_manage_prometheus_backup_pull: true
# Prometheus ED25519 host key read through the controller's strict SSH trust on 2026-09-30.
# Fingerprint: SHA256:rfedk7DHI9mLB3UHk/4F3HHlSIiswtCAFsAXvfh6iXk
atlas_prometheus_ssh_host_key: >-
179.237.102.172 ssh-ed25519
AAAAC3NzaC1lZDI1NTE5AAAAIC4b+QXlPupoEx71W9NKs9tTeYjBqTkVMqbGB97nMNWv
# Physical pool disks and the system NVMe; the disconnected USB disk is intentionally excluded.
atlas_monitor_smart_devices:
- { name: pool-1, path: "{{ atlas_zpool_disks[0] }}", warning_c: 50, critical_c: 55 }
- { name: pool-2, path: "{{ atlas_zpool_disks[1] }}", warning_c: 50, critical_c: 55 }
- { name: pool-3, path: "{{ atlas_zpool_disks[2] }}", warning_c: 50, critical_c: 55 }
- { name: pool-4, path: "{{ atlas_zpool_disks[3] }}", warning_c: 50, critical_c: 55 }
- name: system-nvme
path: /dev/disk/by-id/nvme-Patriot_M.2_P320_256GB_P320ADB26011606111
warning_c: 70
critical_c: 85
atlas_monitor_timers:
- { name: atlas-zfs-snapshot-hourly.timer, max_age_hours: 3 }
- { name: atlas-zfs-snapshot-daily.timer, max_age_hours: 36 }
- { name: atlas-zfs-snapshot-weekly.timer, max_age_hours: 216 }
- { name: atlas-zfs-snapshot-monthly.timer, max_age_hours: 960 }
- { name: zfs-scrub-monthly@zpool.timer, max_age_hours: 960 }
- { name: atlas-borg-backup.timer, max_age_hours: 48 }
- { name: atlas-borg-check.timer, max_age_hours: 960 }
# The first manual USB reminder is not due until October; activation is checked, not age.
- { name: atlas-usb-reminder.timer, max_age_hours: 0 }
atlas_monitor_failure_units:
- atlas-zfs-snapshot@.service
- zfs-scrub@zpool.service
- atlas-borg-backup.service
- atlas-borg-check.service
- atlas-usb-backup.service
- atlas-usb-reminder.service
- atlas-health-monitor.service
atlas_monitor_remote_capacity:
user: "{{ atlas_borg_repository_user }}"
host: "{{ atlas_borg_repository_host }}"
run_as: "{{ atlas_borg_username }}"
ssh_wrapper: "{{ atlas_borg_ssh_wrapper_path }}"
warning_percent: 80
critical_percent: 90
growth_warning_gib_day: 500
atlas_manage_sharing: true
atlas_manage_media_stack: false
# Planned after data-protection validation: move iCloudPD photo ingestion from
# Aegis to Atlas, with photos under /zpool/archive/Pictures/iCloudPD and
# application/MFA state in a separate dataset. Do not deploy or cut over yet.
# WireGuard is retired on Atlas. These rootless services are a temporary home
# until Uranus replaces them.
backend_phase1_enabled: true
backend_phase1_start_services: true
backend_phase1_bind_address: "{{ ansible_host }}"
backend_phase1_firewalld_zone: "{{ atlas_firewalld_zone }}"
backend_phase1_npm_source_ip: "{{ atlas_aegis_ip }}"
backend_phase1_syncthing_native_subnet: "{{ atlas_lan_subnet }}"
backend_phase1_music_sync_enabled: true
rocky_manage_openzfs_repo: true
rocky_manage_syncthing_binary: false
rocky_manage_podman: true
rocky_podman_packages:
- podman
host_packages:
- cockpit
- cockpit-podman
- cockpit-storaged
- realmd
- pcp
- python3-pcp
- cryptsetup
- nfs-utils
- policycoreutils
- policycoreutils-python-utils
- python3-libselinux
- setroubleshoot-server
- samba
- samba-client
- samba-common-tools
- borgbackup
- zfs
atlas_nfs_exports:
- path: "{{ atlas_photobook_mountpoint }}"
client: "{{ atlas_aegis_ip }}"
options:
- rw
- sync
- no_subtree_check
- all_squash
- "anonuid={{ atlas_immich_uid }}"
- "anongid={{ atlas_immich_gid }}"
atlas_samba_share_name: Archive
atlas_samba_workgroup: WORKGROUP
atlas_samba_encryption: required
# Append any pre-existing laptop Unix account here and keep its Samba password in Vault.
atlas_samba_accounts:
- username: "{{ atlas_admin_username }}"
password: "{{ vault_atlas_samba_password | default('') }}"
atlas_samba_valid_users: "{{ atlas_samba_accounts | map(attribute='username') | list }}"
atlas_immich_db_password: "{{ vault_atlas_immich_db_password | default('') }}"
atlas_firewalld_rich_rules:
- 'rule family="ipv4" source address="{{ atlas_lan_subnet }}" port port="22" protocol="tcp" accept'
- 'rule family="ipv4" source address="{{ atlas_lan_subnet }}" port port="9090" protocol="tcp" accept'
- 'rule family="ipv4" source address="{{ atlas_aegis_ip }}" port port="2049" protocol="tcp" accept'
- 'rule family="ipv4" source address="{{ atlas_lan_subnet }}" port port="445" protocol="tcp" accept'
host_enabled_services:
- sshd
- cockpit.socket
- pmlogger.service
- zfs.target

View File

@@ -1,6 +0,0 @@
---
hostname: deadalus-wsl
workstation_wsl_windows_user_home: /mnt/c/Users/FABIOSCOTTODISANTOLO
host_packages: []
host_enabled_services: []

View File

@@ -0,0 +1,13 @@
---
hostname: deadalus
workstation_wsl_windows_user_home: /mnt/c/Users/FABIOSCOTTODISANTOLO
host_packages: []
host_enabled_services: []
# Fedora WSL must not receive alternative application runtimes.
fedora_excluded_packages:
- flatpak
fedora_removed_packages:
- flatpak
- snapd

View File

@@ -1,6 +1,9 @@
---
hostname: ikaros
codex_relay_install_enabled: true
chatgpt_desktop_enabled: true
host_packages:
- openssh-clients
- openssh-server
@@ -229,7 +232,7 @@ gnome_gsettings_settings:
- schema: org.gnome.shell
key: app-picker-layout
value: >-
[{'org.fedoraproject.MediaWriter.desktop': <{'position': <0>}>, 'org.gnome.Contacts.desktop': <{'position': <1>}>, 'org.gnome.Weather.desktop': <{'position': <2>}>, 'org.gnome.clocks.desktop': <{'position': <3>}>, 'org.gnome.Maps.desktop': <{'position': <4>}>, 'org.gnome.SimpleScan.desktop': <{'position': <5>}>, 'org.gnome.Settings.desktop': <{'position': <6>}>, 'org.gnome.Boxes.desktop': <{'position': <7>}>, 'org.gnome.Showtime.desktop': <{'position': <8>}>, 'org.gnome.Snapshot.desktop': <{'position': <9>}>, 'org.gnome.Characters.desktop': <{'position': <10>}>, 'Utilities': <{'position': <11>}>, 'System': <{'position': <12>}>, 'org.gnome.Tour.desktop': <{'position': <13>}>, 'org.gnome.Yelp.desktop': <{'position': <14>}>, 'htop.desktop': <{'position': <15>}>, 'libreoffice-writer.desktop': <{'position': <16>}>, 'libreoffice-calc.desktop': <{'position': <17>}>, 'libreoffice-impress.desktop': <{'position': <18>}>, 'libreoffice-draw.desktop': <{'position': <19>}>, 'libreoffice-math.desktop': <{'position': <20>}>, 'libreoffice-base.desktop': <{'position': <21>}>, 'org.gnome.Meld.desktop': <{'position': <22>}>, 'mpv.desktop': <{'position': <23>}>}, {'com.github.jeromerobert.pdfarranger.desktop': <{'position': <0>}>, 'org.remmina.Remmina.desktop': <{'position': <1>}>, 'ch.protonmail.protonmail-bridge.desktop': <{'position': <2>}>, 'org.gnome.TextEditor.desktop': <{'position': <3>}>, 'org.gnome.Software.desktop': <{'position': <4>}>, 'org.gnome.Calculator.desktop': <{'position': <5>}>, 'org.gnome.Calendar.desktop': <{'position': <6>}>, 'com.mattjakeman.ExtensionManager.desktop': <{'position': <7>}>, 'be.alexandervanhee.gradia.desktop': <{'position': <8>}>, 'syncthing-start.desktop': <{'position': <9>}>, 'syncthing-ui.desktop': <{'position': <10>}>}]
[{'System': <{'position': <0>}>, 'Utilities': <{'position': <1>}>, 'org.fedoraproject.MediaWriter.desktop': <{'position': <2>}>, 'org.gnome.Contacts.desktop': <{'position': <3>}>, 'org.gnome.Calendar.desktop': <{'position': <4>}>, 'org.gnome.Calculator.desktop': <{'position': <5>}>, 'org.gnome.Weather.desktop': <{'position': <6>}>, 'org.gnome.clocks.desktop': <{'position': <7>}>, 'org.gnome.Maps.desktop': <{'position': <8>}>, 'org.gnome.SimpleScan.desktop': <{'position': <9>}>, 'org.gnome.TextEditor.desktop': <{'position': <10>}>, 'org.gnome.Settings.desktop': <{'position': <11>}>, 'org.gnome.Boxes.desktop': <{'position': <12>}>, 'org.gnome.Showtime.desktop': <{'position': <13>}>, 'org.gnome.Snapshot.desktop': <{'position': <14>}>, 'org.gnome.Characters.desktop': <{'position': <15>}>, 'org.gnome.Tour.desktop': <{'position': <16>}>, 'org.gnome.Yelp.desktop': <{'position': <17>}>, 'org.gnome.Software.desktop': <{'position': <18>}>, 'be.alexandervanhee.gradia.desktop': <{'position': <19>}>, 'com.github.jeromerobert.pdfarranger.desktop': <{'position': <20>}>, 'org.onlyoffice.desktopeditors.desktop': <{'position': <21>}>, 'org.gnome.Meld.desktop': <{'position': <22>}>, 'com.mattjakeman.ExtensionManager.desktop': <{'position': <23>}>}, {'org.remmina.Remmina.desktop': <{'position': <0>}>, 'ch.protonmail.protonmail-bridge.desktop': <{'position': <1>}>, 'syncthing-start.desktop': <{'position': <2>}>, 'syncthing-ui.desktop': <{'position': <3>}>, 'emacs.desktop': <{'position': <4>}>, 'emacs-client.desktop': <{'position': <5>}>, 'org.gnome.gitlab.somas.Apostrophe.desktop': <{'position': <6>}>, 'mpv.desktop': <{'position': <7>}>, 'htop.desktop': <{'position': <8>}>, 'com.raspberrypi.rpi-imager.desktop': <{'position': <9>}>}]
- schema: org.gnome.shell
key: disabled-extensions
value: >-
@@ -241,7 +244,7 @@ gnome_gsettings_settings:
- schema: org.gnome.shell
key: favorite-apps
value: >-
['org.gnome.Nautilus.desktop', 'org.mozilla.firefox.desktop', 'com.spotify.Client.desktop', 'org.mozilla.thunderbird.desktop', 'org.telegram.desktop.desktop', 'org.gnome.Ptyxis.desktop', 'virt-manager.desktop']
['org.gnome.Nautilus.desktop', 'org.mozilla.firefox.desktop', 'com.spotify.Client.desktop', 'org.gnome.Evolution.desktop', 'org.telegram.desktop.desktop', 'chatgpt.desktop', 'org.gnome.Ptyxis.desktop', 'virt-manager.desktop']
- schema: org.gnome.shell
key: remember-mount-password
value: "true"

View File

@@ -1,6 +1,8 @@
---
hostname: nymph
chatgpt_desktop_enabled: true
# Fedora Workstation/GNOME laptop target. GNOME is provided by the base Fedora
# Workstation install; Ansible deploys shared desktop dotfiles but leaves GNOME
# settings unmanaged for now.

View File

@@ -1,5 +1,64 @@
---
hostname: prometheus
ansible_host: 179.237.102.172
ansible_user: rocky
ansible_port: 22
ansible_ssh_private_key_file: /home/fscotto/.ssh/id_ed25519
host_packages: []
host_enabled_services: []
server_username: rocky
server_legacy_stack_retired: true
# Destructive deletion runs only with an explicit extra-var and cleanup tag.
server_legacy_cleanup: false
# Explicit opt-in cleanup; no data, volumes, networks or NPM images are removed.
server_legacy_image_cleanup: false
server_legacy_images:
- docker.gitea.com/gitea:1.25.2
- docker.io/deluan/navidrome:latest
- docker.io/library/postgres:13
server_npm_quadlet_stage: true
server_npm_quadlet_image: docker.io/jc21/nginx-proxy-manager@sha256:52b2c59994f3d36acfcf70a1626f29734df0ed8c71bacc0269f78b6f939858bb
# The stopped-source export and live Quadlet cutover passed on 2026-10-03.
server_npm_quadlet_cutover: true
server_backup_export_enabled: true
server_backup_export_start_timer: true
# Install the final-copy helper only; it is never run by a normal playbook invocation.
server_gitea_cutover_tools_enabled: true
server_gitea_on_atlas: true
server_gitea_npm_domains:
- git.fscotto.duckdns.org
server_duckdns_domain: fscotto
server_duckdns_enabled: false
server_ssh_authorized_keys:
- name: ikaros
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINrIxXjA3ffPwziKGR5gzc4gAoBehQPlnEMcXF4Wl0ZS ikaros"
- name: nymph
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEH/7GJfGt0ZVmKeEzceoFkFkeCXFryKK9vAbaip+HCx nymph"
- name: siren
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA95wYlzpfN3rjUhpMeP4KHn8I6ZrjQXoDTgwgRIa++b siren"
wireguard_overlay_enabled: true
wireguard_address: 10.0.0.1/24
wireguard_listen_port: 51820
wireguard_enable_ipv4_forwarding: true
wireguard_reload_rootful_podman_networks: true
wireguard_peers:
- name: aegis
host: aegis
# TODO: Aggiungere VIP di Uranus in futuro
allowed_ips:
- 10.0.0.2/32
- 192.168.178.0/24
host_packages:
- cockpit
- cockpit-navigator
- cockpit-podman
- cockpit-storaged
- realmd
- pcp
- python3-pcp
- setroubleshoot-server
host_enabled_services:
- cockpit.socket
- pmlogger.service

View File

@@ -10,9 +10,18 @@ all:
ansible_connection: local
nymph:
ansible_connection: local
deadalus:
ansible_connection: local
platform_freebsd:
hosts: {}
platform_fedora_iot:
hosts:
aegis:
platform_rocky:
hosts:
atlas:
children:
rocky_server:
void:
children:
@@ -21,6 +30,11 @@ all:
fedora:
children:
platform_fedora:
platform_fedora_iot:
rocky:
children:
platform_rocky:
graphical_desktop:
hosts:
@@ -38,6 +52,10 @@ all:
ikaros:
ansible_connection: local
role_aegis:
hosts:
aegis:
desktop_sway:
hosts: {}
@@ -51,20 +69,10 @@ all:
nymph:
ansible_connection: local
ubuntu:
children:
workstation_dev_wsl:
ubuntu_server:
workstation:
children:
workstation_host:
workstation_dev:
workstation_host:
children:
workstation_host_linux:
workstation_dev:
children:
workstation_dev_fedora:
@@ -72,23 +80,27 @@ all:
server:
children:
ubuntu_server:
fedora_workstation:
hosts: {}
workstation_host_linux:
hosts: {}
rocky_server:
workstation_dev_fedora:
hosts: {}
hosts:
deadalus:
ansible_connection: local
workstation_dev_wsl:
hosts:
deadalus-wsl:
deadalus:
ansible_connection: local
ubuntu_server:
rocky_server:
hosts:
prometheus:
ansible_connection: local
wireguard_overlay:
hosts:
prometheus:
aegis:
role_backend_phase1:
hosts:
atlas:

View File

@@ -23,6 +23,7 @@
dest: "{{ effective_user_home }}/Templates"
owner: "{{ effective_username }}"
group: "{{ effective_user_group }}"
creates: "{{ effective_user_home }}/Templates/code/main.c"
when: "'desktop' in group_names"
- name: Ensure Emacs authoring directories exist
@@ -86,13 +87,25 @@
group: "{{ effective_user_group }}"
mode: "{{ item.mode }}"
loop: >-
{{
(common_dotfiles | default([]))
+ ((ai_agents_dotfiles | default([])) if (ai_agents_enabled | default(false)) else [])
}}
{{ common_dotfiles | default([]) }}
loop_control:
label: "{{ item.dest }}"
- name: Deploy AI coding-agent dotfiles
tags: [dotfiles, dotfiles:common, ai_agents]
ansible.builtin.copy:
src: "{{ playbook_dir }}/../dotfiles/common/{{ item.src }}"
dest: "{{ effective_user_home }}/{{ item.dest }}"
owner: "{{ effective_username }}"
group: "{{ effective_user_group }}"
mode: "{{ item.mode }}"
loop: "{{ ai_agents_dotfiles | default([]) }}"
loop_control:
label: "{{ item.dest }}"
when:
- (item.agents | intersect(ai_agents_deploy_enabled_names)) | length > 0
- (ai_agents_dotfiles | default([])) | length > 0
- name: Install distro packages for Vim plugins
tags: [packages, vim, fzf]
ansible.builtin.package:
@@ -141,7 +154,7 @@
- (vim_plugin_source_plugins | default([])) | length > 0
- name: Ensure AI config directories exist
tags: [dotfiles, dotfiles:common]
tags: [dotfiles, dotfiles:common, ai_agents]
ansible.builtin.file:
path: "{{ effective_user_home }}/{{ item }}"
state: directory
@@ -151,11 +164,11 @@
loop:
- .codex
when:
- ai_agents_enabled | default(false)
- "'codex' in ai_agents_deploy_enabled_names"
- (ai_agents_templates | default([])) | length > 0
- name: Render AI agent templates
tags: [dotfiles, dotfiles:common]
- name: Render AI coding-agent templates
tags: [dotfiles, dotfiles:common, ai_agents]
ansible.builtin.template:
src: "{{ playbook_dir }}/../dotfiles/common/{{ item.src }}"
dest: "{{ effective_user_home }}/{{ item.dest }}"
@@ -165,7 +178,9 @@
loop: "{{ ai_agents_templates | default([]) }}"
loop_control:
label: "{{ item.dest }}"
when: ai_agents_enabled | default(false)
when:
- (item.agents | intersect(ai_agents_deploy_enabled_names)) | length > 0
- (ai_agents_templates | default([])) | length > 0
- name: Refresh bat cache
tags: [dotfiles, dotfiles:common]

View File

@@ -0,0 +1,28 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mQINBGpypFUBEACi1Vvzq9pIpA6lj7chbqELuxJtVuzUzxrasa6ZU0yF4yhq7jf8
3YkJRHwbezBKeQyzJ5lkX0EhXS8aXxUhMAm3PFpAlwcInfKzmV7atJwvaxIw6Rmd
GYe9fBWKjTN/SmPIjtyxrTznZY97+TfD1AeGZpLaJ8fsnhrC+HkiN2TACiTocgpe
hFiP0OWK7mWZeTWnY2scpIYXP1Ro7nQv4KacmY4JacTQ7m/HM0Qej/3olhuEv2Cw
lMVWw57/oHhmTllfLDQOogFQyIVqaaR98y/Eu6cAabSfcsqAAZ2A8vfHYD27z28J
vLO2PZEJd5ThlnX4Zqv0eIpZdBj//8Sl/MSqTshFZ1NDsRoqwdqw284X5MpnOJ4k
4Sc2Se8tJxt/nCeibH3dJ504Fb1X/mnOqhCAQ6pVJz4RB5HRlFPSkxVPyag1v1m/
7T4vie+OR4eqFQNz6mudrOoMmeVIfyL5fbe4cOr4fk/FyvEE2xMgkFatPqXn7vM9
og+zremPCfwRAFpBPyX74VowFY7llcdaj/w8K5T8PzM14Hb3E4ZKizMluKmTvTq9
WE1/eSQJLLQqXD5VmtmdUaC/VyE/1ZlIxcA1LWqvEQ327UXREvX/nHsrkKrl956W
jzkiHFUTsD1NJ0dMfs+csOt8Furb5jZj+HsMmCm9jLdfz5b/4WKLPbvxIwARAQAB
tBZDb2RleCBMaW51eCBSZXBvc2l0b3J5iQJRBBMBCgA7FiEEO/oOSui4zBai2bpo
SjtKVmxGYOQFAmpypFUCGwMFCwkIBwICIgIGFQoJCAsCBBYCAwECHgcCF4AACgkQ
SjtKVmxGYORlCQ/9FyikZo8HQcJBP9E/oXVPds/fQnIFB2qJR2z3DrfYEonNt/ev
SAySkPPq4/mEOjaI0pFlDDGSaps+FTcJFgoVRTasBIF7JJivvjW9ap8iWEbhhVLe
IrFLbMLpUcTRntUx7R4fVMJ/1/cGn+NWZmNwS9ORorzSyCH0IAgCw1Xc3ZrjuMbF
VjdToMC1TiXXCEmlYpQakmQ3Ay1cH0FHC2BBNn1MNVkJdPhpZIZCdhaMPHfYFpyo
pg8wFvZ5iIcvlbMgyuy8CPJVRWUcYy2dOhEOGnYJnXRPkE3E1hf8YOHNzRlduH89
6lT9qcEK2+fpLfrVGoc4zscLZ+Ey+Ko6iQRdVE1j67+wNR3hX8ukue574v1N/xxu
i575jumSE19lEj1sH4+P4gFHOtTbF0JhKKzLctbga0IAwTPKhnt3qzj1U5Yj/MZS
uEVjrLhdRauOuFBXUclgyVf2w/lE85UUOdlcollsYA6Huq7xDamqf8SslZQGre3E
I+lhpqJR1cOwDMUzzcl40uTyhrxXXd/bk4QSlhZbwHR25Pnt+ZMtWavlQWS0eDEV
8djuXAURCmx5WOqAFB/TJe1mn5EvyWg4VFzrY/NVNOpzgY5+Xp7J28z7f637r712
Eu9j4imVcdPigwS+jf/0f81i2o9b82Y26TN8+EtDLCY841MJ1lrjDrX/dno=
=Y+3h
-----END PGP PUBLIC KEY BLOCK-----

View File

@@ -1,9 +1,4 @@
---
- name: Gather installed package facts
tags: [packages]
ansible.builtin.package_facts:
manager: auto
- name: Copy Fedora dotfiles
tags: [dotfiles, dotfiles:common]
ansible.builtin.copy:
@@ -30,52 +25,55 @@
- "'workstation_dev_fedora' in group_names"
- fedora_manage_docker_repo | default(false)
- name: Configure Google Chrome repository on Fedora
tags: [packages]
ansible.builtin.yum_repository:
name: google-chrome
description: Google Chrome
baseurl: "https://dl.google.com/linux/chrome/rpm/stable/$basearch"
enabled: true
gpgcheck: true
gpgkey: https://dl.google.com/linux/linux_signing_key.pub
- name: Install COPR support on Fedora WSL
tags: [packages, mise]
ansible.builtin.dnf:
name: dnf-plugins-core
state: present
when:
- workstation_manage_google_chrome | default(false)
- fedora_manage_google_chrome_repo | default(false)
when: "'workstation_dev_wsl' in group_names"
- name: Configure Visual Studio Code repository on Fedora
tags: [packages, vscode]
ansible.builtin.yum_repository:
name: vscode
description: Visual Studio Code
baseurl: https://packages.microsoft.com/yumrepos/vscode
enabled: true
gpgcheck: true
gpgkey: https://packages.microsoft.com/keys/microsoft.asc
- name: Configure Mise COPR repository on Fedora WSL
tags: [packages, mise]
community.general.copr:
name: jdxcode/mise
state: enabled
when: "'workstation_dev_wsl' in group_names"
- name: Install distribution signing keys for Fedora desktop codecs
tags: [packages, heic]
ansible.builtin.dnf:
name: distribution-gpg-keys
state: present
when:
- "'workstation_host_linux' in group_names"
- fedora_manage_vscode_repo | default(false)
when: "'graphical_desktop' in group_names"
- name: Configure IntelliJ IDEA COPR repository on Fedora
tags: [packages]
ansible.builtin.get_url:
url: >-
https://copr.fedorainfracloud.org/coprs/{{ fedora_intellij_copr_owner }}/{{ fedora_intellij_copr_project }}/repo/fedora-{{ ansible_facts['distribution_major_version'] }}/{{ fedora_intellij_copr_owner }}-{{ fedora_intellij_copr_project }}-fedora-{{ ansible_facts['distribution_major_version'] }}.repo
dest: /etc/yum.repos.d/{{ fedora_intellij_copr_owner }}-{{ fedora_intellij_copr_project }}-fedora-{{ ansible_facts['distribution_major_version'] }}.repo
owner: root
group: root
mode: "0644"
when:
- "'workstation_host_linux' in group_names"
- fedora_manage_intellij_repo | default(false)
- name: Import RPM Fusion Free signing key for Fedora desktop codecs
tags: [packages, heic]
ansible.builtin.rpm_key:
key: /usr/share/distribution-gpg-keys/rpmfusion/RPM-GPG-KEY-rpmfusion-free-fedora-2020
fingerprint: E9A491A3DE247814E7E067EAE06F8ECDD651FF2E
state: present
when: "'graphical_desktop' in group_names"
- name: Enable RPM Fusion Free for Fedora desktop codecs
tags: [packages, heic]
ansible.builtin.dnf:
name: "https://download1.rpmfusion.org/free/fedora/rpmfusion-free-release-{{ ansible_facts['distribution_major_version'] }}.noarch.rpm"
state: present
when: "'graphical_desktop' in group_names"
- name: Refresh dnf package metadata
tags: [packages]
ansible.builtin.dnf:
update_cache: true
- name: Install HEIC decoder on Fedora desktops
tags: [packages, heic]
ansible.builtin.dnf:
name: libheif-freeworld
state: present
update_cache: true
when: "'graphical_desktop' in group_names"
- name: Install packages on Fedora
tags: [packages]
ansible.builtin.dnf:
@@ -89,11 +87,6 @@
if 'workstation_dev_fedora' in group_names
else []
)
+ (
['google-chrome-stable']
if workstation_manage_google_chrome | default(false)
else []
)
+ (profile_packages | default([]))
+ (
(fedora_desktop_packages | default([]))
@@ -108,29 +101,29 @@
else []
)
+ (
(fedora_workstation_dev_packages | default(workstation_dev_packages | default([])))
(fedora_dev_packages | default([]))
if 'workstation_dev_fedora' in group_names
else []
)
+ (
(workstation_host_linux_packages_fedora | default(workstation_host_linux_packages | default([])))
if 'workstation_host_linux' in group_names
else []
)
+ (
[fedora_intellij_package_name]
if (
'workstation_host_linux' in group_names
and fedora_manage_intellij_repo | default(false)
)
(workstation_dev_wsl_packages | default([]))
if 'workstation_dev_wsl' in group_names
else []
)
+ (host_packages | default([]))
)
| difference(fedora_excluded_packages | default([]))
| unique
}}
state: present
- name: Remove excluded Fedora packages
tags: [packages]
ansible.builtin.dnf:
name: "{{ fedora_removed_packages | default([]) }}"
state: absent
when: (fedora_removed_packages | default([])) | length > 0
- name: Add user to docker group
tags: [packages]
ansible.builtin.user:
@@ -141,16 +134,81 @@
- "'workstation_dev_fedora' in group_names"
- (fedora_docker_packages | default([])) | length > 0
- name: Install Fedora npm packages
- name: Install shared AI coding agents on Fedora
tags: [packages, npm, ai_agents]
community.general.npm:
name: "{{ item.name }}"
name: "{{ item.value.npm_package }}"
global: true
state: "{{ item.state | default('present') }}"
loop: "{{ fedora_npm_packages | default([]) }}"
when: (fedora_npm_packages | default([])) | length > 0
state: latest
loop: "{{ ai_agents | dict2items | selectattr('value.npm_package', 'defined') | list }}"
when:
- item.value.install_enabled | bool
- "'workstation_dev_fedora' not in group_names"
loop_control:
label: "{{ item.name }}"
label: "{{ item.key }}"
- name: Uninstall shared AI coding agents on Fedora
tags: [packages, npm, ai_agents]
community.general.npm:
name: "{{ item.value.npm_package }}"
global: true
state: absent
loop: "{{ ai_agents | dict2items | selectattr('value.npm_package', 'defined') | list }}"
when:
- item.value.uninstall_enabled | bool
- "'workstation_dev_fedora' not in group_names"
loop_control:
label: "{{ item.key }}"
- name: Validate ChatGPT desktop architecture on Fedora
tags: [packages, chatgpt]
ansible.builtin.assert:
that:
- ansible_facts['architecture'] in chatgpt_desktop_supported_architectures
- ansible_facts['distribution_major_version'] in chatgpt_desktop_supported_fedora_versions
fail_msg: >-
ChatGPT desktop supports Fedora {{ chatgpt_desktop_supported_fedora_versions | join(' and ') }}
on x86_64 or aarch64; detected Fedora {{ ansible_facts['distribution_major_version'] }}
on {{ ansible_facts['architecture'] }}.
when: chatgpt_desktop_enabled | bool
- name: Install ChatGPT desktop RPM signing key
tags: [packages, chatgpt]
ansible.builtin.copy:
src: "{{ chatgpt_desktop_rpm_gpg_key }}"
dest: "/etc/pki/rpm-gpg/{{ chatgpt_desktop_rpm_gpg_key }}"
owner: root
group: root
mode: "0644"
when: chatgpt_desktop_enabled | bool
- name: Import ChatGPT desktop RPM signing key
tags: [packages, chatgpt]
ansible.builtin.rpm_key:
state: present
key: "/etc/pki/rpm-gpg/{{ chatgpt_desktop_rpm_gpg_key }}"
when: chatgpt_desktop_enabled | bool
- name: Configure ChatGPT desktop RPM repository
tags: [packages, chatgpt]
ansible.builtin.yum_repository:
name: openai-chatgpt
description: ChatGPT
baseurl: "{{ chatgpt_desktop_rpm_repository }}"
enabled: true
gpgcheck: true
repo_gpgcheck: true
gpgkey: "file:///etc/pki/rpm-gpg/{{ chatgpt_desktop_rpm_gpg_key }}"
state: present
when: chatgpt_desktop_enabled | bool
- name: Install ChatGPT desktop on selected Fedora hosts
tags: [packages, chatgpt]
ansible.builtin.dnf:
name: chatgpt
state: latest
update_cache: true
when: chatgpt_desktop_enabled | bool
- name: Define Fedora Flatpak applications
tags: [packages]
@@ -160,11 +218,6 @@
(
(fedora_flatpak_packages | default([]))
+ (host_flatpak_packages | default([]))
+ (
(workstation_flatpak_packages | default([]))
if 'workstation_host_linux' in group_names
else []
)
)
| unique
}}

View File

@@ -1,14 +0,0 @@
---
- name: Install packages on FreeBSD
tags: [packages]
community.general.pkgng:
name: >-
{{
(
(freebsd_packages_base | default([]))
+ (freebsd_development_packages | default([]))
+ (host_packages | default([]))
)
| unique
}}
state: present

View File

@@ -0,0 +1,196 @@
---
- name: Require Rocky Linux 9
tags: [packages]
ansible.builtin.assert:
that:
- ansible_facts['distribution'] == 'Rocky'
- ansible_facts['distribution_major_version'] == '9'
fail_msg: This platform role supports Rocky Linux 9 only.
- name: Install DNF configuration plugin
tags: [packages]
ansible.builtin.dnf:
name: dnf-plugins-core
state: present
- name: Enable Rocky CodeReady Builder repository
tags: [packages]
community.general.dnf_config_manager:
name:
- crb
state: enabled
when: rocky_enable_crb | default(true) | bool
- name: Install EPEL repository package
tags: [packages]
ansible.builtin.dnf:
name: epel-release
state: present
- name: Import official OpenZFS EL9+ signing key
tags: [packages, storage]
ansible.builtin.rpm_key:
state: present
key: "{{ rocky_openzfs_gpg_key_url }}"
fingerprint: "{{ rocky_openzfs_gpg_key_fingerprint }}"
when: rocky_manage_openzfs_repo | bool
- name: Install official OpenZFS repository package
tags: [packages, storage]
ansible.builtin.dnf:
name: "{{ rocky_openzfs_release_rpm }}"
state: present
when: rocky_manage_openzfs_repo | bool
- name: Disable OpenZFS DKMS repository
tags: [packages, storage]
community.general.dnf_config_manager:
name:
- zfs
state: disabled
when: rocky_manage_openzfs_repo | bool
- name: Enable OpenZFS kmod repository
tags: [packages, storage]
community.general.dnf_config_manager:
name:
- zfs-kmod
state: enabled
when: rocky_manage_openzfs_repo | bool
- name: Configure official GitHub CLI RPM repository
tags: [packages]
ansible.builtin.get_url:
url: "{{ rocky_github_cli_repo_url }}"
dest: "{{ rocky_github_cli_repo_file }}"
owner: root
group: root
mode: "0644"
when: rocky_manage_github_cli_repo | bool
- name: Refresh Rocky package metadata
tags: [packages]
ansible.builtin.dnf:
update_cache: true
- name: Install packages on Rocky Linux
tags: [packages]
ansible.builtin.dnf:
name: >-
{{
(
(rocky_common_packages | default([]))
+ (rocky_packages_base | default([]))
+ (rocky_profile_packages | default([]))
+ (rocky_podman_packages | default([]))
+ (host_packages | default([]))
) | unique
}}
state: present
- name: Ensure Podman runtime socket directory exists
tags: [packages, podman]
ansible.builtin.file:
path: /run/podman
state: directory
owner: root
group: root
mode: "0700"
when: rocky_manage_podman | bool
- name: Require supported architecture for Syncthing
tags: [packages, syncthing]
ansible.builtin.assert:
that:
- ansible_facts['architecture'] == 'x86_64'
fail_msg: The pinned Atlas Syncthing binary currently supports x86_64 only.
when: rocky_manage_syncthing_binary | bool
- name: Read installed Syncthing version
tags: [packages, syncthing]
ansible.builtin.command:
argv:
- /usr/local/bin/syncthing
- --version
register: rocky_syncthing_version_check
changed_when: false
failed_when: false
check_mode: false
when: rocky_manage_syncthing_binary | bool
- name: Determine whether Syncthing must be installed
tags: [packages, syncthing]
ansible.builtin.set_fact:
rocky_syncthing_install_required: >-
{{
('syncthing v' ~ rocky_syncthing_version ~ ' ')
not in (rocky_syncthing_version_check.stdout | default(''))
}}
when: rocky_manage_syncthing_binary | bool
- name: Create temporary Syncthing extraction directory
tags: [packages, syncthing]
ansible.builtin.file:
path: "/tmp/syncthing-{{ rocky_syncthing_version }}"
state: directory
owner: root
group: root
mode: "0755"
when:
- rocky_manage_syncthing_binary | bool
- rocky_syncthing_install_required | bool
- name: Download pinned Syncthing release
tags: [packages, syncthing]
ansible.builtin.get_url:
url: "{{ rocky_syncthing_archive_url }}"
dest: "/tmp/syncthing-{{ rocky_syncthing_version }}.tar.gz"
checksum: "{{ rocky_syncthing_archive_checksum }}"
owner: root
group: root
mode: "0644"
when:
- rocky_manage_syncthing_binary | bool
- rocky_syncthing_install_required | bool
- name: Extract pinned Syncthing release
tags: [packages, syncthing]
ansible.builtin.unarchive:
src: "/tmp/syncthing-{{ rocky_syncthing_version }}.tar.gz"
dest: "/tmp/syncthing-{{ rocky_syncthing_version }}"
remote_src: true
when:
- rocky_manage_syncthing_binary | bool
- rocky_syncthing_install_required | bool
- name: Install pinned Syncthing binary
tags: [packages, syncthing]
ansible.builtin.copy:
src: >-
{{
'/tmp/syncthing-' ~ rocky_syncthing_version
~ '/syncthing-linux-amd64-v' ~ rocky_syncthing_version
~ '/syncthing'
}}
dest: /usr/local/bin/syncthing
remote_src: true
owner: root
group: root
mode: "0755"
when:
- rocky_manage_syncthing_binary | bool
- rocky_syncthing_install_required | bool
- name: Remove Syncthing release archive
tags: [packages, syncthing]
ansible.builtin.file:
path: "/tmp/syncthing-{{ rocky_syncthing_version }}.tar.gz"
state: absent
when: rocky_manage_syncthing_binary | bool
- name: Remove Syncthing extraction directory
tags: [packages, syncthing]
ansible.builtin.file:
path: "/tmp/syncthing-{{ rocky_syncthing_version }}"
state: absent
when: rocky_manage_syncthing_binary | bool

View File

@@ -1,297 +0,0 @@
---
- name: Gather installed package facts
tags: [packages]
ansible.builtin.package_facts:
manager: auto
- name: Copy Ubuntu dotfiles
tags: [dotfiles, dotfiles:common]
ansible.builtin.copy:
src: "{{ playbook_dir }}/../dotfiles/ubuntu/{{ item.src }}"
dest: "{{ effective_user_home }}/{{ item.dest }}"
owner: "{{ effective_username }}"
group: "{{ effective_user_group }}"
mode: "{{ item.mode }}"
loop: "{{ ubuntu_dotfiles | default([]) }}"
loop_control:
label: "{{ item.dest }}"
- name: Ensure architecture is supported for Google Chrome
tags: [packages]
ansible.builtin.fail:
msg: "Unsupported architecture {{ ansible_facts['architecture'] }} for Google Chrome stable package"
when:
- workstation_manage_google_chrome | default(false)
- ansible_facts['architecture'] != 'x86_64'
- name: Download Google Chrome Debian package
tags: [packages]
ansible.builtin.get_url:
url: https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
dest: /tmp/google-chrome-stable_current_amd64.deb
mode: "0644"
when:
- workstation_manage_google_chrome | default(false)
- "'google-chrome-stable' not in (ansible_facts.packages | default({}))"
- name: Install Google Chrome from downloaded Debian package
tags: [packages]
ansible.builtin.apt:
deb: /tmp/google-chrome-stable_current_amd64.deb
state: present
when:
- workstation_manage_google_chrome | default(false)
- "'google-chrome-stable' not in (ansible_facts.packages | default({}))"
- name: Remove downloaded Google Chrome Debian package
tags: [packages]
ansible.builtin.file:
path: /tmp/google-chrome-stable_current_amd64.deb
state: absent
when:
- workstation_manage_google_chrome | default(false)
- "'google-chrome-stable' not in (ansible_facts.packages | default({}))"
- name: Download glab Debian package
tags: [packages]
ansible.builtin.get_url:
url: >-
https://gitlab.com/gitlab-org/cli/-/releases/v{{ workstation_glab_version }}/downloads/glab_{{ workstation_glab_version }}_linux_{{
'amd64' if ansible_facts['architecture'] == 'x86_64'
else 'arm64' if ansible_facts['architecture'] in ['aarch64', 'arm64']
else ansible_facts['architecture']
}}.deb
dest: "/tmp/glab_{{ workstation_glab_version }}.deb"
mode: "0644"
when:
- workstation_manage_glab | default(false)
- ansible_facts.packages['glab'] is not defined or ansible_facts.packages['glab'][0].version != workstation_glab_version
- name: Install glab from downloaded Debian package
tags: [packages]
ansible.builtin.apt:
deb: "/tmp/glab_{{ workstation_glab_version }}.deb"
state: present
when:
- workstation_manage_glab | default(false)
- ansible_facts.packages['glab'] is not defined or ansible_facts.packages['glab'][0].version != workstation_glab_version
- name: Remove downloaded glab Debian package
tags: [packages]
ansible.builtin.file:
path: "/tmp/glab_{{ workstation_glab_version }}.deb"
state: absent
when:
- workstation_manage_glab | default(false)
- ansible_facts.packages['glab'] is not defined or ansible_facts.packages['glab'][0].version != workstation_glab_version
- name: Ensure Docker apt keyrings directory exists
tags: [packages]
ansible.builtin.file:
path: /etc/apt/keyrings
state: directory
owner: root
group: root
mode: "0755"
when: ubuntu_manage_docker_repo | default(false)
- name: Download Docker apt repository signing key
tags: [packages]
ansible.builtin.get_url:
url: https://download.docker.com/linux/ubuntu/gpg
dest: /etc/apt/keyrings/docker.asc
owner: root
group: root
mode: "0644"
when: ubuntu_manage_docker_repo | default(false)
- name: Configure Docker apt repository
tags: [packages]
ansible.builtin.apt_repository:
repo: >-
deb [arch={{ 'amd64' if ansible_facts['architecture'] == 'x86_64'
else 'arm64' if ansible_facts['architecture'] in ['aarch64', 'arm64']
else ansible_facts['architecture'] }}
signed-by=/etc/apt/keyrings/docker.asc]
https://download.docker.com/linux/ubuntu
{{ ansible_facts['distribution_release'] }} stable
filename: docker
state: present
update_cache: true
when: ubuntu_manage_docker_repo | default(false)
- name: Refresh apt package cache
tags: [packages]
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
- name: Install packages on Ubuntu
tags: [packages]
ansible.builtin.apt:
name: >-
{{
(
(common_packages | default([]))
+ (ubuntu_packages_base | default([]))
+ (ubuntu_docker_packages | default([]))
+ (profile_packages | default([]))
+ (
(ubuntu_emacs_packages | default([]))
if (emacs_enabled | default(false) | bool)
else []
)
+ (workstation_dev_packages | default([]))
+ (
(workstation_host_linux_packages | default([]))
if 'workstation_host_linux' in group_names
else []
)
+ (
(workstation_dev_wsl_packages | default([]))
if 'workstation_dev_wsl' in group_names
else []
)
+ (desktop_common_packages | default([]))
+ (host_packages | default([]))
)
| difference(
(workstation_dev_wsl_excluded_packages | default([]))
if 'workstation_dev_wsl' in group_names
else []
)
| unique
}}
state: present
- name: Add user to docker group
tags: [packages]
ansible.builtin.user:
name: "{{ effective_username }}"
groups: docker
append: true
when: (ubuntu_docker_packages | default([])) | length > 0
- name: Ensure architecture is supported for croc GitHub release
tags: [packages]
ansible.builtin.fail:
msg: "Unsupported architecture {{ ansible_facts['architecture'] }} for croc GitHub release"
when:
- workstation_manage_croc | default(false)
- ansible_facts['architecture'] not in ['x86_64', 'aarch64', 'arm64']
- name: Read installed croc version
tags: [packages]
ansible.builtin.command: /usr/local/bin/croc --version
register: croc_version_check
changed_when: false
failed_when: false
check_mode: false
when: workstation_manage_croc | default(false)
- name: Ensure temporary croc extraction directory is absent
tags: [packages]
ansible.builtin.file:
path: "/tmp/croc_{{ workstation_croc_version }}"
state: absent
when:
- workstation_manage_croc | default(false)
- workstation_croc_version not in (croc_version_check.stdout | default(''))
- name: Create temporary croc extraction directory
tags: [packages]
ansible.builtin.file:
path: "/tmp/croc_{{ workstation_croc_version }}"
state: directory
owner: root
group: root
mode: "0755"
when:
- workstation_manage_croc | default(false)
- workstation_croc_version not in (croc_version_check.stdout | default(''))
- name: Download croc GitHub release archive
tags: [packages]
ansible.builtin.get_url:
url: >-
{{
'https://github.com/schollz/croc/releases/download/'
~ workstation_croc_version
~ '/croc_'
~ workstation_croc_version
~ '_Linux-'
~ (
'64bit' if ansible_facts['architecture'] == 'x86_64'
else 'ARM64' if ansible_facts['architecture'] in ['aarch64', 'arm64']
else ansible_facts['architecture']
)
~ '.tar.gz'
}}
dest: "/tmp/croc_{{ workstation_croc_version }}.tar.gz"
mode: "0644"
when:
- workstation_manage_croc | default(false)
- workstation_croc_version not in (croc_version_check.stdout | default(''))
- name: Extract croc GitHub release archive
tags: [packages]
ansible.builtin.unarchive:
src: "/tmp/croc_{{ workstation_croc_version }}.tar.gz"
dest: "/tmp/croc_{{ workstation_croc_version }}"
remote_src: true
when:
- workstation_manage_croc | default(false)
- workstation_croc_version not in (croc_version_check.stdout | default(''))
- name: Install croc binary
tags: [packages]
ansible.builtin.copy:
src: "/tmp/croc_{{ workstation_croc_version }}/croc"
dest: /usr/local/bin/croc
owner: root
group: root
mode: "0755"
remote_src: true
when:
- workstation_manage_croc | default(false)
- workstation_croc_version not in (croc_version_check.stdout | default(''))
- name: Remove downloaded croc archive
tags: [packages]
ansible.builtin.file:
path: "/tmp/croc_{{ workstation_croc_version }}.tar.gz"
state: absent
when:
- workstation_manage_croc | default(false)
- workstation_croc_version not in (croc_version_check.stdout | default(''))
- name: Remove temporary croc extraction directory
tags: [packages]
ansible.builtin.file:
path: "/tmp/croc_{{ workstation_croc_version }}"
state: absent
when:
- workstation_manage_croc | default(false)
- workstation_croc_version not in (croc_version_check.stdout | default(''))
- name: Remove workstation snap packages
tags: [packages]
community.general.snap:
name: "{{ item }}"
state: absent
loop: "{{ workstation_removed_snap_packages | default([]) }}"
loop_control:
label: "{{ item }}"
when: (workstation_removed_snap_packages | default([])) | length > 0
- name: Install workstation snap packages
tags: [packages]
community.general.snap:
name: "{{ item.name }}"
classic: "{{ item.classic | default(false) }}"
channel: "{{ item.channel | default(omit) }}"
state: present
loop: "{{ workstation_snap_packages | default([]) }}"
loop_control:
label: "{{ item.name }}"
when: (workstation_snap_packages | default([])) | length > 0

View File

@@ -0,0 +1,20 @@
---
aegis_hostname: aegis
aegis_layered_packages:
- nfs-utils
- wireguard-tools
aegis_network_connection_name: ""
aegis_network_connection_uuid: ""
aegis_host_dns_servers: []
aegis_host_dns_search_domains: []
aegis_adguard_image: docker.io/adguard/adguardhome:latest
aegis_ikaros_mac_address: aa:bb:cc:dd:ee:ff
aegis_wol_port: 9
aegis_lan_subnet: CHANGEME_LAN_SUBNET
aegis_firewalld_zone: public
aegis_adguard_web_port: 80
aegis_ssh_allowed_users:
- "{{ ansible_user }}"
aegis_ssh_user_home: "/var/home/{{ ansible_user }}"
aegis_ssh_authorized_keys: []

View File

@@ -0,0 +1,20 @@
---
- name: Restart Aegis systemd-resolved
ansible.builtin.systemd:
name: systemd-resolved.service
state: restarted
- name: Reload Aegis SSH
ansible.builtin.systemd:
name: sshd.service
state: reloaded
- name: Restart Aegis AdGuard Quadlet
ansible.builtin.systemd:
name: "{{ item }}"
state: restarted
daemon_reload: true
loop:
- adguardhome.service
loop_control:
label: "{{ item }}"

View File

@@ -0,0 +1,362 @@
---
- name: Manage Aegis layered packages
tags: [aegis, packages, nfs, wireguard]
community.general.rpm_ostree_pkg:
name: "{{ aegis_layered_packages }}"
state: present
register: aegis_layered_packages_result
when: aegis_layered_packages | length > 0
- name: Report reboot required for Aegis layered packages
tags: [aegis, packages, nfs, wireguard]
ansible.builtin.debug:
msg: Reboot Aegis to activate the newly layered packages, then rerun the playbook.
when: aegis_layered_packages_result.needs_reboot | default(false)
- name: Require completed Aegis network placeholders
tags: [aegis, dns, firewall, network, services]
ansible.builtin.assert:
that:
- aegis_lan_subnet != 'CHANGEME_LAN_SUBNET'
- aegis_firewalld_zone | length > 0
- aegis_adguard_web_port | int > 0
- aegis_adguard_web_port | int < 65536
- aegis_network_connection_name | length > 0
- aegis_network_connection_uuid | length > 0
- aegis_host_dns_servers | length > 0
- ansible_facts["default_ipv4"]["address"] not in aegis_host_dns_servers
- aegis_ssh_allowed_users | length > 0
fail_msg: >-
Define the Aegis LAN subnet, firewalld zone, AdGuard web port, independent host DNS,
NetworkManager connection, and SSH users. Aegis must not use its own address as upstream DNS.
- name: Verify the declared Aegis NetworkManager connection exists
tags: [aegis, dns, network, services]
ansible.builtin.command:
argv:
- nmcli
- --get-values
- connection.id
- connection
- show
- uuid
- "{{ aegis_network_connection_uuid }}"
register: aegis_network_connection
changed_when: false
failed_when: >-
aegis_network_connection.rc != 0
or aegis_network_connection.stdout != aegis_network_connection_name
- name: Read the current Aegis host DNS configuration
tags: [aegis, dns, network, services]
ansible.builtin.command:
argv:
- nmcli
- --get-values
- ipv4.ignore-auto-dns,ipv4.dns,ipv4.dns-search,ipv6.ignore-auto-dns
- connection
- show
- uuid
- "{{ aegis_network_connection_uuid }}"
register: aegis_host_dns_current
changed_when: false
- name: Configure independent upstream DNS for the Aegis host
tags: [aegis, dns, network, services]
ansible.builtin.command:
argv:
- nmcli
- connection
- modify
- uuid
- "{{ aegis_network_connection_uuid }}"
- ipv4.ignore-auto-dns
- "yes"
- ipv4.dns
- "{{ aegis_host_dns_servers | join(',') }}"
- ipv4.dns-search
- "{{ aegis_host_dns_search_domains | join(',') }}"
- ipv6.ignore-auto-dns
- "yes"
register: aegis_host_dns_profile
when: >-
aegis_host_dns_current.stdout_lines !=
[
'yes',
aegis_host_dns_servers | join(','),
aegis_host_dns_search_domains | join(','),
'yes'
]
changed_when: true
- name: Report Aegis reboot required for host DNS changes
tags: [aegis, dns, network, services]
ansible.builtin.debug:
msg: Reboot Aegis to activate its independent upstream DNS before testing another OS update.
when: aegis_host_dns_profile.changed | default(false)
- name: Set Aegis hostname
tags: [aegis, services]
ansible.builtin.hostname:
name: "{{ aegis_hostname }}"
- name: Create Aegis persistent container directories
tags: [aegis, containers]
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: "0755"
loop:
- /var/lib/adguard/work
- /var/lib/adguard/conf
- name: Create Quadlet configuration directory
tags: [aegis, containers]
ansible.builtin.file:
path: /etc/containers/systemd
state: directory
owner: root
group: root
mode: "0755"
- name: Render Aegis Quadlet units
tags: [aegis, containers]
ansible.builtin.template:
src: "{{ item.src }}"
dest: "/etc/containers/systemd/{{ item.dest }}"
owner: root
group: root
mode: "0644"
loop:
- src: adguardhome.container.j2
dest: adguardhome.container
loop_control:
label: "{{ item.dest }}"
notify: Restart Aegis AdGuard Quadlet
- name: Create Aegis systemd-resolved configuration directory
tags: [aegis, adguard, dns, services]
ansible.builtin.file:
path: /etc/systemd/resolved.conf.d
state: directory
owner: root
group: root
mode: "0755"
- name: Disable Aegis systemd-resolved DNS stub listener
tags: [aegis, adguard, dns, services]
ansible.builtin.template:
src: 10-adguard-dns.conf.j2
dest: /etc/systemd/resolved.conf.d/10-adguard-dns.conf
owner: root
group: root
mode: "0644"
notify:
- Restart Aegis systemd-resolved
- Restart Aegis AdGuard Quadlet
- name: Point Aegis resolver at the full systemd-resolved configuration
tags: [aegis, adguard, dns, services]
ansible.builtin.file:
src: ../run/systemd/resolve/resolv.conf
dest: /etc/resolv.conf
state: link
force: true
notify: Restart Aegis systemd-resolved
- name: Enable Aegis firewalld
tags: [aegis, firewall, services]
ansible.builtin.systemd:
name: firewalld.service
enabled: true
state: started
- name: Render Aegis AdGuard web firewalld service
tags: [aegis, firewall]
ansible.builtin.template:
src: aegis-adguard-web.xml.j2
dest: /etc/firewalld/services/aegis-adguard-web.xml
owner: root
group: root
mode: "0644"
- name: Reload firewalld custom services
tags: [aegis, firewall]
ansible.builtin.command:
argv:
- firewall-cmd
- --reload
changed_when: false
when: not ansible_check_mode
- name: Remove unrestricted Aegis services from firewalld zone
tags: [aegis, firewall]
ansible.posix.firewalld:
service: "{{ item }}"
permanent: true
immediate: true
state: disabled
zone: "{{ aegis_firewalld_zone }}"
loop:
- ssh
- dns
- http
- aegis-adguard-web
loop_control:
label: "{{ item }}"
- name: Remove retired Aegis TLS access rule
tags: [aegis, firewall]
ansible.posix.firewalld:
rich_rule: 'rule family="ipv4" source address="{{ aegis_lan_subnet }}" port port="853" protocol="tcp" accept'
permanent: true
immediate: true
state: disabled
zone: "{{ aegis_firewalld_zone }}"
- name: Allow Aegis services from the LAN only
tags: [aegis, firewall]
ansible.posix.firewalld:
rich_rule: "{{ item }}"
permanent: true
immediate: true
state: enabled
zone: "{{ aegis_firewalld_zone }}"
loop:
- 'rule family="ipv4" source address="{{ aegis_lan_subnet }}" service name="ssh" accept'
- 'rule family="ipv4" source address="{{ aegis_lan_subnet }}" service name="dns" accept'
- 'rule family="ipv4" source address="{{ aegis_lan_subnet }}" service name="aegis-adguard-web" accept'
loop_control:
label: "{{ item }}"
- name: Check the standard Aegis SSH authorized keys file
tags: [aegis, ssh, services]
ansible.builtin.stat:
path: "{{ aegis_ssh_user_home }}/.ssh/authorized_keys"
register: aegis_authorized_keys
- name: Ensure Aegis SSH authorized key fragments directory exists
tags: [aegis, ssh, services]
ansible.builtin.file:
path: "{{ aegis_ssh_user_home }}/.ssh/authorized_keys.d"
state: directory
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0700"
when: aegis_ssh_authorized_keys | length > 0
- name: Manage Aegis SSH authorized key fragments
tags: [aegis, ssh, services]
ansible.builtin.copy:
content: "{{ item.key }}\n"
dest: "{{ aegis_ssh_user_home }}/.ssh/authorized_keys.d/{{ item.name }}"
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0600"
loop: "{{ aegis_ssh_authorized_keys }}"
loop_control:
label: "{{ item.name }}"
when: aegis_ssh_authorized_keys | length > 0
- name: Find Aegis SSH authorized key fragments
tags: [aegis, ssh, services]
ansible.builtin.find:
paths: "{{ aegis_ssh_user_home }}/.ssh/authorized_keys.d"
file_type: file
recurse: false
register: aegis_authorized_key_fragments
- name: Require an Aegis SSH authorized key before hardening
tags: [aegis, ssh, services]
ansible.builtin.assert:
that:
- >-
(aegis_ssh_authorized_keys | length > 0)
or (aegis_authorized_keys.stat.exists and aegis_authorized_keys.stat.size | int > 0)
or aegis_authorized_key_fragments.matched | int > 0
fail_msg: Add a public key for the Ansible SSH user before disabling password authentication.
- name: Ensure Aegis SSH configuration drop-in directory exists
tags: [aegis, ssh, services]
ansible.builtin.file:
path: /etc/ssh/sshd_config.d
state: directory
owner: root
group: root
mode: "0755"
- name: Ensure Aegis SSH drop-ins are loaded before other settings
tags: [aegis, ssh, services]
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^\s*Include\s+/etc/ssh/sshd_config\.d/\*\.conf\s*$'
line: Include /etc/ssh/sshd_config.d/*.conf
insertbefore: BOF
state: present
validate: "sshd -t -f %s"
- name: Render Aegis SSH hardening drop-in
tags: [aegis, ssh, services]
ansible.builtin.template:
src: 00-aegis-hardening.conf.j2
dest: /etc/ssh/sshd_config.d/00-aegis-hardening.conf
owner: root
group: root
mode: "0600"
notify: Reload Aegis SSH
- name: Validate Aegis SSH configuration
tags: [aegis, ssh, services]
ansible.builtin.command:
argv:
- sshd
- -t
changed_when: false
when: not ansible_check_mode
- name: Read effective Aegis SSH daemon configuration
tags: [aegis, ssh, services]
ansible.builtin.command:
argv:
- sshd
- -T
- -C
- "user={{ ansible_user }},host={{ aegis_hostname }},addr=127.0.0.1"
register: aegis_sshd_effective_configuration
changed_when: false
when: not ansible_check_mode
- name: Verify effective Aegis SSH hardening
tags: [aegis, ssh, services]
ansible.builtin.assert:
that:
- "'permitrootlogin no' in aegis_sshd_effective_configuration.stdout_lines"
- "'pubkeyauthentication yes' in aegis_sshd_effective_configuration.stdout_lines"
- "'passwordauthentication no' in aegis_sshd_effective_configuration.stdout_lines"
- "'kbdinteractiveauthentication no' in aegis_sshd_effective_configuration.stdout_lines"
- "'allowusers ' + (aegis_ssh_allowed_users | join(' ')) in aegis_sshd_effective_configuration.stdout_lines"
when: not ansible_check_mode
- name: Install Wake-on-LAN helper for Ikaros
tags: [aegis, wol]
ansible.builtin.template:
src: wake-ikaros.j2
dest: /usr/local/bin/wake-ikaros
owner: root
group: root
mode: "0755"
- name: Enable Aegis AdGuard Quadlet and automatic updates
tags: [aegis, containers, services]
ansible.builtin.systemd:
name: "{{ item }}"
enabled: true
state: started
daemon_reload: true
loop:
- adguardhome.service
- podman-auto-update.timer
loop_control:
label: "{{ item }}"

View File

@@ -0,0 +1,6 @@
# Managed by Ansible. Do not edit manually.
PermitRootLogin no
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
AllowUsers {{ aegis_ssh_allowed_users | join(' ') }}

View File

@@ -0,0 +1,3 @@
# Managed by Ansible. Do not edit manually.
[Resolve]
DNSStubListener=no

View File

@@ -0,0 +1,18 @@
# Managed by Ansible. Do not edit manually.
[Unit]
Description=AdGuard Home DNS sinkhole
Wants=network-online.target
After=network-online.target
[Container]
Image={{ aegis_adguard_image }}
Network=host
Volume=/var/lib/adguard/work:/opt/adguardhome/work:Z
Volume=/var/lib/adguard/conf:/opt/adguardhome/conf:Z
AutoUpdate=registry
[Service]
Restart=always
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<service>
<short>Aegis AdGuard Home web interface</short>
<description>AdGuard Home administrative web interface on Aegis.</description>
<port protocol="tcp" port="{{ aegis_adguard_web_port }}"/>
</service>

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Managed by Ansible. Do not edit manually.
set -euo pipefail
mac='{{ aegis_ikaros_mac_address }}'
port='{{ aegis_wol_port }}'
mac_hex="${mac//:/}"
if [[ ! $mac_hex =~ ^[[:xdigit:]]{12}$ ]]; then
printf 'Indirizzo MAC non valido: %s\n' "$mac" >&2
exit 1
fi
packet='\xFF\xFF\xFF\xFF\xFF\xFF'
for _ in {1..16}; do
packet+="$(printf '\\x%s\\x%s\\x%s\\x%s\\x%s\\x%s' \
"${mac_hex:0:2}" "${mac_hex:2:2}" "${mac_hex:4:2}" \
"${mac_hex:6:2}" "${mac_hex:8:2}" "${mac_hex:10:2}")"
done
printf '%b' "$packet" > "/dev/udp/255.255.255.255/${port}"

View File

@@ -0,0 +1,275 @@
---
atlas_manage_storage: false
atlas_manage_sharing: false
# Destructive first-boot action; normally false once the pool exists.
atlas_create_pool: false
atlas_zpool_disks: []
atlas_admin_username: CHANGEME_ATLAS_ADMIN
atlas_admin_group: "{{ atlas_admin_username }}"
atlas_admin_home: "/home/{{ atlas_admin_username }}"
atlas_admin_uid: 1000
atlas_admin_gid: 1000
atlas_admin_ssh_keys: []
atlas_admin_ssh_key_directory: "{{ atlas_admin_home }}/.ssh/authorized_keys.d"
atlas_admin_password_hash: "!"
# Local forwarding permits an administrator to tunnel a private service without allowing remote forwards.
atlas_ssh_allow_tcp_forwarding: local
atlas_immich_username: immich
atlas_immich_group: immich
atlas_immich_uid: 1100
atlas_immich_gid: 1100
atlas_immich_supplementary_groups:
- video
- render
atlas_lan_subnet: CHANGEME_LAN_SUBNET
atlas_aegis_ip: CHANGEME_AEGIS_IP
atlas_manage_firewall: false
atlas_firewalld_zone: public
atlas_hardening_sysctls:
net.ipv4.conf.all.accept_redirects: "0"
net.ipv4.conf.default.accept_redirects: "0"
net.ipv4.conf.all.send_redirects: "0"
net.ipv4.conf.default.send_redirects: "0"
net.ipv4.conf.all.accept_source_route: "0"
net.ipv4.conf.default.accept_source_route: "0"
net.ipv4.conf.all.log_martians: "1"
net.ipv4.conf.default.log_martians: "1"
net.ipv4.conf.all.rp_filter: "2"
net.ipv4.conf.default.rp_filter: "2"
net.ipv4.tcp_syncookies: "1"
net.ipv4.icmp_echo_ignore_broadcasts: "1"
net.ipv4.icmp_ignore_bogus_error_responses: "1"
net.ipv4.ip_forward: "0"
net.ipv6.conf.all.accept_redirects: "0"
net.ipv6.conf.default.accept_redirects: "0"
net.ipv6.conf.all.accept_source_route: "0"
net.ipv6.conf.default.accept_source_route: "0"
atlas_zfs_pool: CHANGEME_ZFS_POOL
atlas_zfs_dataset_archive: archive
atlas_zfs_dataset_services: services
atlas_zfs_dataset_app_data: services/data
atlas_zfs_dataset_navidrome: services/data/navidrome
atlas_zfs_dataset_syncthing: services/data/syncthing
atlas_zfs_dataset_media: media
atlas_zfs_dataset_music: media/music
atlas_zfs_dataset_backup: backup
atlas_zfs_dataset_host_backups: backup/hosts
atlas_zfs_dataset_backup_prometheus: backup/hosts/prometheus
atlas_zfs_backup_reservation: 500G
atlas_zfs_dataset_photobook: media/photobook
atlas_mount_root: /CHANGEME_ATLAS_MOUNT_ROOT
atlas_manage_zfs_snapshots: false
atlas_zfs_snapshot_prefix: atlas-auto
atlas_zfs_snapshot_policies: []
atlas_manage_zfs_scrub: false
atlas_zfs_scrub_calendar: ""
atlas_manage_borg_backup: false
atlas_borg_username: borg
atlas_borg_group: borg
atlas_borg_home: /var/lib/atlas-borg
atlas_borg_repository_host: CHANGEME_BORG_HOST
atlas_borg_repository_user: CHANGEME_BORG_USER
atlas_borg_repository_port: 23
atlas_borg_repository_path: ./borg-data
atlas_borg_remote_path: borg-1.4
atlas_borg_host_key: ""
atlas_borg_ssh_private_key_path: /etc/atlas-borg/id_ed25519
atlas_borg_known_hosts_path: /etc/atlas-borg/known_hosts
atlas_borg_passphrase_path: /etc/atlas-borg/passphrase
atlas_borg_ssh_wrapper_path: /usr/local/libexec/atlas-borg-ssh
atlas_borg_passphrase: "{{ vault_atlas_borg_passphrase | default('') }}"
atlas_borg_encryption_mode: repokey
atlas_borg_archive_prefix: atlas
atlas_borg_snapshot_prefix: atlas-borg
atlas_borg_compression: auto,zstd,3
atlas_borg_backup_calendar: ""
atlas_borg_check_calendar: ""
atlas_borg_randomized_delay: 30m
atlas_borg_keep_daily: 30
atlas_borg_keep_weekly: 8
atlas_borg_keep_monthly: 12
atlas_borg_config_dir: /var/lib/atlas-borg
atlas_borg_cache_dir: /var/cache/atlas-borg
atlas_borg_lock_path: /var/lib/atlas-borg/backup.lock
atlas_borg_recovery_export_path: "{{ playbook_dir }}/../secrets/recovery/atlas-borg-repokey.export"
# Manual-only offline backup. No USB device is formatted or mounted by Ansible.
atlas_manage_usb_backup: false
atlas_usb_backup_luks_uuid: ""
atlas_usb_backup_fs_uuid: ""
atlas_usb_backup_mapper_name: atlas-usb-backup
atlas_usb_backup_min_free_bytes: 10737418240
atlas_usb_backup_snapshot_prefix: atlas-usb
atlas_manage_usb_reminder: false
atlas_usb_reminder_calendar: ""
atlas_usb_reminder_notifier: /opt/45drives/houston/houston-notify
# Read-only health probes and 45Drives Alerts; disabled outside Atlas host vars.
atlas_manage_monitoring: false
atlas_monitor_calendar: "*:0/30"
atlas_monitor_notifier: "{{ atlas_usb_reminder_notifier }}"
atlas_monitor_smart_devices: []
atlas_monitor_timers: []
atlas_monitor_failure_units: []
atlas_monitor_effective_timers: >-
{{ atlas_monitor_timers
+ ([{'name': 'atlas-prometheus-pull.timer', 'max_age_hours': 26}]
if atlas_manage_prometheus_backup_pull | bool and atlas_prometheus_pull_start_timer | bool
else []) }}
atlas_monitor_effective_failure_units: >-
{{ atlas_monitor_failure_units
+ (['atlas-prometheus-pull.service']
if atlas_manage_prometheus_backup_pull | bool else []) }}
atlas_monitor_remote_capacity: {}
atlas_monitor_pool_warning_percent: 80
atlas_monitor_pool_critical_percent: 90
atlas_monitor_root_warning_percent: 80
atlas_monitor_root_critical_percent: 90
atlas_monitor_snapshot_warning_percent: 10
atlas_monitor_snapshot_critical_percent: 20
atlas_monitor_snapshot_growth_warning_gib_day: 100
atlas_monitor_backup_growth_warning_gib_day: 100
atlas_monitor_cpu_warning_c: 85
atlas_monitor_cpu_critical_c: 95
atlas_monitor_borg_max_runtime_days: 14
# Explicit post-restore relabeling only; never relabel datasets during ordinary runs.
atlas_restorecon_paths: []
atlas_archive_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_archive }}"
atlas_services_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_services }}"
atlas_app_data_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_app_data }}"
atlas_navidrome_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_navidrome }}"
atlas_syncthing_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_syncthing }}"
atlas_media_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_media }}"
atlas_music_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_music }}"
atlas_backup_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_backup }}"
atlas_host_backups_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_host_backups }}"
atlas_backup_prometheus_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_backup_prometheus }}"
atlas_manage_prometheus_backup_pull: false
atlas_prometheus_pull_ssh_dir: /etc/atlas-prometheus-pull
atlas_prometheus_pull_private_key_path: "{{ atlas_prometheus_pull_ssh_dir }}/id_ed25519"
atlas_prometheus_pull_known_hosts_path: "{{ atlas_prometheus_pull_ssh_dir }}/known_hosts"
atlas_prometheus_ssh_host_key: ""
atlas_prometheus_pull_source_user: prometheus-backup
atlas_prometheus_pull_source_port: 22
atlas_prometheus_pull_calendar: "*-*-* 03:00:00 Europe/Rome"
atlas_prometheus_pull_start_timer: false
atlas_prometheus_pull_keep_daily: 30
atlas_prometheus_pull_keep_weekly: 8
atlas_prometheus_pull_keep_monthly: 12
atlas_prometheus_pull_max_age_hours: 24
atlas_photobook_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_photobook }}"
# Rootless Gitea runs in admin's user manager; the image maps internal gitea to UID/GID 1000.
atlas_manage_gitea: false
atlas_gitea_username: "{{ atlas_admin_username }}"
atlas_gitea_group: "{{ atlas_admin_group }}"
atlas_gitea_uid: "{{ atlas_admin_uid }}"
atlas_gitea_gid: "{{ atlas_admin_gid }}"
atlas_gitea_home: "{{ atlas_admin_home }}"
atlas_gitea_container_uid: 1000
atlas_gitea_container_gid: 1000
atlas_gitea_legacy_username: gitea
atlas_gitea_legacy_uid: 1101
atlas_gitea_legacy_home: /var/lib/atlas-gitea
atlas_gitea_owner_migration: false
atlas_gitea_dataset: "{{ atlas_zfs_pool }}/services/data/gitea"
atlas_gitea_mountpoint: "{{ atlas_app_data_mountpoint }}/gitea"
atlas_gitea_quadlet_dir: "{{ atlas_gitea_home }}/.config/containers/systemd"
atlas_gitea_image: localhost/atlas-gitea:1.25.2-user-gitea-v1
atlas_gitea_image_build_dir: "{{ atlas_gitea_home }}/.local/share/atlas-gitea-image"
atlas_gitea_production_enabled: false
atlas_gitea_public_domain: ""
atlas_gitea_bind_address: "{{ ansible_host }}"
atlas_gitea_http_port: 3000
atlas_gitea_ssh_port: 2222
atlas_gitea_staging_bind_address: 127.0.0.1
atlas_gitea_staging_http_port: 3001
atlas_gitea_staging_ssh_port: 2223
atlas_gitea_restore_test: false
atlas_gitea_final_restore: false
atlas_gitea_restore_helper: /usr/local/libexec/atlas-gitea-restore-test
# Declare storage and an inactive Quadlet only. The operator supplies the
# private configuration, handles MFA, and starts the user service manually.
atlas_icloudpd_dataset: "{{ atlas_zfs_pool }}/services/data/icloudpd"
atlas_icloudpd_state_dir: "{{ atlas_app_data_mountpoint }}/icloudpd"
atlas_icloudpd_config_dir: "{{ atlas_icloudpd_state_dir }}/config"
atlas_icloudpd_photos_dir: "{{ atlas_archive_mountpoint }}/Pictures/iCloudPD"
atlas_icloudpd_image: >-
docker.io/boredazfcuk/icloudpd@sha256:9966c31ddf0b5b306ac2410b4edd5d626806d96e80c92b83cbb689972dc9389f
atlas_icloudpd_quadlet_dir: "{{ atlas_admin_home }}/.config/containers/systemd"
atlas_icloudpd_timezone: Europe/Rome
atlas_45drives_repo_url: https://repo.45drives.com/repofiles/rocky/45drives-enterprise.repo
atlas_45drives_repo_file: /etc/yum.repos.d/45drives-enterprise.repo
atlas_45drives_packages:
- cockpit-file-sharing
- cockpit-navigator
- cockpit-identities
- cockpit-zfs
- cockpit-scheduler
atlas_nfs_exports:
- path: "{{ atlas_photobook_mountpoint }}"
client: "{{ atlas_aegis_ip }}"
options:
- rw
- sync
- no_subtree_check
- all_squash
- "anonuid={{ atlas_immich_uid }}"
- "anongid={{ atlas_immich_gid }}"
atlas_samba_share_name: Archive
atlas_samba_workgroup: WORKGROUP
# Keep encryption mandatory unless a verified client-compatibility exception is explicitly required.
atlas_samba_encryption: required
atlas_samba_valid_users: []
atlas_samba_accounts: []
atlas_samba_password_marker_dir: /var/lib/samba/private/ansible-passwords
atlas_firewalld_rich_rules: []
atlas_firewalld_restricted_services:
- ssh
- cockpit
- nfs
- samba
- http
- https
atlas_selinux_booleans:
- samba_export_all_rw
- nfs_export_all_rw
atlas_manage_media_stack: false
atlas_container_quadlet_dir: /etc/containers/systemd
atlas_container_data_root: /var/lib/atlas-containers
atlas_npm_data_dir: "{{ atlas_container_data_root }}/npm/data"
atlas_npm_letsencrypt_dir: "{{ atlas_container_data_root }}/npm/letsencrypt"
atlas_npm_admin_bind_address: 127.0.0.1
atlas_timezone: Europe/Rome
atlas_immich_upload_dir: "{{ atlas_container_data_root }}/immich/upload"
atlas_immich_model_cache_dir: "{{ atlas_container_data_root }}/immich/model-cache"
atlas_immich_machine_learning_config_dir: "{{ atlas_container_data_root }}/immich/machine-learning-config"
atlas_immich_machine_learning_cache_dir: "{{ atlas_container_data_root }}/immich/machine-learning-cache"
atlas_immich_redis_data_dir: "{{ atlas_container_data_root }}/immich/redis"
atlas_immich_postgres_data_dir: "{{ atlas_container_data_root }}/immich/postgres"
atlas_npm_image: docker.io/jc21/nginx-proxy-manager:latest
atlas_immich_version: release
atlas_immich_server_image: "ghcr.io/immich-app/immich-server:{{ atlas_immich_version }}"
atlas_immich_machine_learning_image: "ghcr.io/immich-app/immich-machine-learning:{{ atlas_immich_version }}-openvino"
atlas_immich_redis_image: docker.io/valkey/valkey:9
atlas_immich_postgres_image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0
atlas_immich_db_username: postgres
atlas_immich_db_name: immich
atlas_immich_db_password: "{{ vault_atlas_immich_db_password | default('') }}"
atlas_media_quadlet_services:
- atlas-immich-redis.service
- atlas-immich-postgres.service
- atlas-immich-machine-learning.service
- atlas-immich-server.service
- atlas-npm.service

View File

@@ -0,0 +1,10 @@
FROM docker.gitea.com/gitea@sha256:f1943db2d2f1e447e857b3f0aee4ebb7b184500f86e5b80eae110fd435435906
# Preserve the official image's UID/GID, paths and entrypoint; change only the
# internal Unix identity. The host-side rootless owner is Atlas admin.
USER 0
RUN sed -i 's/^git:x:1000:1000:/gitea:x:1000:1000:/' /etc/passwd \
&& sed -i 's/^git:x:1000:/gitea:x:1000:/' /etc/group \
&& grep -q '^gitea:x:1000:1000:' /etc/passwd \
&& grep -q '^gitea:x:1000:' /etc/group
USER 1000:1000

View File

@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Turn Borg's JSON progress stream into bounded, readable journal entries."""
import argparse
import json
import sys
import time
def size(value):
if not isinstance(value, (int, float)):
return "unknown"
return f"{value / (1024 ** 3):.2f} GiB"
parser = argparse.ArgumentParser()
parser.add_argument("--estimated-total-bytes", type=int, required=True)
args = parser.parse_args()
if args.estimated_total_bytes <= 0:
parser.error("estimated total must be positive")
last_progress = 0.0
for line in sys.stdin:
try:
event = json.loads(line)
except json.JSONDecodeError:
print(line.rstrip(), flush=True)
continue
kind = event.get("type")
if kind == "archive_progress":
now = time.monotonic()
if now - last_progress < 60 and not event.get("finished"):
continue
path = event.get("path") or ""
parts = path.split("/")
dataset = parts[1] if len(parts) > 1 and parts[0] == "source" else "unknown"
original_size = event.get("original_size")
if isinstance(original_size, (int, float)) and original_size >= 0:
percent = original_size / args.estimated_total_bytes * 100
estimated_progress = (
f"{percent:.1f}%" if percent < 100 else ">=100% (ZFS estimate exceeded)"
)
else:
estimated_progress = "unknown"
print(
"Borg create progress: "
f"estimated={estimated_progress} dataset={dataset} "
f"files={event.get('nfiles', 'unknown')} "
f"original={size(original_size)} "
f"compressed={size(event.get('compressed_size'))} "
f"deduplicated={size(event.get('deduplicated_size'))}",
flush=True,
)
last_progress = now
elif kind == "log_message":
print(f"Borg {event.get('levelname', 'INFO')}: {event.get('message', '')}", flush=True)
elif kind == "progress_message" and event.get("message"):
print(f"Borg: {event['message']}", flush=True)

View File

@@ -0,0 +1,249 @@
#!/usr/bin/python3
"""Rehearse a selective rootful-to-rootless Gitea restore, never a cutover."""
import argparse
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import re
import shutil
import sqlite3
import tarfile
import tempfile
SOURCE_PREFIX = PurePosixPath("opt/gitea/data")
HOST_KEYS = (
"ssh_host_ed25519_key",
"ssh_host_rsa_key",
"ssh_host_ecdsa_key",
)
SERVER_SETTINGS = {
"START_SSH_SERVER": "true",
"BUILTIN_SSH_SERVER_USER": "git",
"SSH_USER": "git",
"SSH_PORT": "2222",
"SSH_LISTEN_PORT": "2222",
"SSH_SERVER_HOST_KEYS": ", ".join(
f"/var/lib/gitea/ssh/{key}" for key in HOST_KEYS
),
}
def sha256(path):
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def expected_digest(backup):
checksum = (backup / "payload.sha256").read_text().strip().split()
if len(checksum) != 2 or checksum[1] != "payload.tar":
raise ValueError("Unexpected Prometheus backup checksum manifest")
if not re.fullmatch(r"[0-9a-f]{64}", checksum[0]):
raise ValueError("Invalid Prometheus backup SHA-256")
return checksum[0]
def convert_config(config):
original = config.read_text()
output = []
section = ""
server_seen = set()
server_found = False
run_user_seen = False
def append_missing_server_settings():
for key, value in SERVER_SETTINGS.items():
if key not in server_seen:
output.append(f"{key} = {value}\n")
for line in original.splitlines(keepends=True):
match = re.match(r"^\s*\[([^]]+)\]\s*$", line)
if match:
if not run_user_seen:
output.append("RUN_USER = gitea\n")
run_user_seen = True
if section == "server":
append_missing_server_settings()
section = match.group(1).lower()
server_found |= section == "server"
output.append(line)
continue
setting = re.match(r"^(\s*)([A-Z_]+)(\s*=\s*)(.*?)(\r?\n?)$", line)
if setting and section == "" and setting.group(2) == "RUN_USER":
run_user_seen = True
line = f"{setting.group(1)}RUN_USER{setting.group(3)}gitea{setting.group(5)}"
elif setting and section == "server" and setting.group(2) in SERVER_SETTINGS:
key = setting.group(2)
server_seen.add(key)
line = f"{setting.group(1)}{key}{setting.group(3)}{SERVER_SETTINGS[key]}{setting.group(5)}"
else:
line = line.replace("/data/", "/var/lib/gitea/")
output.append(line)
if section == "server":
append_missing_server_settings()
if not server_found:
raise ValueError("Gitea server configuration missing")
config.write_text("".join(output))
config.chmod(0o600)
def extract_gitea(tar_path, staged_data):
count = 0
with tarfile.open(tar_path, mode="r") as archive:
for member in archive:
name = PurePosixPath(member.name)
if name == SOURCE_PREFIX:
continue
if SOURCE_PREFIX not in name.parents:
continue
relative = name.relative_to(SOURCE_PREFIX)
if not relative.parts or any(part in (".", "..") for part in relative.parts):
raise ValueError("Unsafe Gitea backup path")
if not (member.isdir() or member.isfile()):
raise ValueError("Unexpected Gitea backup member type")
destination = staged_data.joinpath(*relative.parts)
if member.isdir():
destination.mkdir(parents=True, exist_ok=True)
destination.chmod(0o700)
continue
destination.parent.mkdir(parents=True, exist_ok=True)
with archive.extractfile(member) as source, destination.open("xb") as target:
shutil.copyfileobj(source, target)
destination.chmod(member.mode & 0o777)
count += 1
if count == 0:
raise ValueError("No Gitea files in backup")
def validate(staged_data, staged_config):
database = staged_data / "gitea/gitea.db"
repositories = staged_data / "git/repositories"
if not database.is_file() or not repositories.is_dir():
raise ValueError("Missing SQLite database or Git repositories")
with sqlite3.connect(f"file:{database}?mode=ro", uri=True) as connection:
if connection.execute("PRAGMA quick_check").fetchone()[0] != "ok":
raise ValueError("Gitea SQLite quick_check failed")
if connection.execute("SELECT count(*) FROM repository").fetchone()[0] < 1:
raise ValueError("Gitea backup contains no repository records")
if not any(repositories.rglob("*.git")):
raise ValueError("Gitea backup contains no Git repository directories")
if not (staged_config / "app.ini").is_file():
raise ValueError("Gitea app.ini missing")
for name in HOST_KEYS:
if not (staged_data / "ssh" / name).is_file():
raise ValueError("Gitea SSH host key missing")
def chown_tree(root, uid, gid):
for directory, dirs, files in os.walk(root):
os.chown(directory, uid, gid)
for name in dirs + files:
os.chown(os.path.join(directory, name), uid, gid)
def replace_rehearsal(target, stage, digest, uid, gid):
previous_data = target / ".previous-rehearsal-data"
previous_config = target / ".previous-rehearsal-config"
if previous_data.exists() or previous_config.exists():
raise ValueError("An interrupted Gitea replacement needs manual recovery")
os.rename(target / "data", previous_data)
try:
os.rename(target / "config", previous_config)
os.rename(stage / "data", target / "data")
os.rename(stage / "config", target / "config")
final_marker = target / ".final-sha256"
final_marker.write_text(digest + "\n")
final_marker.chmod(0o600)
os.chown(final_marker, uid, gid)
(target / ".rehearsal-sha256").unlink()
except Exception:
for name, previous in (("data", previous_data), ("config", previous_config)):
current = target / name
if previous.exists():
if current.exists():
shutil.rmtree(current)
os.rename(previous, current)
(target / ".final-sha256").unlink(missing_ok=True)
raise
shutil.rmtree(previous_data)
shutil.rmtree(previous_config)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--backup", type=Path, required=True)
parser.add_argument("--target", type=Path, required=True)
parser.add_argument("--uid", type=int, required=True)
parser.add_argument("--gid", type=int, required=True)
parser.add_argument("--replace-rehearsal", action="store_true")
args = parser.parse_args()
backup = args.backup.resolve(strict=True)
target = args.target.resolve(strict=True)
if not str(backup).startswith("/zpool/backup/hosts/prometheus/snapshots/"):
raise ValueError("Refusing backup outside the Atlas Prometheus snapshots")
if str(target) != "/zpool/services/data/gitea":
raise ValueError("Refusing target outside the dedicated Gitea dataset")
if args.uid != 1000 or args.gid != 1000:
raise ValueError("Unexpected admin-owned Gitea account IDs")
expected = expected_digest(backup)
if sha256(backup / "payload.tar") != expected:
raise ValueError("Prometheus backup SHA-256 mismatch")
marker = target / (".final-sha256" if args.replace_rehearsal else ".rehearsal-sha256")
if marker.exists():
if marker.read_text().strip() != expected:
raise ValueError("A different Gitea restore already occupies this dataset")
validate(target / "data", target / "config")
print("unchanged")
return
if args.replace_rehearsal:
metadata = json.loads((backup / "metadata.json").read_text())
if metadata.get("purpose") != "gitea-cutover":
raise ValueError("Final restore requires an explicit Gitea cutover export")
if not (target / ".rehearsal-sha256").is_file():
raise ValueError("Only a marked rehearsal may be replaced")
if not all((target / name).is_dir() for name in ("data", "config")):
raise ValueError("Prepared Gitea volume paths are missing")
else:
if (target / ".final-sha256").exists():
raise ValueError("Refusing a rehearsal restore over final Gitea data")
for name in ("data", "config"):
directory = target / name
if not directory.is_dir() or any(directory.iterdir()):
raise ValueError("Gitea target is not empty; refusing overwrite")
with tempfile.TemporaryDirectory(prefix=".rehearsal-", dir=target) as temporary:
stage = Path(temporary)
staged_data = stage / "data"
staged_config = stage / "config"
staged_data.mkdir()
staged_config.mkdir()
extract_gitea(backup / "payload.tar", staged_data)
source_config = staged_data / "gitea/conf/app.ini"
if not source_config.is_file():
raise ValueError("Source Gitea app.ini missing")
shutil.copy2(source_config, staged_config / "app.ini")
source_config.unlink()
convert_config(staged_config / "app.ini")
validate(staged_data, staged_config)
chown_tree(stage, args.uid, args.gid)
if args.replace_rehearsal:
replace_rehearsal(target, stage, expected, args.uid, args.gid)
else:
for name in ("data", "config"):
(target / name).rmdir()
os.rename(stage / name, target / name)
marker.write_text(expected + "\n")
marker.chmod(0o600)
os.chown(marker, args.uid, args.gid)
print("restored")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,396 @@
#!/usr/bin/python3
"""Read-only Atlas health probes with deduplicated 45Drives Alerts."""
import argparse
import fcntl
import json
import os
import re
import subprocess
import sys
import tempfile
import time
from datetime import datetime, timezone
from pathlib import Path
CONFIG_PATH = Path("/etc/atlas-health-monitor.json")
STATE_DIR = Path("/var/lib/atlas-health-monitor")
STATE_PATH = STATE_DIR / "state.json"
GIB = 1024**3
def run(*argv, timeout=40):
return subprocess.run(argv, capture_output=True, text=True, timeout=timeout, check=False)
def issue(issues, key, severity, message):
issues[key] = {"severity": severity, "message": message}
def notify(config, event, severity, subject, message):
now = datetime.now(timezone.utc)
payload = {
"timestamp": now.isoformat(timespec="seconds"),
"unixtime": int(now.timestamp()),
"event": event,
"severity": severity,
"subject": subject,
"email_message": message,
}
result = run(config["notifier"], json.dumps(payload, ensure_ascii=False), timeout=30)
if result.returncode:
raise RuntimeError(f"45Drives notifier exited {result.returncode}: {result.stderr.strip()}")
def parse_fields(text):
return dict(line.split("=", 1) for line in text.splitlines() if "=" in line)
def systemd_fields(unit, *properties):
result = run("systemctl", "show", unit, *(f"-p{item}" for item in properties))
if result.returncode:
raise RuntimeError(f"systemctl show {unit} exited {result.returncode}")
return parse_fields(result.stdout)
def unix_time(text):
if not text or text == "n/a":
return None
result = run("date", "-d", text, "+%s")
if result.returncode:
raise ValueError(f"Cannot parse systemd timestamp: {text}")
return int(result.stdout.strip())
def check_pool(config, issues, measurements):
pool = config["pool"]
listing = run("zpool", "list", "-H", "-p", "-o", "size,alloc,capacity,health", pool)
if listing.returncode:
issue(issues, "pool.probe", "critical", f"Cannot query ZFS pool {pool}")
return
try:
size, alloc, capacity, health = listing.stdout.strip().split("\t")
size, alloc, capacity = int(size), int(alloc), int(capacity)
except (ValueError, TypeError):
issue(issues, "pool.probe", "critical", "Invalid ZFS pool capacity response")
return
measurements.update(pool_size_bytes=size, pool_alloc_bytes=alloc, pool_capacity_percent=capacity)
if health != "ONLINE":
issue(issues, "pool.health", "critical", f"ZFS pool {pool} state is {health}")
if capacity >= config["pool_critical_percent"]:
issue(issues, "pool.capacity", "critical", f"ZFS pool {pool} is {capacity}% full")
elif capacity >= config["pool_warning_percent"]:
issue(issues, "pool.capacity", "warning", f"ZFS pool {pool} is {capacity}% full")
status = run("zpool", "status", "-P", pool)
if status.returncode:
issue(issues, "pool.status", "critical", f"Cannot query detailed ZFS status for {pool}")
return
bad_vdevs = []
for line in status.stdout.splitlines():
match = re.match(r"^\s*(\S+)\s+(ONLINE|DEGRADED|FAULTED|OFFLINE|UNAVAIL|REMOVED)\s+(\d+)\s+(\d+)\s+(\d+)", line)
if match:
name, state, reads, writes, checksums = match.groups()
if state != "ONLINE" or any(int(value) for value in (reads, writes, checksums)):
bad_vdevs.append(f"{name}: {state}, READ={reads}, WRITE={writes}, CKSUM={checksums}")
if bad_vdevs:
issue(issues, "pool.vdevs", "critical", "ZFS vdev errors: " + "; ".join(bad_vdevs))
errors = re.search(r"^errors:\s*(.*)$", status.stdout, re.MULTILINE)
if not errors or errors.group(1).strip() != "No known data errors":
issue(issues, "pool.data_errors", "critical", "ZFS status reports data errors; inspect zpool status -v")
if re.search(r"^\s*scan:\s*resilver in progress", status.stdout, re.MULTILINE | re.IGNORECASE):
issue(issues, "pool.resilver", "warning", "ZFS resilver is in progress; inspect zpool status")
scan = re.search(r"^\s*scan:\s*(.*)$", status.stdout, re.MULTILINE)
if scan and re.search(r"\bwith [1-9][0-9]* errors\b", scan.group(1)):
issue(issues, "pool.scan_errors", "critical", f"ZFS scan reported errors: {scan.group(1)}")
def check_capacity(config, issues, measurements):
pool = config["pool"]
listing = run("zfs", "list", "-H", "-p", "-o", "name,usedbysnapshots", "-r", pool)
if listing.returncode:
issue(issues, "snapshot.probe", "warning", "Cannot query ZFS snapshot space")
else:
try:
snapshots = sum(int(line.split("\t")[1]) for line in listing.stdout.splitlines())
measurements["snapshots_bytes"] = snapshots
size = measurements.get("pool_size_bytes")
if size:
percent = snapshots * 100 // size
measurements["snapshots_percent"] = percent
if percent >= config["snapshot_critical_percent"]:
issue(issues, "snapshot.capacity", "critical", f"Snapshots use {percent}% of pool size")
elif percent >= config["snapshot_warning_percent"]:
issue(issues, "snapshot.capacity", "warning", f"Snapshots use {percent}% of pool size")
except (ValueError, IndexError):
issue(issues, "snapshot.probe", "warning", "Invalid ZFS snapshot-space response")
backup = run("zfs", "list", "-H", "-p", "-o", "used", config["backup_dataset"])
if backup.returncode:
issue(issues, "backup.capacity_probe", "warning", "Cannot query local backup dataset space")
else:
try:
measurements["backup_bytes"] = int(backup.stdout.strip())
except ValueError:
issue(issues, "backup.capacity_probe", "warning", "Invalid local backup space response")
try:
filesystem = os.statvfs("/")
total = filesystem.f_blocks * filesystem.f_frsize
available = filesystem.f_bavail * filesystem.f_frsize
used_percent = (total - available) * 100 // total
measurements["root_capacity_percent"] = used_percent
if used_percent >= config["root_critical_percent"]:
issue(issues, "root.capacity", "critical", f"Atlas system filesystem is {used_percent}% full")
elif used_percent >= config["root_warning_percent"]:
issue(issues, "root.capacity", "warning", f"Atlas system filesystem is {used_percent}% full")
except (OSError, ZeroDivisionError):
issue(issues, "root.capacity_probe", "warning", "Cannot query Atlas system filesystem space")
def check_remote_capacity(config, issues, measurements):
"""Query only the Storage Box quota; do not open or inspect the Borg repository."""
remote = config["remote_capacity"]
try:
result = run("runuser", "-u", remote["run_as"], "--", remote["ssh_wrapper"],
f"{remote['user']}@{remote['host']}", "df", "-m", timeout=65)
if result.returncode:
raise ValueError(f"SSH df exited {result.returncode}")
lines = result.stdout.strip().splitlines()
if len(lines) != 2:
raise ValueError("Unexpected Storage Box df output")
fields = lines[1].split()
if len(fields) < 5:
raise ValueError("Incomplete Storage Box df output")
total_mib, used_mib, available_mib = (int(value) for value in fields[1:4])
percent = int(fields[4].rstrip("%"))
if total_mib <= 0 or not 0 <= percent <= 100 or available_mib < 0:
raise ValueError("Invalid Storage Box quota values")
except (OSError, ValueError, subprocess.TimeoutExpired):
issue(issues, "remote.capacity_probe", "warning", "Cannot query Hetzner Storage Box quota via pinned-key SSH")
return
measurements.update(remote_capacity_percent=percent, remote_bytes=used_mib * 1024**2,
remote_available_bytes=available_mib * 1024**2)
if percent >= remote["critical_percent"]:
issue(issues, "remote.capacity", "critical", f"Hetzner Storage Box quota is {percent}% full")
elif percent >= remote["warning_percent"]:
issue(issues, "remote.capacity", "warning", f"Hetzner Storage Box quota is {percent}% full")
def check_smart(config, issues, measurements):
for device in config["smart_devices"]:
name, path = device["name"], device["path"]
try:
result = run("smartctl", "-j", "-a", path, timeout=60)
data = json.loads(result.stdout)
status = int(data.get("smartctl", {}).get("exit_status", result.returncode))
except (subprocess.TimeoutExpired, json.JSONDecodeError, ValueError) as exc:
issue(issues, f"smart.{name}.probe", "critical", f"SMART probe failed for {name}: {type(exc).__name__}")
continue
if status:
severity = "critical" if status & 0b00001111 else "warning"
issue(issues, f"smart.{name}.status", severity, f"SMART reported exit status {status} for {name}")
passed = data.get("smart_status", {}).get("passed")
if passed is False:
issue(issues, f"smart.{name}.health", "critical", f"SMART self-assessment failed for {name}")
elif passed is None:
issue(issues, f"smart.{name}.health", "warning", f"SMART self-assessment unavailable for {name}")
temperature = data.get("temperature", {}).get("current")
if isinstance(temperature, (int, float)):
measurements[f"smart_{name}_c"] = temperature
if temperature >= device["critical_c"]:
issue(issues, f"smart.{name}.temperature", "critical", f"{name} temperature is {temperature} C")
elif temperature >= device["warning_c"]:
issue(issues, f"smart.{name}.temperature", "warning", f"{name} temperature is {temperature} C")
else:
issue(issues, f"smart.{name}.temperature", "warning", f"Temperature unavailable for {name}")
for attribute in data.get("ata_smart_attributes", {}).get("table", []):
attribute_id = attribute.get("id")
if attribute_id in (5, 187, 197, 198):
raw = attribute.get("raw", {}).get("value", 0)
if isinstance(raw, int) and raw > 0:
severity = "critical" if attribute_id in (197, 198) else "warning"
issue(issues, f"smart.{name}.ata_{attribute_id}", severity,
f"{name} SMART attribute {attribute_id} raw count is {raw}")
nvme = data.get("nvme_smart_health_information_log", {})
if isinstance(nvme, dict):
if int(nvme.get("critical_warning", 0)):
issue(issues, f"smart.{name}.nvme_warning", "critical", f"{name} NVMe critical warning is nonzero")
if int(nvme.get("media_errors", 0)):
issue(issues, f"smart.{name}.nvme_media", "critical", f"{name} NVMe media errors are nonzero")
def check_cpu(config, issues, measurements):
sensors = []
for hwmon in Path("/sys/class/hwmon").glob("hwmon*"):
try:
if (hwmon / "name").read_text().strip() != "coretemp":
continue
sensors.extend(int(path.read_text().strip()) / 1000 for path in hwmon.glob("temp*_input"))
except (OSError, ValueError):
continue
if not sensors:
issue(issues, "cpu.temperature_probe", "warning", "CPU temperature sensors are unavailable")
return
hottest = max(sensors)
measurements["cpu_max_c"] = hottest
if hottest >= config["cpu_critical_c"]:
issue(issues, "cpu.temperature", "critical", f"CPU temperature is {hottest:g} C")
elif hottest >= config["cpu_warning_c"]:
issue(issues, "cpu.temperature", "warning", f"CPU temperature is {hottest:g} C")
def check_jobs(config, issues, measurements, now):
for timer in config["timers"]:
name = timer["name"]
try:
fields = systemd_fields(name, "ActiveState", "UnitFileState", "LastTriggerUSec", "ActiveEnterTimestamp")
if fields.get("ActiveState") != "active" or fields.get("UnitFileState") != "enabled":
issue(issues, f"timer.{name}", "critical", f"Timer {name} is not active and enabled")
max_age = int(timer["max_age_hours"]) * 3600
if max_age:
last = unix_time(fields.get("LastTriggerUSec"))
if last is None:
last = unix_time(fields.get("ActiveEnterTimestamp"))
if last is not None and now - last > max_age:
issue(issues, f"timer.{name}.stale", "warning",
f"Timer {name} has not fired in {int((now-last)/3600)} hours")
except (RuntimeError, ValueError, subprocess.TimeoutExpired):
issue(issues, f"timer.{name}.probe", "warning", f"Cannot query timer {name}")
for unit in config["failure_units"]:
if unit.endswith("@.service"):
continue
try:
fields = systemd_fields(unit, "ActiveState", "Result", "ExecMainStartTimestamp")
state = fields.get("ActiveState")
if state == "failed" or (state == "inactive" and fields.get("Result") not in (None, "", "success")):
issue(issues, f"service.{unit}", "critical", f"Service {unit} failed: {fields.get('Result')}")
if unit == "atlas-borg-backup.service" and fields.get("ActiveState") == "activating":
started = unix_time(fields.get("ExecMainStartTimestamp"))
if started is not None and now - started > config["borg_max_runtime_days"] * 86400:
issue(issues, "backup.borg_long_running", "warning",
"Borg has run longer than its configured limit")
except (RuntimeError, ValueError, subprocess.TimeoutExpired):
issue(issues, f"service.{unit}.probe", "warning", f"Cannot query service {unit}")
def check_growth(config, issues, measurements, samples, now):
previous = [sample for sample in samples if 20 * 3600 <= now - sample.get("time", now) <= 48 * 3600]
if previous:
baseline = min(previous, key=lambda sample: abs(now - sample["time"] - 86400))
days = (now - baseline["time"]) / 86400
for name, threshold in (("snapshots", config["snapshot_growth_warning_gib_day"]),
("backup", config["backup_growth_warning_gib_day"]),
("remote", config["remote_capacity"]["growth_warning_gib_day"])):
current, old = measurements.get(f"{name}_bytes"), baseline.get(f"{name}_bytes")
if isinstance(current, int) and isinstance(old, int) and days > 0:
growth_gib_day = (current - old) / GIB / days
measurements[f"{name}_growth_gib_day"] = round(growth_gib_day, 1)
if growth_gib_day >= threshold:
issue(issues, f"{name}.growth", "warning",
f"Local {name} usage grew {growth_gib_day:.1f} GiB/day over {days:.1f} days")
def allowed_failure_unit(config, unit):
for allowed in config["failure_units"]:
if allowed == unit:
return True
if allowed.endswith("@.service") and unit.startswith(allowed[:-9] + "@") and unit.endswith(".service"):
return True
return False
def load_state():
if not STATE_PATH.exists():
return {"active": {}, "samples": []}
with STATE_PATH.open(encoding="utf-8") as stream:
state = json.load(stream)
if not isinstance(state.get("active"), dict) or not isinstance(state.get("samples"), list):
raise ValueError("Invalid Atlas monitor state; refusing to overwrite it")
return state
def save_state(state):
with tempfile.NamedTemporaryFile("w", dir=STATE_DIR, prefix=".state-", delete=False,
encoding="utf-8") as stream:
path = Path(stream.name)
os.chmod(path, 0o600)
json.dump(state, stream, sort_keys=True)
stream.write("\n")
stream.flush()
os.fsync(stream.fileno())
os.replace(path, STATE_PATH)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--dry-run", action="store_true", help="probe without notifications or state changes")
parser.add_argument("--test-notification", action="store_true", help="submit a labelled test alert")
parser.add_argument("--job-failed", metavar="UNIT", help="notify about a failed configured service")
args = parser.parse_args()
with CONFIG_PATH.open(encoding="utf-8") as stream:
config = json.load(stream)
if args.test_notification:
notify(config, "atlas_monitor_test", "warning", "Test monitoraggio Atlas",
"Notifica di prova: il monitoraggio Atlas raggiunge 45Drives Alerts. Non conferma l'invio email.")
print("Atlas monitor test submitted to 45Drives Alerts; email delivery is not verified.")
return 0
if args.job_failed:
if not allowed_failure_unit(config, args.job_failed):
raise ValueError("Unconfigured Atlas failure unit")
notify(config, "atlas_job_failed", "critical", f"Job Atlas fallito: {args.job_failed}",
f"Il servizio {args.job_failed} e' fallito. Controlla: "
f"sudo journalctl -u {args.job_failed} -n 100 --no-pager")
print(f"Atlas job failure submitted to 45Drives Alerts: {args.job_failed}")
return 0
now = int(time.time())
issues, measurements = {}, {}
check_pool(config, issues, measurements)
check_capacity(config, issues, measurements)
check_remote_capacity(config, issues, measurements)
check_smart(config, issues, measurements)
check_cpu(config, issues, measurements)
check_jobs(config, issues, measurements, now)
if args.dry_run:
print(json.dumps({"issues": issues, "measurements": measurements}, sort_keys=True))
return 0
STATE_DIR.mkdir(mode=0o700, exist_ok=True)
with (STATE_DIR / "monitor.lock").open("w") as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
state = load_state()
check_growth(config, issues, measurements, state["samples"], now)
active, failed_notifications = state["active"], []
for key, details in issues.items():
old = active.get(key)
if old is None or old.get("severity") != details["severity"]:
try:
notify(config, "atlas_health_issue", details["severity"],
f"Atlas: {key}", details["message"])
active[key] = details
print(f"ALERT {details['severity']} {key}: {details['message']}", flush=True)
except (RuntimeError, subprocess.TimeoutExpired) as exc:
failed_notifications.append(key)
print(f"NOTIFICATION FAILED {key}: {exc}", file=sys.stderr, flush=True)
for key in set(active) - set(issues):
print(f"RECOVERED {key}", flush=True)
del active[key]
state["samples"] = [sample for sample in state["samples"] if now - sample.get("time", 0) < 48 * 3600]
state["samples"].append({"time": now, **{key: value for key, value in measurements.items()
if key in ("snapshots_bytes", "backup_bytes", "remote_bytes")}})
save_state(state)
print(f"Atlas health: issues={len(issues)} notifications_failed={len(failed_notifications)} "
f"pool={measurements.get('pool_capacity_percent', 'unknown')}% "
f"remote={measurements.get('remote_capacity_percent', 'unknown')}% "
f"snapshots={measurements.get('snapshots_bytes', 'unknown')} bytes "
f"backup={measurements.get('backup_bytes', 'unknown')} bytes", flush=True)
return 1 if failed_notifications else 0
if __name__ == "__main__":
try:
sys.exit(main())
except (OSError, RuntimeError, ValueError, subprocess.TimeoutExpired) as error:
print(f"Atlas health monitor failed: {error}", file=sys.stderr)
sys.exit(1)

View File

@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Prune only verified, named Prometheus backup versions after publication."""
import datetime as dt
import pathlib
import re
import shutil
import sys
def main() -> None:
if len(sys.argv) != 5:
raise SystemExit("Usage: atlas-prometheus-prune SNAPSHOTS DAILY WEEKLY MONTHLY")
root = pathlib.Path(sys.argv[1])
counts = [int(value) for value in sys.argv[2:]]
if not root.is_dir() or root.is_symlink() or min(counts) < 1:
raise SystemExit("Invalid backup directory or retention counts")
versions = []
for entry in root.iterdir():
if not entry.is_dir() or entry.is_symlink():
continue
if not re.fullmatch(r"[0-9]{8}T[0-9]{6}Z", entry.name):
continue
try:
when = dt.datetime.strptime(entry.name, "%Y%m%dT%H%M%SZ")
except ValueError:
continue
if not all((entry / name).is_file() for name in ("payload.tar", "payload.sha256", "metadata.json")):
continue
versions.append((when, entry))
versions.sort(reverse=True)
if not versions:
raise SystemExit("No published backup versions found; refusing to prune")
keep = {entry for _, entry in versions[: counts[0]]}
for count, key in (
(counts[1], lambda when: when.isocalendar()[:2]),
(counts[2], lambda when: (when.year, when.month)),
):
periods = set()
for when, entry in versions:
period = key(when)
if period in periods:
continue
periods.add(period)
keep.add(entry)
if len(periods) >= count:
break
for _, entry in versions:
if entry not in keep:
shutil.rmtree(entry)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,63 @@
---
- name: Reload Atlas admin user manager
become_user: "{{ atlas_admin_username }}"
ansible.builtin.systemd:
scope: user
daemon_reload: true
environment:
XDG_RUNTIME_DIR: "/run/user/{{ atlas_admin_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ atlas_admin_uid }}/bus"
when: not ansible_check_mode
- name: Reload SSH service
ansible.builtin.systemd:
name: sshd
state: reloaded
- name: Reload NFS exports
ansible.builtin.command:
argv:
- exportfs
- -ra
changed_when: true
when: not ansible_check_mode
- name: Restart NFS server
ansible.builtin.systemd:
name: nfs-server
state: restarted
- name: Restart NFS mount daemon
ansible.builtin.systemd:
name: nfs-mountd
state: restarted
daemon_reload: true
- name: Restart Samba service
ansible.builtin.systemd:
name: smb
state: restarted
- name: Restart Atlas Borg timers
ansible.builtin.systemd:
name: "{{ item }}"
state: restarted
daemon_reload: true
loop:
- atlas-borg-backup.timer
- atlas-borg-check.timer
when:
- atlas_manage_borg_backup | bool
- not ansible_check_mode
- name: Restart Atlas media Quadlets
ansible.builtin.systemd:
name: "{{ item }}"
state: restarted
daemon_reload: true
loop: "{{ atlas_media_quadlet_services }}"
loop_control:
label: "{{ item }}"
when:
- atlas_manage_media_stack | bool
- not ansible_check_mode

View File

@@ -0,0 +1,180 @@
---
- name: Reject incomplete Atlas account configuration
tags: [atlas, services]
ansible.builtin.assert:
that:
- atlas_admin_username != 'CHANGEME_ATLAS_ADMIN'
- (atlas_admin_ssh_keys | default([])) | length > 0
- atlas_admin_password_hash not in ['', '!', '*']
- atlas_ssh_allow_tcp_forwarding in ['no', 'yes', 'local', 'remote', 'all']
- "'wheel' not in atlas_immich_supplementary_groups"
fail_msg: >-
Define atlas_admin_username, atlas_admin_ssh_keys and vault_atlas_admin_password_hash
before applying the Atlas profile.
no_log: true
- name: Create Atlas administrator group
tags: [atlas, services]
ansible.builtin.group:
name: "{{ atlas_admin_group }}"
gid: "{{ atlas_admin_gid }}"
state: present
- name: Create Atlas administrator account
tags: [atlas, services]
ansible.builtin.user:
name: "{{ atlas_admin_username }}"
uid: "{{ atlas_admin_uid }}"
group: "{{ atlas_admin_group }}"
home: "{{ atlas_admin_home }}"
shell: /bin/bash
password: "{{ atlas_admin_password_hash }}"
create_home: true
state: present
no_log: true
- name: Create Immich primary group
tags: [atlas, accounts, containers, immich]
ansible.builtin.group:
name: "{{ atlas_immich_group }}"
gid: "{{ atlas_immich_gid }}"
state: present
- name: Ensure Immich hardware-access groups exist
tags: [atlas, accounts, containers, immich]
ansible.builtin.group:
name: "{{ item }}"
state: present
loop: "{{ atlas_immich_supplementary_groups }}"
loop_control:
label: "{{ item }}"
- name: Create unprivileged Immich account
tags: [atlas, accounts, containers, immich]
ansible.builtin.user:
name: "{{ atlas_immich_username }}"
uid: "{{ atlas_immich_uid }}"
group: "{{ atlas_immich_group }}"
groups: "{{ atlas_immich_supplementary_groups }}"
append: false
home: /nonexistent
create_home: false
shell: /sbin/nologin
system: true
state: present
- name: Read Immich hardware-access group IDs
tags: [atlas, accounts, containers, immich]
ansible.builtin.getent:
database: group
- name: Grant Atlas administrator passwordless sudo
tags: [atlas, services]
ansible.builtin.copy:
content: "{{ atlas_admin_username }} ALL=(ALL) NOPASSWD: ALL\n"
dest: "/etc/sudoers.d/{{ atlas_admin_username }}"
owner: root
group: root
mode: "0440"
validate: "visudo -cf %s"
- name: Ensure Atlas administrator SSH authorized key fragments directory exists
tags: [atlas, services]
ansible.builtin.file:
path: "{{ atlas_admin_ssh_key_directory }}"
state: directory
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "0700"
- name: Manage Atlas administrator SSH authorized key fragments
tags: [atlas, services]
ansible.builtin.copy:
content: "{{ item.key }}\n"
dest: "{{ atlas_admin_ssh_key_directory }}/{{ item.name }}"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "0600"
loop: "{{ atlas_admin_ssh_keys }}"
loop_control:
label: "{{ item.name }}"
- name: Check whether the Atlas SSH host key exists
tags: [atlas, services]
ansible.builtin.stat:
path: /etc/ssh/ssh_host_ed25519_key
register: atlas_ssh_host_ed25519_key
- name: Generate missing Atlas SSH host keys
tags: [atlas, services]
ansible.builtin.command:
argv:
- ssh-keygen
- -A
changed_when: true
when: not atlas_ssh_host_ed25519_key.stat.exists
- name: Ensure Atlas SSH configuration drop-in directory exists
tags: [atlas, services]
ansible.builtin.file:
path: /etc/ssh/sshd_config.d
state: directory
owner: root
group: root
mode: "0755"
- name: Ensure Atlas SSH drop-ins are loaded before other settings
tags: [atlas, services]
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^\s*Include\s+/etc/ssh/sshd_config\.d/\*\.conf\s*$'
line: Include /etc/ssh/sshd_config.d/*.conf
insertbefore: BOF
state: present
validate: "sshd -t -f %s"
- name: Render Atlas SSH hardening drop-in
tags: [atlas, services]
ansible.builtin.template:
src: 00-atlas-hardening.conf.j2
dest: /etc/ssh/sshd_config.d/00-atlas-hardening.conf
owner: root
group: root
mode: "0600"
notify: Reload SSH service
- name: Read effective Atlas SSH daemon configuration
tags: [atlas, services]
ansible.builtin.command:
argv:
- sshd
- -T
- -C
- "user={{ atlas_admin_username }},host=atlas,addr=127.0.0.1"
register: atlas_sshd_effective_configuration
changed_when: false
when: not ansible_check_mode
- name: Verify effective Atlas SSH hardening
tags: [atlas, services]
ansible.builtin.assert:
that:
- "'permitrootlogin no' in atlas_sshd_effective_configuration.stdout_lines"
- "'pubkeyauthentication yes' in atlas_sshd_effective_configuration.stdout_lines"
- "'passwordauthentication no' in atlas_sshd_effective_configuration.stdout_lines"
- "'kbdinteractiveauthentication no' in atlas_sshd_effective_configuration.stdout_lines"
- "'authenticationmethods publickey' in atlas_sshd_effective_configuration.stdout_lines"
- "'x11forwarding no' in atlas_sshd_effective_configuration.stdout_lines"
- "('allowtcpforwarding ' ~ atlas_ssh_allow_tcp_forwarding) in atlas_sshd_effective_configuration.stdout_lines"
- "'allowagentforwarding no' in atlas_sshd_effective_configuration.stdout_lines"
- "'gatewayports no' in atlas_sshd_effective_configuration.stdout_lines"
- "'permittunnel no' in atlas_sshd_effective_configuration.stdout_lines"
- "'permituserenvironment no' in atlas_sshd_effective_configuration.stdout_lines"
- "'maxauthtries 3' in atlas_sshd_effective_configuration.stdout_lines"
- "'logingracetime 30' in atlas_sshd_effective_configuration.stdout_lines"
- "'clientaliveinterval 300' in atlas_sshd_effective_configuration.stdout_lines"
- "'clientalivecountmax 2' in atlas_sshd_effective_configuration.stdout_lines"
- "'loglevel VERBOSE' in atlas_sshd_effective_configuration.stdout_lines"
- "('allowusers ' ~ atlas_admin_username) in atlas_sshd_effective_configuration.stdout_lines"
fail_msg: The effective Atlas SSH configuration does not match the required hardening.
when: not ansible_check_mode

View File

@@ -0,0 +1,538 @@
---
- name: Validate Atlas Borg backup configuration
tags: [atlas, storage, backup, borg, borg_key]
ansible.builtin.assert:
that:
- atlas_manage_storage | bool
- atlas_zfs_pool != 'CHANGEME_ZFS_POOL'
- atlas_mount_root.startswith('/')
- atlas_borg_username is match('^[a-z_][a-z0-9_-]*$')
- atlas_borg_group is match('^[a-z_][a-z0-9_-]*$')
- atlas_borg_username not in ['root', atlas_admin_username]
- atlas_borg_group != 'wheel'
- atlas_borg_home.startswith('/var/lib/')
- atlas_borg_repository_host is match('^[A-Za-z0-9.-]+$')
- atlas_borg_repository_user is match('^[A-Za-z0-9_-]+$')
- atlas_borg_repository_port | int > 0
- atlas_borg_repository_port | int < 65536
- atlas_borg_repository_path is match('^\./[A-Za-z0-9][A-Za-z0-9._/-]*$')
- "'/../' not in ('/' ~ atlas_borg_repository_path ~ '/')"
- atlas_borg_remote_path is match('^borg-[0-9]+\.[0-9]+$')
- atlas_borg_host_key.startswith(
'[' ~ atlas_borg_repository_host ~ ']:' ~ (atlas_borg_repository_port | string) ~ ' ssh-ed25519 '
)
- atlas_borg_ssh_private_key_path.startswith('/etc/atlas-borg/')
- atlas_borg_known_hosts_path.startswith('/etc/atlas-borg/')
- atlas_borg_passphrase_path.startswith('/etc/atlas-borg/')
- atlas_borg_ssh_wrapper_path.startswith('/usr/local/libexec/')
- atlas_borg_encryption_mode == 'repokey'
- atlas_borg_archive_prefix is match('^[a-z0-9][a-z0-9_-]*$')
- atlas_borg_snapshot_prefix is match('^[a-z0-9][a-z0-9_-]*$')
- atlas_borg_keep_daily | int > 0
- atlas_borg_keep_weekly | int > 0
- atlas_borg_keep_monthly | int > 0
fail_msg: >-
Atlas Borg needs a safe relative repository path, a pinned ED25519 host
key, positive retention counts, and valid dedicated SSH settings.
when: atlas_manage_borg_backup | bool
- name: Create the Atlas Borg system group
tags: [atlas, storage, backup, borg, borg_key]
ansible.builtin.group:
name: "{{ atlas_borg_group }}"
system: true
state: present
when: atlas_manage_borg_backup | bool
- name: Create the least-privilege Atlas Borg account
tags: [atlas, storage, backup, borg, borg_key]
ansible.builtin.user:
name: "{{ atlas_borg_username }}"
group: "{{ atlas_borg_group }}"
groups: []
append: false
comment: Atlas Borg backup service
home: "{{ atlas_borg_home }}"
create_home: false
shell: /sbin/nologin
password_lock: true
system: true
state: present
when: atlas_manage_borg_backup | bool
- name: Read Atlas Borg account group membership
tags: [atlas, storage, backup, borg, borg_key]
ansible.builtin.command:
argv:
- id
- -nG
- "{{ atlas_borg_username }}"
register: atlas_borg_account_groups
changed_when: false
check_mode: false
when:
- atlas_manage_borg_backup | bool
- not ansible_check_mode
- name: Require the Atlas Borg account to have no supplementary groups
tags: [atlas, storage, backup, borg, borg_key]
ansible.builtin.assert:
that:
- atlas_borg_account_groups.stdout.split() == [atlas_borg_group]
fail_msg: >-
The Atlas Borg service account must belong only to its private primary
group and must never receive wheel or other supplementary membership.
when:
- atlas_manage_borg_backup | bool
- not ansible_check_mode
- name: Validate Atlas Borg systemd calendars
tags: [atlas, storage, backup, borg]
ansible.builtin.command:
argv:
- systemd-analyze
- calendar
- "{{ item }}"
loop:
- "{{ atlas_borg_backup_calendar }}"
- "{{ atlas_borg_check_calendar }}"
changed_when: false
check_mode: false
when: atlas_manage_borg_backup | bool
- name: Create Atlas Borg configuration directory
tags: [atlas, storage, backup, borg, borg_key]
ansible.builtin.file:
path: /etc/atlas-borg
state: directory
owner: root
group: "{{ atlas_borg_group }}"
mode: "0750"
when: atlas_manage_borg_backup | bool
- name: Generate the dedicated Atlas Borg SSH identity
tags: [atlas, storage, backup, borg, borg_key]
ansible.builtin.command:
argv:
- ssh-keygen
- -q
- -t
- ed25519
- -N
- ""
- -C
- atlas-borg@atlas
- -f
- "{{ atlas_borg_ssh_private_key_path }}"
creates: "{{ atlas_borg_ssh_private_key_path }}"
when: atlas_manage_borg_backup | bool
- name: Protect the Atlas Borg private SSH identity
tags: [atlas, storage, backup, borg, borg_key]
ansible.builtin.file:
path: "{{ atlas_borg_ssh_private_key_path }}"
owner: "{{ atlas_borg_username }}"
group: "{{ atlas_borg_group }}"
mode: "0600"
when:
- atlas_manage_borg_backup | bool
- not ansible_check_mode
- name: Set permissions on the Atlas Borg public SSH identity
tags: [atlas, storage, backup, borg, borg_key]
ansible.builtin.file:
path: "{{ atlas_borg_ssh_private_key_path }}.pub"
owner: "{{ atlas_borg_username }}"
group: "{{ atlas_borg_group }}"
mode: "0644"
when:
- atlas_manage_borg_backup | bool
- not ansible_check_mode
- name: Read the dedicated Atlas Borg public SSH identity
tags: [atlas, storage, backup, borg, borg_key]
ansible.builtin.slurp:
src: "{{ atlas_borg_ssh_private_key_path }}.pub"
register: atlas_borg_public_key
when:
- atlas_manage_borg_backup | bool
- not ansible_check_mode
- name: Report the public SSH identity to install in the Hetzner sub-account
tags: [atlas, storage, backup, borg, borg_key]
ansible.builtin.debug:
msg: "{{ atlas_borg_public_key.content | b64decode | trim }}"
when:
- atlas_manage_borg_backup | bool
- not ansible_check_mode
- name: Pin the Hetzner Storage Box SSH host key
tags: [atlas, storage, backup, borg]
ansible.builtin.copy:
content: "{{ atlas_borg_host_key }}\n"
dest: "{{ atlas_borg_known_hosts_path }}"
owner: "{{ atlas_borg_username }}"
group: "{{ atlas_borg_group }}"
mode: "0600"
when: atlas_manage_borg_backup | bool
- name: Create Atlas Borg state directories
tags: [atlas, storage, backup, borg]
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: "{{ atlas_borg_username }}"
group: "{{ atlas_borg_group }}"
mode: "0700"
loop:
- "{{ atlas_borg_config_dir }}"
- "{{ atlas_borg_cache_dir }}"
when: atlas_manage_borg_backup | bool
- name: Create the shared Atlas Borg operation lock
tags: [atlas, storage, backup, borg]
ansible.builtin.copy:
content: ""
dest: "{{ atlas_borg_lock_path }}"
owner: "{{ atlas_borg_username }}"
group: "{{ atlas_borg_group }}"
mode: "0600"
force: false
when: atlas_manage_borg_backup | bool
- name: Require the Atlas Borg encryption passphrase from Vault
tags: [atlas, storage, backup, borg]
ansible.builtin.assert:
that:
- atlas_borg_passphrase | length >= 20
fail_msg: >-
Define vault_atlas_borg_passphrase with a strong unique value in the
encrypted Vault before activating the Borg repository.
no_log: true
when: atlas_manage_borg_backup | bool
- name: Install the Atlas Borg passphrase
tags: [atlas, storage, backup, borg]
ansible.builtin.copy:
content: "{{ atlas_borg_passphrase }}\n"
dest: "{{ atlas_borg_passphrase_path }}"
owner: "{{ atlas_borg_username }}"
group: "{{ atlas_borg_group }}"
mode: "0600"
diff: false
no_log: true
when: atlas_manage_borg_backup | bool
- name: Install the Atlas Borg backup helper
tags: [atlas, storage, backup, borg, borg_logging]
ansible.builtin.template:
src: atlas-borg-backup.sh.j2
dest: /usr/local/sbin/atlas-borg-backup
owner: root
group: root
mode: "0750"
when: atlas_manage_borg_backup | bool
- name: Install the Atlas Borg snapshot cleanup helper
tags: [atlas, storage, backup, borg, borg_logging]
ansible.builtin.template:
src: atlas-borg-snapshot-cleanup.sh.j2
dest: /usr/local/sbin/atlas-borg-snapshot-cleanup
owner: root
group: root
mode: "0750"
when: atlas_manage_borg_backup | bool
- name: Install the Atlas Borg check helper
tags: [atlas, storage, backup, borg]
ansible.builtin.template:
src: atlas-borg-check.sh.j2
dest: /usr/local/sbin/atlas-borg-check
owner: root
group: "{{ atlas_borg_group }}"
mode: "0750"
when: atlas_manage_borg_backup | bool
- name: Create the local libexec directory for the Atlas Borg SSH wrapper
tags: [atlas, storage, backup, borg, borg_logging]
ansible.builtin.file:
path: "{{ atlas_borg_ssh_wrapper_path | dirname }}"
state: directory
owner: root
group: root
mode: "0755"
when: atlas_manage_borg_backup | bool
- name: Install the Atlas Borg progress formatter
tags: [atlas, storage, backup, borg, borg_logging]
ansible.builtin.copy:
src: atlas-borg-progress.py
dest: /usr/local/libexec/atlas-borg-progress
owner: root
group: root
mode: "0755"
when: atlas_manage_borg_backup | bool
- name: Install the capability-dropping Atlas Borg SSH wrapper
tags: [atlas, storage, backup, borg]
ansible.builtin.template:
src: atlas-borg-ssh.sh.j2
dest: "{{ atlas_borg_ssh_wrapper_path }}"
owner: root
group: root
mode: "0755"
when: atlas_manage_borg_backup | bool
- name: Install Atlas Borg systemd units
tags: [atlas, storage, backup, borg, borg_logging]
ansible.builtin.template:
src: "{{ item }}.j2"
dest: "/etc/systemd/system/{{ item }}"
owner: root
group: root
mode: "0644"
loop:
- atlas-borg-backup.service
- atlas-borg-backup.timer
- atlas-borg-check.service
- atlas-borg-check.timer
notify: Restart Atlas Borg timers
when: atlas_manage_borg_backup | bool
- name: Verify dedicated SSH access to the Hetzner Storage Box
tags: [atlas, storage, backup, borg]
ansible.builtin.command:
argv:
- ssh
- -T
- -i
- "{{ atlas_borg_ssh_private_key_path }}"
- -p
- "{{ atlas_borg_repository_port | string }}"
- -o
- BatchMode=yes
- -o
- IdentitiesOnly=yes
- -o
- StrictHostKeyChecking=yes
- -o
- "UserKnownHostsFile={{ atlas_borg_known_hosts_path }}"
- "{{ atlas_borg_repository_user }}@{{ atlas_borg_repository_host }}"
- pwd
register: atlas_borg_ssh_probe
become: true
become_user: "{{ atlas_borg_username }}"
changed_when: false
failed_when: false
when:
- atlas_manage_borg_backup | bool
- not ansible_check_mode
- name: Require the dedicated public key on the Hetzner sub-account
tags: [atlas, storage, backup, borg]
ansible.builtin.assert:
that:
- atlas_borg_ssh_probe.rc == 0
fail_msg: >-
Install the reported Atlas Borg public key in the Hetzner sub-account
before rerunning the Borg tasks. Password authentication is never used.
when:
- atlas_manage_borg_backup | bool
- not ansible_check_mode
- name: Probe the remote Atlas Borg repository path
tags: [atlas, storage, backup, borg]
ansible.builtin.command:
argv:
- ssh
- -T
- -i
- "{{ atlas_borg_ssh_private_key_path }}"
- -p
- "{{ atlas_borg_repository_port | string }}"
- -o
- BatchMode=yes
- -o
- IdentitiesOnly=yes
- -o
- StrictHostKeyChecking=yes
- -o
- "UserKnownHostsFile={{ atlas_borg_known_hosts_path }}"
- "{{ atlas_borg_repository_user }}@{{ atlas_borg_repository_host }}"
- stat
- "{{ atlas_borg_repository_path }}"
register: atlas_borg_repository_path_probe
become: true
become_user: "{{ atlas_borg_username }}"
changed_when: false
failed_when: false
when:
- atlas_manage_borg_backup | bool
- not ansible_check_mode
- name: Probe the Atlas Borg repository
tags: [atlas, storage, backup, borg]
ansible.builtin.command:
argv:
- /usr/bin/borg
- --remote-path
- "{{ atlas_borg_remote_path }}"
- info
- >-
ssh://{{ atlas_borg_repository_user }}@{{ atlas_borg_repository_host }}:
{{- atlas_borg_repository_port }}/{{ atlas_borg_repository_path }}
environment:
BORG_CACHE_DIR: "{{ atlas_borg_cache_dir }}"
BORG_CONFIG_DIR: "{{ atlas_borg_config_dir }}"
BORG_PASSCOMMAND: "cat {{ atlas_borg_passphrase_path }}"
BORG_RSH: "{{ atlas_borg_ssh_wrapper_path }}"
register: atlas_borg_repository_probe
become: true
become_user: "{{ atlas_borg_username }}"
changed_when: false
failed_when: false
when:
- atlas_manage_borg_backup | bool
- not ansible_check_mode
- atlas_borg_repository_path_probe.rc == 0
- name: Reject an existing path that is not the configured Borg repository
tags: [atlas, storage, backup, borg]
ansible.builtin.assert:
that:
- atlas_borg_repository_probe.rc == 0
fail_msg: >-
The remote repository path already exists but Borg could not open it.
Refusing to initialize over existing data; verify the path, passphrase,
and repository state manually.
when:
- atlas_manage_borg_backup | bool
- not ansible_check_mode
- atlas_borg_repository_path_probe.rc == 0
- name: Initialize the encrypted Atlas Borg repository
tags: [atlas, storage, backup, borg]
ansible.builtin.command:
argv:
- /usr/bin/borg
- --remote-path
- "{{ atlas_borg_remote_path }}"
- init
- --encryption
- "{{ atlas_borg_encryption_mode }}"
- >-
ssh://{{ atlas_borg_repository_user }}@{{ atlas_borg_repository_host }}:
{{- atlas_borg_repository_port }}/{{ atlas_borg_repository_path }}
environment:
BORG_CACHE_DIR: "{{ atlas_borg_cache_dir }}"
BORG_CONFIG_DIR: "{{ atlas_borg_config_dir }}"
BORG_PASSCOMMAND: "cat {{ atlas_borg_passphrase_path }}"
BORG_RSH: "{{ atlas_borg_ssh_wrapper_path }}"
become: true
become_user: "{{ atlas_borg_username }}"
when:
- atlas_manage_borg_backup | bool
- not ansible_check_mode
- atlas_borg_repository_path_probe.rc != 0
- name: Verify the encrypted Atlas Borg repository
tags: [atlas, storage, backup, borg]
ansible.builtin.command:
argv:
- /usr/bin/borg
- --remote-path
- "{{ atlas_borg_remote_path }}"
- info
- >-
ssh://{{ atlas_borg_repository_user }}@{{ atlas_borg_repository_host }}:
{{- atlas_borg_repository_port }}/{{ atlas_borg_repository_path }}
environment:
BORG_CACHE_DIR: "{{ atlas_borg_cache_dir }}"
BORG_CONFIG_DIR: "{{ atlas_borg_config_dir }}"
BORG_PASSCOMMAND: "cat {{ atlas_borg_passphrase_path }}"
BORG_RSH: "{{ atlas_borg_ssh_wrapper_path }}"
become: true
become_user: "{{ atlas_borg_username }}"
changed_when: false
when:
- atlas_manage_borg_backup | bool
- not ansible_check_mode
- name: Check for the local Atlas Borg recovery-key export
tags: [atlas, storage, backup, borg]
ansible.builtin.stat:
path: "{{ atlas_borg_recovery_export_path }}"
register: atlas_borg_recovery_export
delegate_to: localhost
become: false
when:
- atlas_manage_borg_backup | bool
- not ansible_check_mode
- name: Export the Atlas Borg recovery key for offline preservation
tags: [atlas, storage, backup, borg]
when:
- atlas_manage_borg_backup | bool
- not ansible_check_mode
- not atlas_borg_recovery_export.stat.exists
no_log: true
block:
- name: Create the local recovery-material directory
ansible.builtin.file:
path: "{{ atlas_borg_recovery_export_path | dirname }}"
state: directory
mode: "0700"
delegate_to: localhost
become: false
- name: Export the encrypted Borg repository key on Atlas
ansible.builtin.command:
argv:
- /usr/bin/borg
- --remote-path
- "{{ atlas_borg_remote_path }}"
- key
- export
- >-
ssh://{{ atlas_borg_repository_user }}@{{ atlas_borg_repository_host }}:
{{- atlas_borg_repository_port }}/{{ atlas_borg_repository_path }}
- "{{ atlas_borg_config_dir }}/atlas-borg-repokey.export"
environment:
BORG_CACHE_DIR: "{{ atlas_borg_cache_dir }}"
BORG_CONFIG_DIR: "{{ atlas_borg_config_dir }}"
BORG_PASSCOMMAND: "cat {{ atlas_borg_passphrase_path }}"
BORG_RSH: "{{ atlas_borg_ssh_wrapper_path }}"
become: true
become_user: "{{ atlas_borg_username }}"
- name: Fetch the encrypted Borg recovery key from Atlas
ansible.builtin.fetch:
src: "{{ atlas_borg_config_dir }}/atlas-borg-repokey.export"
dest: "{{ atlas_borg_recovery_export_path }}"
flat: true
- name: Protect the local Borg recovery-key export
ansible.builtin.file:
path: "{{ atlas_borg_recovery_export_path }}"
mode: "0600"
delegate_to: localhost
become: false
always:
- name: Remove the temporary recovery-key export from Atlas
ansible.builtin.file:
path: "{{ atlas_borg_config_dir }}/atlas-borg-repokey.export"
state: absent
- name: Enable Atlas Borg backup and check timers
tags: [atlas, storage, backup, borg]
ansible.builtin.systemd:
name: "{{ item }}"
enabled: true
state: started
daemon_reload: true
loop:
- atlas-borg-backup.timer
- atlas-borg-check.timer
when:
- atlas_manage_borg_backup | bool
- not ansible_check_mode

View File

@@ -0,0 +1,15 @@
---
- name: Configure the official 45Drives repository
tags: [atlas, packages]
ansible.builtin.get_url:
url: "{{ atlas_45drives_repo_url }}"
dest: "{{ atlas_45drives_repo_file }}"
owner: root
group: root
mode: "0644"
- name: Install 45Drives Cockpit plugins
tags: [atlas, packages]
ansible.builtin.dnf:
name: "{{ atlas_45drives_packages }}"
state: present

View File

@@ -0,0 +1,140 @@
---
- name: Require completed Atlas media-stack configuration
tags: [atlas, containers, immich]
ansible.builtin.assert:
that:
- atlas_manage_storage | bool
- atlas_zfs_pool != 'CHANGEME_ZFS_POOL'
- atlas_mount_root != '/CHANGEME_ATLAS_MOUNT_ROOT'
- atlas_immich_db_password | length > 0
- atlas_npm_admin_bind_address == '127.0.0.1'
fail_msg: >-
Enable and configure Atlas storage, provide vault_atlas_immich_db_password,
and keep the NPM administration port bound to loopback before enabling the media stack.
no_log: true
when: atlas_manage_media_stack | bool
- name: Check Atlas Intel graphics device
tags: [atlas, containers, immich]
ansible.builtin.stat:
path: /dev/dri
register: atlas_dri_device
when: atlas_manage_media_stack | bool
- name: Require Atlas Intel graphics device
tags: [atlas, containers, immich]
ansible.builtin.assert:
that:
- atlas_dri_device.stat.isdir | default(false)
fail_msg: /dev/dri is required for Immich QuickSync and OpenVINO acceleration.
when: atlas_manage_media_stack | bool
- name: Create Atlas rootful Quadlet directory
tags: [atlas, containers]
ansible.builtin.file:
path: "{{ atlas_container_quadlet_dir }}"
state: directory
owner: root
group: root
mode: "0755"
when: atlas_manage_media_stack | bool
- name: Create Atlas Immich configuration directory
tags: [atlas, containers, immich]
ansible.builtin.file:
path: /etc/immich
state: directory
owner: root
group: "{{ atlas_immich_group }}"
mode: "0750"
when: atlas_manage_media_stack | bool
- name: Create Atlas Immich persistent directories
tags: [atlas, containers, immich]
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: "{{ atlas_immich_username }}"
group: "{{ atlas_immich_group }}"
mode: "0750"
loop:
- "{{ atlas_immich_upload_dir }}"
- "{{ atlas_immich_model_cache_dir }}"
- "{{ atlas_immich_machine_learning_config_dir }}"
- "{{ atlas_immich_machine_learning_cache_dir }}"
- "{{ atlas_immich_redis_data_dir }}"
- "{{ atlas_immich_postgres_data_dir }}"
loop_control:
label: "{{ item }}"
when: atlas_manage_media_stack | bool
- name: Create Atlas NPM persistent directories
tags: [atlas, containers, npm]
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: "0750"
loop:
- "{{ atlas_npm_data_dir }}"
- "{{ atlas_npm_letsencrypt_dir }}"
loop_control:
label: "{{ item }}"
when: atlas_manage_media_stack | bool
- name: Allow confined containers to use Atlas graphics devices
tags: [atlas, containers, immich]
ansible.posix.seboolean:
name: container_use_devices
state: true
persistent: true
when:
- atlas_manage_media_stack | bool
- (ansible_facts['selinux'] | default({})).get('status', 'disabled') == 'enabled'
- name: Render Vault-backed Immich environment
tags: [atlas, containers, immich]
ansible.builtin.template:
src: immich.env.j2
dest: /etc/immich/immich.env
owner: root
group: "{{ atlas_immich_group }}"
mode: "0640"
no_log: true
diff: false
notify: Restart Atlas media Quadlets
when: atlas_manage_media_stack | bool
- name: Render Atlas media Quadlets
tags: [atlas, containers]
ansible.builtin.template:
src: "{{ item }}.j2"
dest: "{{ atlas_container_quadlet_dir }}/{{ item }}"
owner: root
group: root
mode: "0644"
loop:
- atlas-media.network
- atlas-immich-redis.container
- atlas-immich-postgres.container
- atlas-immich-machine-learning.container
- atlas-immich-server.container
- atlas-npm.container
loop_control:
label: "{{ item }}"
notify: Restart Atlas media Quadlets
when: atlas_manage_media_stack | bool
- name: Start Atlas media Quadlet services
tags: [atlas, containers, services]
ansible.builtin.systemd:
name: "{{ item }}"
state: started
daemon_reload: true
loop: "{{ atlas_media_quadlet_services }}"
loop_control:
label: "{{ item }}"
when:
- atlas_manage_media_stack | bool
- not ansible_check_mode

View File

@@ -0,0 +1,159 @@
---
- name: Prepare the isolated rootless Atlas Gitea target
tags: [atlas, gitea]
when: atlas_manage_gitea | bool
block:
- name: Require the existing Atlas application-data dataset
ansible.builtin.assert:
that:
- atlas_manage_storage | bool
- atlas_gitea_dataset == atlas_zfs_pool ~ '/services/data/gitea'
- atlas_gitea_mountpoint == atlas_app_data_mountpoint ~ '/gitea'
- atlas_gitea_username == atlas_admin_username
- atlas_gitea_group == atlas_admin_group
- atlas_gitea_uid | int == atlas_admin_uid | int
- atlas_gitea_gid | int == atlas_admin_gid | int
- atlas_gitea_container_uid | int == 1000
- atlas_gitea_container_gid | int == 1000
- atlas_gitea_staging_bind_address == '127.0.0.1'
- not (atlas_gitea_production_enabled | bool) or atlas_manage_firewall | bool
- not (atlas_gitea_production_enabled | bool) or atlas_gitea_bind_address == ansible_host
fail_msg: >-
Rootless Gitea requires Atlas storage, the admin user manager, the
dedicated dataset, and loopback-only staging ports.
- name: Inspect the final-restore marker before production activation
ansible.builtin.stat:
path: "{{ atlas_gitea_mountpoint }}/.final-sha256"
register: atlas_gitea_final_marker
when: atlas_gitea_production_enabled | bool
- name: Refuse production activation without the final consistent restore
ansible.builtin.assert:
that:
- atlas_gitea_final_marker.stat.isreg | default(false)
fail_msg: Restore the final stopped-source Gitea export before enabling production.
when: atlas_gitea_production_enabled | bool
- name: Verify the production Gitea dataset belongs to admin
ansible.builtin.stat:
path: "{{ atlas_gitea_mountpoint }}"
register: atlas_gitea_dataset_owner
when: atlas_gitea_production_enabled | bool
- name: Refuse to overlap the legacy host-account service
ansible.builtin.assert:
that:
- atlas_gitea_dataset_owner.stat.uid | int == atlas_admin_uid | int
- atlas_gitea_dataset_owner.stat.gid | int == atlas_admin_gid | int
fail_msg: >-
Run the explicit Gitea owner migration before enabling the admin
Quadlet; never chown an active legacy service in a normal run.
when: atlas_gitea_production_enabled | bool
- name: Remove the retired account's parent-dataset traverse ACL
ansible.posix.acl:
path: "{{ item }}"
etype: user
entity: "{{ atlas_gitea_legacy_username }}"
state: absent
loop:
- "{{ atlas_services_mountpoint }}"
- "{{ atlas_app_data_mountpoint }}"
when: atlas_gitea_production_enabled | bool
- name: Enable POSIX ACLs only on the service-namespace parents
community.general.zfs:
name: "{{ item }}"
state: present
extra_zfs_properties:
acltype: posix
loop:
- "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_services }}"
- "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_app_data }}"
- name: Create the dedicated Gitea ZFS dataset
community.general.zfs:
name: "{{ atlas_gitea_dataset }}"
state: present
extra_zfs_properties:
compression: zstd
mountpoint: "{{ atlas_gitea_mountpoint }}"
- name: Restrict the Gitea dataset and create rootless volume paths
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: "{{ atlas_gitea_username }}"
group: "{{ atlas_gitea_group }}"
mode: "0700"
loop:
- "{{ atlas_gitea_mountpoint }}"
- "{{ atlas_gitea_mountpoint }}/data"
- "{{ atlas_gitea_mountpoint }}/config"
- "{{ atlas_gitea_home }}/.config"
- "{{ atlas_gitea_home }}/.config/containers"
- "{{ atlas_gitea_quadlet_dir }}"
- name: Ensure lingering for the admin rootless account
ansible.builtin.command:
argv:
- loginctl
- enable-linger
- "{{ atlas_gitea_username }}"
creates: "/var/lib/systemd/linger/{{ atlas_gitea_username }}"
- name: Start the admin rootless user manager
ansible.builtin.systemd:
name: "user@{{ atlas_gitea_uid }}.service"
state: started
when: not ansible_check_mode
- name: Prepare the admin-owned Gitea image
ansible.builtin.import_tasks: gitea_image.yml
- name: Render the rootless Gitea Quadlet
ansible.builtin.template:
src: atlas-gitea.container.j2
dest: "{{ atlas_gitea_quadlet_dir }}/atlas-gitea.container"
owner: "{{ atlas_gitea_username }}"
group: "{{ atlas_gitea_group }}"
mode: "0644"
- name: Permit only Aegis to reach production Gitea HTTP and SSH
ansible.posix.firewalld:
rich_rule: >-
rule family="ipv4" source address="{{ atlas_aegis_ip }}"
port port="{{ item }}" protocol="tcp" accept
zone: "{{ atlas_firewalld_zone }}"
state: "{{ 'enabled' if atlas_gitea_production_enabled | bool else 'disabled' }}"
permanent: true
immediate: true
loop:
- "{{ atlas_gitea_http_port }}"
- "{{ atlas_gitea_ssh_port }}"
when: atlas_manage_firewall | bool
- name: Reload the rootless Gitea user manager without starting Gitea
become_user: "{{ atlas_gitea_username }}"
ansible.builtin.systemd:
scope: user
daemon_reload: true
environment:
XDG_RUNTIME_DIR: "/run/user/{{ atlas_gitea_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ atlas_gitea_uid }}/bus"
when: not ansible_check_mode
- name: Start and enable the rootless Gitea user Quadlet after final restore
become_user: "{{ atlas_gitea_username }}"
ansible.builtin.systemd:
name: atlas-gitea.service
scope: user
state: started
enabled: true
environment:
XDG_RUNTIME_DIR: "/run/user/{{ atlas_gitea_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ atlas_gitea_uid }}/bus"
when:
- atlas_gitea_production_enabled | bool
- not ansible_check_mode

View File

@@ -0,0 +1,51 @@
---
- name: Create the admin-owned Gitea image build directory
ansible.builtin.file:
path: "{{ atlas_gitea_image_build_dir }}"
state: directory
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "0700"
- name: Install the pinned rootless Gitea Containerfile
ansible.builtin.copy:
src: Containerfile.gitea-rootless
dest: "{{ atlas_gitea_image_build_dir }}/Containerfile"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "0644"
- name: Check the admin-owned Gitea image
become_user: "{{ atlas_admin_username }}"
ansible.builtin.command:
argv: [podman, image, exists, "{{ atlas_gitea_image }}"]
args:
chdir: "{{ atlas_gitea_image_build_dir }}"
environment:
HOME: "{{ atlas_admin_home }}"
XDG_RUNTIME_DIR: "/run/user/{{ atlas_admin_uid }}"
register: atlas_gitea_image_present
changed_when: false
failed_when: false
check_mode: false
- name: Build the pinned Gitea image with the internal gitea identity
become_user: "{{ atlas_admin_username }}"
ansible.builtin.command:
argv:
- podman
- build
- --pull=always
- --tag
- "{{ atlas_gitea_image }}"
- --file
- Containerfile
- .
args:
chdir: "{{ atlas_gitea_image_build_dir }}"
environment:
HOME: "{{ atlas_admin_home }}"
XDG_RUNTIME_DIR: "/run/user/{{ atlas_admin_uid }}"
when:
- atlas_gitea_image_present.rc != 0
- not ansible_check_mode

View File

@@ -0,0 +1,275 @@
---
# Run only in an approved outage with -e atlas_gitea_owner_migration=true.
- name: Move live Gitea from the legacy host account to admin
tags: [atlas, gitea_owner_migration]
when: atlas_gitea_owner_migration | bool
block:
- name: Refuse a check-mode owner migration
ansible.builtin.assert:
that: not ansible_check_mode
fail_msg: The owner migration requires an explicit live outage.
- name: Inspect the Gitea dataset owner
ansible.builtin.stat:
path: "{{ atlas_gitea_mountpoint }}"
register: atlas_gitea_migration_owner
- name: Require either the legacy owner or an already migrated dataset
ansible.builtin.assert:
that:
- atlas_gitea_migration_owner.stat.isdir | default(false)
- atlas_gitea_migration_owner.stat.uid | int in [atlas_gitea_legacy_uid | int, atlas_admin_uid | int]
fail_msg: Refusing to modify a Gitea dataset with an unexpected owner.
- name: Migrate only a legacy-owned Gitea dataset
when: atlas_gitea_migration_owner.stat.uid | int == atlas_gitea_legacy_uid | int
block:
- name: Require the final cutover marker and configuration
ansible.builtin.stat:
path: "{{ item }}"
loop:
- "{{ atlas_gitea_mountpoint }}/.final-sha256"
- "{{ atlas_gitea_mountpoint }}/config/app.ini"
register: atlas_gitea_migration_files
- name: Refuse migration without both final data and configuration
ansible.builtin.assert:
that: atlas_gitea_migration_files.results | map(attribute='stat.isreg') | min
- name: Check that admin has no existing Gitea Quadlet
ansible.builtin.stat:
path: "{{ atlas_gitea_quadlet_dir }}/atlas-gitea.container"
register: atlas_gitea_admin_quadlet
- name: Refuse to overwrite an existing admin Quadlet
ansible.builtin.assert:
that: not atlas_gitea_admin_quadlet.stat.exists
- name: Check pool health before the outage
ansible.builtin.command:
argv: [zpool, status, -x, "{{ atlas_zfs_pool }}"]
register: atlas_gitea_pool_before
changed_when: false
failed_when: "'is healthy' not in atlas_gitea_pool_before.stdout"
- name: Ensure the admin Gitea image is available before stopping the source
ansible.builtin.import_tasks: gitea_image.yml
- name: Stop, snapshot and test the admin-owned staging service
block:
- name: Stop and disable the legacy Gitea user service
become_user: "{{ atlas_gitea_legacy_username }}"
ansible.builtin.systemd:
name: atlas-gitea.service
scope: user
state: stopped
enabled: false
environment:
XDG_RUNTIME_DIR: "/run/user/{{ atlas_gitea_legacy_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ atlas_gitea_legacy_uid }}/bus"
- name: Record the migration snapshot name
ansible.builtin.set_fact:
atlas_gitea_migration_snapshot: >-
{{ atlas_gitea_dataset }}@gitea-owner-migration-{{ ansible_facts.date_time.iso8601_basic_short }}
- name: Snapshot the stopped Gitea dataset for manual recovery
ansible.builtin.command:
argv: [zfs, snapshot, "{{ atlas_gitea_migration_snapshot }}"]
- name: Transfer only the Gitea dataset to admin
ansible.builtin.file:
path: "{{ atlas_gitea_mountpoint }}"
state: directory
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
recurse: true
- name: Set the actual internal Unix process user
ansible.builtin.lineinfile:
path: "{{ atlas_gitea_mountpoint }}/config/app.ini"
regexp: '^RUN_USER\s*='
line: RUN_USER = gitea
mode: "0600"
no_log: true
diff: false
- name: Preserve public git clone URLs independently of the Unix user
community.general.ini_file:
path: "{{ atlas_gitea_mountpoint }}/config/app.ini"
section: server
option: "{{ item }}"
value: git
mode: "0600"
no_extra_spaces: false
loop: [BUILTIN_SSH_SERVER_USER, SSH_USER]
no_log: true
diff: false
- name: Render admin's loopback-only staging Quadlet
ansible.builtin.template:
src: atlas-gitea.container.j2
dest: "{{ atlas_gitea_quadlet_dir }}/atlas-gitea.container"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "0644"
vars:
atlas_gitea_production_enabled: false
- name: Reload the admin user manager for staging
become_user: "{{ atlas_admin_username }}"
ansible.builtin.systemd:
scope: user
daemon_reload: true
environment:
XDG_RUNTIME_DIR: "/run/user/{{ atlas_admin_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ atlas_admin_uid }}/bus"
- name: Start admin's loopback-only staging service
become_user: "{{ atlas_admin_username }}"
ansible.builtin.systemd:
name: atlas-gitea.service
scope: user
state: started
environment:
XDG_RUNTIME_DIR: "/run/user/{{ atlas_admin_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ atlas_admin_uid }}/bus"
- name: Verify staging HTTP before promotion
ansible.builtin.uri:
url: "http://127.0.0.1:{{ atlas_gitea_staging_http_port }}/"
status_code: 200
register: atlas_gitea_staging_http
retries: 30
delay: 2
until: atlas_gitea_staging_http is succeeded
- name: Verify the container really runs as internal gitea
become_user: "{{ atlas_admin_username }}"
ansible.builtin.command:
argv: [podman, exec, atlas-gitea, id, -un]
environment:
HOME: "{{ atlas_admin_home }}"
XDG_RUNTIME_DIR: "/run/user/{{ atlas_admin_uid }}"
register: atlas_gitea_internal_user
changed_when: false
failed_when: atlas_gitea_internal_user.stdout != 'gitea'
- name: Verify the migrated SQLite database
ansible.builtin.command:
argv:
- sqlite3
- "{{ atlas_gitea_mountpoint }}/data/gitea/gitea.db"
- PRAGMA quick_check;
register: atlas_gitea_migration_sqlite
changed_when: false
failed_when: atlas_gitea_migration_sqlite.stdout != 'ok'
rescue:
- name: Stop admin's failed staging service
become_user: "{{ atlas_admin_username }}"
ansible.builtin.systemd:
name: atlas-gitea.service
scope: user
state: stopped
environment:
XDG_RUNTIME_DIR: "/run/user/{{ atlas_admin_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ atlas_admin_uid }}/bus"
failed_when: false
- name: Restore the original Gitea configuration from the safety snapshot
ansible.builtin.command:
argv:
- cp
- -a
- "{{ atlas_gitea_mountpoint }}/.zfs/snapshot/{{ atlas_gitea_migration_snapshot.split('@')[1] }}/config/app.ini"
- "{{ atlas_gitea_mountpoint }}/config/app.ini"
when: atlas_gitea_migration_snapshot is defined
- name: Return the Gitea dataset to the legacy account
ansible.builtin.file:
path: "{{ atlas_gitea_mountpoint }}"
state: directory
owner: "{{ atlas_gitea_legacy_username }}"
group: "{{ atlas_gitea_legacy_username }}"
recurse: true
- name: Restart the legacy Gitea service
become_user: "{{ atlas_gitea_legacy_username }}"
ansible.builtin.systemd:
name: atlas-gitea.service
scope: user
state: started
enabled: true
environment:
XDG_RUNTIME_DIR: "/run/user/{{ atlas_gitea_legacy_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ atlas_gitea_legacy_uid }}/bus"
- name: Report the failed migration and preserved snapshot
ansible.builtin.fail:
msg: >-
Admin staging failed; legacy Gitea was restarted. Inspect
{{ atlas_gitea_migration_snapshot | default('the host journal') }}.
- name: Stop admin's validated staging service
become_user: "{{ atlas_admin_username }}"
ansible.builtin.systemd:
name: atlas-gitea.service
scope: user
state: stopped
environment:
XDG_RUNTIME_DIR: "/run/user/{{ atlas_admin_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ atlas_admin_uid }}/bus"
- name: Render admin's production Gitea Quadlet
ansible.builtin.template:
src: atlas-gitea.container.j2
dest: "{{ atlas_gitea_quadlet_dir }}/atlas-gitea.container"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "0644"
vars:
atlas_gitea_production_enabled: true
- name: Reload admin's production user manager
become_user: "{{ atlas_admin_username }}"
ansible.builtin.systemd:
scope: user
daemon_reload: true
environment:
XDG_RUNTIME_DIR: "/run/user/{{ atlas_admin_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ atlas_admin_uid }}/bus"
- name: Enable and start admin's production Gitea
become_user: "{{ atlas_admin_username }}"
ansible.builtin.systemd:
name: atlas-gitea.service
scope: user
state: started
enabled: true
environment:
XDG_RUNTIME_DIR: "/run/user/{{ atlas_admin_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ atlas_admin_uid }}/bus"
- name: Verify production HTTP before retiring the old Quadlet
ansible.builtin.uri:
url: "http://{{ atlas_gitea_bind_address }}:{{ atlas_gitea_http_port }}/"
status_code: 200
register: atlas_gitea_production_http
retries: 30
delay: 2
until: atlas_gitea_production_http is succeeded
- name: Remove only the disabled legacy Quadlet
ansible.builtin.file:
path: "{{ atlas_gitea_legacy_home }}/.config/containers/systemd/atlas-gitea.container"
state: absent
- name: Reload the legacy user manager after Quadlet removal
become_user: "{{ atlas_gitea_legacy_username }}"
ansible.builtin.systemd:
scope: user
daemon_reload: true
environment:
XDG_RUNTIME_DIR: "/run/user/{{ atlas_gitea_legacy_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ atlas_gitea_legacy_uid }}/bus"

View File

@@ -0,0 +1,58 @@
---
- name: Manage the public domain of the restored production Gitea
tags: [atlas, gitea, gitea_public_domain]
when:
- atlas_manage_gitea | bool
- atlas_gitea_production_enabled | bool
- atlas_gitea_public_domain | length > 0
block:
- name: Require an explicit public Gitea hostname
ansible.builtin.assert:
that:
- atlas_gitea_public_domain is match('^[a-zA-Z0-9][a-zA-Z0-9.-]*\.[a-zA-Z]{2,}$')
- name: Inspect the restored private Gitea configuration
ansible.builtin.stat:
path: "{{ atlas_gitea_mountpoint }}/config/app.ini"
follow: false
register: atlas_gitea_public_config
- name: Refuse to create or replace an unprepared Gitea configuration
ansible.builtin.assert:
that:
- atlas_gitea_public_config.stat.isreg | default(false)
- atlas_gitea_public_config.stat.uid | int == atlas_gitea_uid | int
- atlas_gitea_public_config.stat.mode == '0600'
# app.ini contains secrets: preserve all unrelated settings and suppress diffs.
- name: Set only the declared public Gitea server fields
community.general.ini_file:
path: "{{ atlas_gitea_mountpoint }}/config/app.ini"
section: server
option: "{{ item.option }}"
value: "{{ item.value }}"
create: false
backup: true
owner: "{{ atlas_gitea_username }}"
group: "{{ atlas_gitea_group }}"
mode: "0600"
loop:
- { option: DOMAIN, value: "{{ atlas_gitea_public_domain }}" }
- { option: ROOT_URL, value: "https://{{ atlas_gitea_public_domain }}/" }
- { option: SSH_DOMAIN, value: "{{ atlas_gitea_public_domain }}" }
register: atlas_gitea_public_domain_update
no_log: true
diff: false
- name: Restart only Gitea when its public configuration changes
become_user: "{{ atlas_gitea_username }}"
ansible.builtin.systemd:
name: atlas-gitea.service
scope: user
state: restarted
environment:
XDG_RUNTIME_DIR: "/run/user/{{ atlas_gitea_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ atlas_gitea_uid }}/bus"
when:
- atlas_gitea_public_domain_update is changed
- not ansible_check_mode

View File

@@ -0,0 +1,107 @@
---
- name: Restore Gitea from a verified Prometheus backup only on explicit request
tags: [atlas, gitea_restore, gitea_final_restore]
when: atlas_gitea_restore_test | bool or atlas_gitea_final_restore | bool
block:
- name: Require the prepared rootless Gitea target
ansible.builtin.assert:
that:
- atlas_manage_gitea | bool
- not (atlas_gitea_restore_test | bool and atlas_gitea_final_restore | bool)
- atlas_gitea_staging_bind_address == '127.0.0.1'
- atlas_gitea_mountpoint == atlas_app_data_mountpoint ~ '/gitea'
fail_msg: Prepare the isolated, loopback-only rootless Gitea target first.
- name: Confirm the rootless Gitea service is inactive
become_user: "{{ atlas_gitea_username }}"
ansible.builtin.command:
argv:
- systemctl
- --user
- is-active
- atlas-gitea.service
environment:
XDG_RUNTIME_DIR: "/run/user/{{ atlas_gitea_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ atlas_gitea_uid }}/bus"
register: atlas_gitea_restore_service_state
changed_when: false
failed_when: false
when: not ansible_check_mode
- name: Refuse to overwrite an active rootless Gitea service
ansible.builtin.assert:
that:
- atlas_gitea_restore_service_state.stdout == 'inactive'
fail_msg: The rootless Gitea user service must be known and inactive before restoring data.
when: not ansible_check_mode
- name: Check for a manually running rootless Gitea container
become_user: "{{ atlas_gitea_username }}"
ansible.builtin.command:
argv:
- podman
- ps
- --quiet
- --filter
- name=atlas-gitea
args:
chdir: "{{ atlas_gitea_home }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ atlas_gitea_uid }}"
register: atlas_gitea_restore_container_state
changed_when: false
when: not ansible_check_mode
- name: Refuse to overwrite a running rootless Gitea container
ansible.builtin.assert:
that:
- atlas_gitea_restore_container_state.stdout | length == 0
fail_msg: Stop every rootless Atlas Gitea container before restoring data.
when: not ansible_check_mode
- name: Install the selective rootless Gitea restore helper
ansible.builtin.copy:
src: atlas-gitea-restore-test.py
dest: "{{ atlas_gitea_restore_helper }}"
owner: root
group: root
mode: "0700"
- name: Restore only Gitea data into the isolated target
ansible.builtin.command:
argv:
- "{{ atlas_gitea_restore_helper }}"
- --backup
- "{{ atlas_backup_prometheus_mountpoint }}/latest"
- --target
- "{{ atlas_gitea_mountpoint }}"
- --uid
- "{{ atlas_gitea_uid | string }}"
- --gid
- "{{ atlas_gitea_gid | string }}"
register: atlas_gitea_restore_result
changed_when: atlas_gitea_restore_result.stdout == 'restored'
no_log: true
when:
- atlas_gitea_restore_test | bool
- not ansible_check_mode
- name: Replace the marked rehearsal with the final consistent Gitea export
ansible.builtin.command:
argv:
- "{{ atlas_gitea_restore_helper }}"
- --backup
- "{{ atlas_backup_prometheus_mountpoint }}/latest"
- --target
- "{{ atlas_gitea_mountpoint }}"
- --uid
- "{{ atlas_gitea_uid | string }}"
- --gid
- "{{ atlas_gitea_gid | string }}"
- --replace-rehearsal
register: atlas_gitea_final_restore_result
changed_when: atlas_gitea_final_restore_result.stdout == 'restored'
no_log: true
when:
- atlas_gitea_final_restore | bool
- not ansible_check_mode

View File

@@ -0,0 +1,45 @@
---
- name: Enforce targeted SELinux on Atlas
tags: [atlas, hardening, security]
ansible.posix.selinux:
policy: targeted
state: enforcing
update_kernel_param: true
register: atlas_selinux_enforcement
- name: Report when Atlas requires a reboot for SELinux enforcement
tags: [atlas, hardening, security]
ansible.builtin.debug:
msg: Reboot Atlas before relying on SELinux enforcement.
when: atlas_selinux_enforcement.reboot_required | default(false)
- name: Apply Atlas network-kernel hardening
tags: [atlas, hardening, security]
ansible.posix.sysctl:
name: "{{ item.key }}"
value: "{{ item.value }}"
state: present
sysctl_set: true
reload: true
loop: "{{ atlas_hardening_sysctls | dict2items }}"
loop_control:
label: "{{ item.key }}={{ item.value }}"
- name: Read active Atlas SELinux mode
tags: [atlas, hardening, security]
ansible.builtin.command:
argv:
- getenforce
register: atlas_selinux_mode
changed_when: false
when: not ansible_check_mode
- name: Require active SELinux enforcement on Atlas
tags: [atlas, hardening, security]
ansible.builtin.assert:
that:
- atlas_selinux_mode.stdout == 'Enforcing'
fail_msg: >-
Atlas SELinux is not enforcing. Reboot if requested by the preceding
SELinux task, then rerun the hardening role before enabling services.
when: not ansible_check_mode

View File

@@ -0,0 +1,188 @@
---
- name: Require exact Atlas iCloudPD paths and rootless identity
tags: [atlas, icloudpd]
ansible.builtin.assert:
that:
- atlas_manage_storage | bool
- atlas_icloudpd_dataset == atlas_zfs_pool ~ '/services/data/icloudpd'
- atlas_icloudpd_state_dir == atlas_app_data_mountpoint ~ '/icloudpd'
- atlas_icloudpd_config_dir == atlas_icloudpd_state_dir ~ '/config'
- atlas_icloudpd_photos_dir == atlas_archive_mountpoint ~ '/Pictures/iCloudPD'
- atlas_admin_uid | int == 1000
- atlas_admin_gid | int == 1000
- atlas_icloudpd_image is search('@sha256:[0-9a-f]{64}$')
fail_msg: Verify the fixed, separate Atlas iCloudPD photo and state paths.
- name: Declare rootless Atlas iCloudPD storage and boot-started Quadlet
tags: [atlas, icloudpd]
block:
- name: Inspect the existing Archive and application-data datasets
community.general.zfs_facts:
name: "{{ item.dataset }}"
properties: name,mounted,mountpoint
loop:
- dataset: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_archive }}"
mountpoint: "{{ atlas_archive_mountpoint }}"
- dataset: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_app_data }}"
mountpoint: "{{ atlas_app_data_mountpoint }}"
loop_control:
label: "{{ item.dataset }}"
register: atlas_icloudpd_parent_datasets
- name: Refuse missing or unmounted iCloudPD parent datasets
ansible.builtin.assert:
that:
- item.ansible_facts.ansible_zfs_datasets | length == 1
- item.ansible_facts.ansible_zfs_datasets[0].mounted == 'yes'
- item.ansible_facts.ansible_zfs_datasets[0].mountpoint == item.item.mountpoint
loop: "{{ atlas_icloudpd_parent_datasets.results }}"
loop_control:
label: "{{ item.item.dataset }}"
- name: Inspect the existing Pictures namespace and proposed target
ansible.builtin.stat:
path: "{{ item }}"
follow: false
loop:
- "{{ atlas_archive_mountpoint }}/Pictures"
- "{{ atlas_icloudpd_photos_dir }}"
- "{{ atlas_icloudpd_photos_dir }}/.atlas-icloudpd-managed"
register: atlas_icloudpd_photo_paths
- name: Refuse to adopt unrelated Pictures data or a symlink
ansible.builtin.assert:
that:
- atlas_icloudpd_photo_paths.results[0].stat.isdir | default(false)
- atlas_icloudpd_photo_paths.results[0].stat.uid | int == atlas_admin_uid | int
- >-
not atlas_icloudpd_photo_paths.results[1].stat.exists or
(atlas_icloudpd_photo_paths.results[1].stat.isdir | default(false) and
atlas_icloudpd_photo_paths.results[2].stat.isreg | default(false))
fail_msg: >-
Pictures must exist and be admin-owned; an existing iCloudPD target
must carry its managed marker. Never adopt or replace unrelated data.
- name: Create a dedicated ZFS dataset for iCloudPD configuration and MFA
community.general.zfs:
name: "{{ atlas_icloudpd_dataset }}"
state: present
extra_zfs_properties:
compression: zstd
mountpoint: "{{ atlas_icloudpd_state_dir }}"
- name: Restrict iCloudPD state and the new photo subtree
ansible.builtin.file:
path: "{{ item.path }}"
state: directory
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "{{ item.mode }}"
loop:
- path: "{{ atlas_icloudpd_state_dir }}"
mode: "0700"
- path: "{{ atlas_icloudpd_config_dir }}"
mode: "0700"
- path: "{{ atlas_icloudpd_photos_dir }}"
mode: "0750"
- path: "{{ atlas_icloudpd_quadlet_dir }}"
mode: "0700"
loop_control:
label: "{{ item.path }}"
- name: Mark only the newly managed iCloudPD photo subtree
ansible.builtin.copy:
content: "Atlas iCloudPD photo subtree; do not remove source photos.\n"
dest: "{{ atlas_icloudpd_photos_dir }}/.atlas-icloudpd-managed"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "0600"
force: false
- name: Install the image's required mounted-filesystem failsafe
ansible.builtin.copy:
content: ""
dest: "{{ atlas_icloudpd_photos_dir }}/.mounted"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "0644"
force: false
- name: Require the Vault-backed iCloudPD Apple ID
ansible.builtin.assert:
that:
- vault_atlas_icloudpd_apple_id is defined
- vault_atlas_icloudpd_apple_id | length > 0
- vault_atlas_icloudpd_apple_id != 'REPLACE_ME'
- vault_atlas_icloudpd_apple_id.splitlines() | length == 1
fail_msg: Configure the existing iCloudPD Apple ID in Vault.
no_log: true
- name: Seed private Atlas iCloudPD configuration when absent
ansible.builtin.template:
src: atlas-icloudpd.conf.j2
dest: "{{ atlas_icloudpd_config_dir }}/icloudpd.conf"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "0600"
force: false
no_log: true
diff: false
- name: Keep declared iCloudPD options in the image-managed configuration
ansible.builtin.lineinfile:
path: "{{ atlas_icloudpd_config_dir }}/icloudpd.conf"
regexp: "^{{ item.key }}="
line: "{{ item.key }}={{ item.value }}"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "0600"
loop:
- {key: apple_id, value: "{{ vault_atlas_icloudpd_apple_id }}"}
- {key: authentication_type, value: MFA}
- {key: user, value: user}
- {key: user_id, value: "1000"}
- {key: group, value: group}
- {key: group_id, value: "1000"}
- {key: download_path, value: /home/user/iCloud}
- {key: folder_structure, value: "{:%Y/%m/%d}"}
- {key: directory_permissions, value: "750"}
- {key: file_permissions, value: "640"}
- {key: download_interval, value: "86400"}
- {key: auto_delete, value: "false"}
- {key: delete_after_download, value: "false"}
loop_control:
label: "{{ item.key }}"
no_log: true
diff: false
- name: Render the rootless Atlas iCloudPD Quadlet
ansible.builtin.template:
src: atlas-icloudpd.container.j2
dest: "{{ atlas_icloudpd_quadlet_dir }}/atlas-icloudpd.container"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "0644"
register: atlas_icloudpd_quadlet
- name: Reload the Atlas admin user manager after iCloudPD Quadlet changes
become_user: "{{ atlas_admin_username }}"
ansible.builtin.systemd:
scope: user
daemon_reload: true
environment:
XDG_RUNTIME_DIR: "/run/user/{{ atlas_admin_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ atlas_admin_uid }}/bus"
when:
- atlas_icloudpd_quadlet.changed
- not ansible_check_mode
- name: Keep the rootless Atlas iCloudPD service running
become_user: "{{ atlas_admin_username }}"
ansible.builtin.systemd:
name: atlas-icloudpd.service
scope: user
state: started
environment:
XDG_RUNTIME_DIR: "/run/user/{{ atlas_admin_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ atlas_admin_uid }}/bus"
when: not ansible_check_mode

View File

@@ -0,0 +1,57 @@
---
- name: Import Atlas account tasks
ansible.builtin.import_tasks: account.yml
- name: Import Atlas 45Drives tasks
ansible.builtin.import_tasks: cockpit.yml
- name: Import Atlas network hardening tasks
ansible.builtin.import_tasks: hardening.yml
- name: Import Atlas pool bootstrap tasks
ansible.builtin.import_tasks: pool.yml
- name: Import Atlas storage tasks
ansible.builtin.import_tasks: storage.yml
- name: Import explicit Atlas Gitea owner migration
ansible.builtin.import_tasks: gitea_owner_migration.yml
- name: Import staged Atlas rootless Gitea tasks
ansible.builtin.import_tasks: gitea.yml
- name: Import the declared Atlas Gitea public domain
ansible.builtin.import_tasks: gitea_public_domain.yml
- name: Import Atlas iCloudPD storage and boot-started Quadlet tasks
ansible.builtin.import_tasks: icloudpd.yml
- name: Import explicit Atlas Gitea restore rehearsal tasks
ansible.builtin.import_tasks: gitea_restore.yml
- name: Import Atlas ZFS maintenance tasks
ansible.builtin.import_tasks: zfs_maintenance.yml
- name: Import Atlas Borg backup tasks
ansible.builtin.import_tasks: borg_backup.yml
- name: Import Atlas offline USB backup tasks
ansible.builtin.import_tasks: usb_backup.yml
- name: Import Atlas Prometheus backup pull identity tasks
ansible.builtin.import_tasks: prometheus_pull_identity.yml
- name: Import Atlas Prometheus backup pull job tasks
ansible.builtin.import_tasks: prometheus_pull_job.yml
- name: Import Atlas health monitoring tasks
ansible.builtin.import_tasks: monitoring.yml
- name: Import Atlas post-restore SELinux relabeling tasks
ansible.builtin.import_tasks: restorecon.yml
- name: Import Atlas file sharing tasks
ansible.builtin.import_tasks: sharing.yml
- name: Import Atlas media-container tasks
ansible.builtin.import_tasks: containers.yml

View File

@@ -0,0 +1,201 @@
---
- name: Validate Atlas health monitoring policy
tags: [atlas, monitoring]
ansible.builtin.assert:
that:
- atlas_manage_storage | bool
- atlas_zfs_pool != 'CHANGEME_ZFS_POOL'
- atlas_monitor_calendar | length > 0
- atlas_monitor_smart_devices | length > 0
- atlas_monitor_effective_timers | length > 0
- atlas_monitor_effective_failure_units | length > 0
- atlas_monitor_remote_capacity.user == atlas_borg_repository_user
- atlas_monitor_remote_capacity.host == atlas_borg_repository_host
- atlas_monitor_remote_capacity.run_as == atlas_borg_username
- atlas_monitor_remote_capacity.ssh_wrapper == atlas_borg_ssh_wrapper_path
- >-
0 < atlas_monitor_remote_capacity.warning_percent | int
< atlas_monitor_remote_capacity.critical_percent | int < 100
- atlas_monitor_remote_capacity.growth_warning_gib_day | int > 0
- atlas_monitor_notifier.startswith('/opt/45drives/houston/')
- 0 < atlas_monitor_pool_warning_percent | int < atlas_monitor_pool_critical_percent | int < 100
- 0 < atlas_monitor_root_warning_percent | int < atlas_monitor_root_critical_percent | int < 100
- 0 < atlas_monitor_snapshot_warning_percent | int < atlas_monitor_snapshot_critical_percent | int < 100
- atlas_monitor_snapshot_growth_warning_gib_day | int > 0
- atlas_monitor_backup_growth_warning_gib_day | int > 0
- 0 < atlas_monitor_cpu_warning_c | int < atlas_monitor_cpu_critical_c | int
- atlas_monitor_borg_max_runtime_days | int > 0
fail_msg: >-
Atlas health monitoring needs real devices, job units, a valid calendar,
positive ordered thresholds, and the existing Houston notifier.
when: atlas_manage_monitoring | bool
- name: Validate monitored Atlas SMART devices
tags: [atlas, monitoring]
ansible.builtin.assert:
that:
- item.name is match('^[a-z0-9][a-z0-9_-]*$')
- item.path.startswith('/dev/disk/by-id/')
- 0 < item.warning_c | int < item.critical_c | int
fail_msg: "Every monitored disk needs a stable by-id path and ordered temperature thresholds."
loop: "{{ atlas_monitor_smart_devices }}"
loop_control:
label: "{{ item.name }}"
when: atlas_manage_monitoring | bool
- name: Validate monitored Atlas timer names and age thresholds
tags: [atlas, monitoring]
ansible.builtin.assert:
that:
- item.name is match('^[a-zA-Z0-9@_.-]+\\.timer$')
- item.max_age_hours | int >= 0
loop: "{{ atlas_monitor_effective_timers }}"
loop_control:
label: "{{ item.name }}"
when: atlas_manage_monitoring | bool
- name: Validate monitored Atlas failure unit names
tags: [atlas, monitoring]
ansible.builtin.assert:
that:
- item is match('^[a-zA-Z0-9@_.-]+\\.service$')
loop: "{{ atlas_monitor_effective_failure_units }}"
when: atlas_manage_monitoring | bool
- name: Validate Atlas health monitor calendar
tags: [atlas, monitoring]
ansible.builtin.command:
argv: [systemd-analyze, calendar, "{{ atlas_monitor_calendar }}"]
changed_when: false
check_mode: false
when: atlas_manage_monitoring | bool
- name: Install SMART tooling for Atlas health checks
tags: [atlas, monitoring, packages]
ansible.builtin.dnf:
name: smartmontools
state: present
when: atlas_manage_monitoring | bool
- name: Inspect the existing 45Drives notifier for monitoring
tags: [atlas, monitoring]
ansible.builtin.stat:
path: "{{ atlas_monitor_notifier }}"
register: atlas_monitor_notifier_file
when: atlas_manage_monitoring | bool
- name: Require the existing 45Drives notifier for monitoring
tags: [atlas, monitoring]
ansible.builtin.assert:
that:
- atlas_monitor_notifier_file.stat.executable | default(false)
fail_msg: "The existing 45Drives Houston notifier must be executable."
when: atlas_manage_monitoring | bool
- name: Create private Atlas health monitor state directory
tags: [atlas, monitoring]
ansible.builtin.file:
path: /var/lib/atlas-health-monitor
state: directory
owner: root
group: root
mode: "0700"
when: atlas_manage_monitoring | bool
- name: Install Atlas health monitor configuration
tags: [atlas, monitoring]
ansible.builtin.template:
src: atlas-health-monitor.json.j2
dest: /etc/atlas-health-monitor.json
owner: root
group: root
mode: "0600"
when: atlas_manage_monitoring | bool
- name: Install Atlas health monitor helper
tags: [atlas, monitoring]
ansible.builtin.copy:
src: atlas-health-monitor.py
dest: /usr/local/libexec/atlas-health-monitor
owner: root
group: root
mode: "0750"
when: atlas_manage_monitoring | bool
- name: Install Atlas health monitoring units
tags: [atlas, monitoring]
ansible.builtin.template:
src: "{{ item }}.j2"
dest: "/etc/systemd/system/{{ item }}"
owner: root
group: root
mode: "0644"
loop:
- atlas-health-monitor.service
- atlas-health-monitor.timer
- atlas-monitor-failure@.service
when: atlas_manage_monitoring | bool
- name: Create failure hook directories for monitored Atlas jobs
tags: [atlas, monitoring]
ansible.builtin.file:
path: "/etc/systemd/system/{{ item }}.d"
state: directory
owner: root
group: root
mode: "0755"
loop: "{{ atlas_monitor_effective_failure_units }}"
when: atlas_manage_monitoring | bool
- name: Notify 45Drives Alerts when an Atlas job fails
tags: [atlas, monitoring]
ansible.builtin.template:
src: atlas-monitor-failure.conf.j2
dest: "/etc/systemd/system/{{ item }}.d/atlas-monitor.conf"
owner: root
group: root
mode: "0644"
loop: "{{ atlas_monitor_effective_failure_units }}"
when: atlas_manage_monitoring | bool
- name: Reload systemd after installing Atlas monitoring
tags: [atlas, monitoring]
ansible.builtin.systemd:
daemon_reload: true
when:
- atlas_manage_monitoring | bool
- not ansible_check_mode
- name: Enable the Atlas health monitoring timer
tags: [atlas, monitoring]
ansible.builtin.systemd:
name: atlas-health-monitor.timer
enabled: true
state: started
when:
- atlas_manage_monitoring | bool
- not ansible_check_mode
- name: Validate the deployed Atlas health monitoring units
tags: [atlas, monitoring]
ansible.builtin.command:
argv:
- systemd-analyze
- verify
- atlas-health-monitor.service
- atlas-health-monitor.timer
- atlas-monitor-failure@.service
changed_when: false
when:
- atlas_manage_monitoring | bool
- not ansible_check_mode
- name: Probe Atlas health without sending notifications
tags: [atlas, monitoring]
ansible.builtin.command:
argv: [/usr/local/libexec/atlas-health-monitor, --dry-run]
register: atlas_monitor_dry_run
changed_when: false
when:
- atlas_manage_monitoring | bool
- not ansible_check_mode

View File

@@ -0,0 +1,57 @@
---
- name: Bootstrap Atlas ZFS pool
tags: [atlas, storage, pool]
when: atlas_create_pool | bool
block:
- name: Validate Atlas pool bootstrap inputs
ansible.builtin.assert:
that:
- atlas_zpool_disks | length == 4
- atlas_zpool_disks | unique | length == 4
- atlas_zpool_disks | select('match', '^/dev/disk/by-id/') | list | length == 4
fail_msg: >-
Set exactly four distinct persistent /dev/disk/by-id paths in
atlas_zpool_disks before creating the RAIDZ2 pool.
- name: Inspect declared Atlas pool disks
ansible.builtin.stat:
path: "{{ item }}"
follow: true
loop: "{{ atlas_zpool_disks }}"
loop_control:
label: "{{ item }}"
register: atlas_zpool_disk_stats
- name: Require every declared Atlas pool disk
ansible.builtin.assert:
that:
- item.stat.exists
- item.stat.isblk | default(false)
fail_msg: "Declared Atlas pool disk is unavailable or is not a block device: {{ item.item }}"
loop: "{{ atlas_zpool_disk_stats.results }}"
loop_control:
label: "{{ item.item }}"
- name: Check whether the Atlas ZFS pool already exists
ansible.builtin.command:
argv:
- zpool
- list
- -H
- -o
- name
- "{{ atlas_zfs_pool }}"
register: atlas_zpool_bootstrap_check
changed_when: false
failed_when: atlas_zpool_bootstrap_check.rc not in [0, 1]
- name: Create the Atlas RAIDZ2 pool when absent
community.general.zpool:
name: "{{ atlas_zfs_pool }}"
state: present
mountpoint: "{{ atlas_mount_root }}"
force: false
vdevs:
- type: raidz2
disks: "{{ atlas_zpool_disks }}"
when: atlas_zpool_bootstrap_check.rc == 1

View File

@@ -0,0 +1,66 @@
---
- name: Validate Atlas Prometheus pull identity inputs
tags: [atlas, backup, prometheus_backup, prometheus_backup_key]
ansible.builtin.assert:
that:
- atlas_prometheus_pull_ssh_dir.startswith('/etc/')
- atlas_prometheus_pull_private_key_path.startswith(atlas_prometheus_pull_ssh_dir ~ '/')
- atlas_prometheus_pull_known_hosts_path.startswith(atlas_prometheus_pull_ssh_dir ~ '/')
- atlas_prometheus_ssh_host_key.startswith(
(hostvars['prometheus'].ansible_host | string) ~ ' ssh-ed25519 '
)
fail_msg: Pin the verified Prometheus ED25519 SSH host key before enabling the pull.
when: atlas_manage_prometheus_backup_pull | bool
- name: Create private Atlas Prometheus pull SSH directory
tags: [atlas, backup, prometheus_backup, prometheus_backup_key]
ansible.builtin.file:
path: "{{ atlas_prometheus_pull_ssh_dir }}"
state: directory
owner: root
group: root
mode: "0700"
when: atlas_manage_prometheus_backup_pull | bool
- name: Generate Atlas-only Prometheus pull SSH identity
tags: [atlas, backup, prometheus_backup, prometheus_backup_key]
ansible.builtin.command:
argv:
- ssh-keygen
- -q
- -t
- ed25519
- -N
- ""
- -C
- atlas-prometheus-pull@atlas
- -f
- "{{ atlas_prometheus_pull_private_key_path }}"
creates: "{{ atlas_prometheus_pull_private_key_path }}"
when: atlas_manage_prometheus_backup_pull | bool
- name: Protect Atlas-only Prometheus pull SSH identity
tags: [atlas, backup, prometheus_backup, prometheus_backup_key]
ansible.builtin.file:
path: "{{ item.path }}"
owner: root
group: root
mode: "{{ item.mode }}"
loop:
- { path: "{{ atlas_prometheus_pull_private_key_path }}", mode: "0600" }
- { path: "{{ atlas_prometheus_pull_private_key_path }}.pub", mode: "0644" }
loop_control:
label: "{{ item.path }}"
when:
- atlas_manage_prometheus_backup_pull | bool
- not ansible_check_mode
- name: Pin Prometheus SSH host key on Atlas
tags: [atlas, backup, prometheus_backup, prometheus_backup_key]
ansible.builtin.copy:
content: "{{ atlas_prometheus_ssh_host_key }}\n"
dest: "{{ atlas_prometheus_pull_known_hosts_path }}"
owner: root
group: root
mode: "0600"
when: atlas_manage_prometheus_backup_pull | bool

View File

@@ -0,0 +1,90 @@
---
- name: Validate Atlas Prometheus backup pull inputs
tags: [atlas, backup, prometheus_backup]
ansible.builtin.assert:
that:
- atlas_manage_storage | bool
- atlas_prometheus_pull_source_user is match('^[a-z_][a-z0-9_-]*$')
- atlas_prometheus_pull_source_port | int > 0
- atlas_prometheus_pull_source_port | int < 65536
- atlas_prometheus_pull_keep_daily | int > 0
- atlas_prometheus_pull_keep_weekly | int > 0
- atlas_prometheus_pull_keep_monthly | int > 0
- atlas_prometheus_pull_max_age_hours | int > 0
- atlas_backup_prometheus_mountpoint.startswith(atlas_mount_root ~ '/')
fail_msg: Define the Atlas backup destination, source account, and retention before enabling the pull.
when: atlas_manage_prometheus_backup_pull | bool
- name: Validate Atlas Prometheus backup pull calendar
tags: [atlas, backup, prometheus_backup]
ansible.builtin.command:
argv: [systemd-analyze, calendar, "{{ atlas_prometheus_pull_calendar }}"]
changed_when: false
check_mode: false
when: atlas_manage_prometheus_backup_pull | bool
- name: Create private Atlas Prometheus backup version directory
tags: [atlas, backup, prometheus_backup]
ansible.builtin.file:
path: "{{ atlas_backup_prometheus_mountpoint }}/snapshots"
state: directory
owner: root
group: root
mode: "0700"
when: atlas_manage_prometheus_backup_pull | bool
- name: Install Atlas Prometheus backup pull helper
tags: [atlas, backup, prometheus_backup]
ansible.builtin.template:
src: atlas-prometheus-pull.sh.j2
dest: /usr/local/sbin/atlas-prometheus-pull
owner: root
group: root
mode: "0750"
when: atlas_manage_prometheus_backup_pull | bool
- name: Install Atlas Prometheus backup retention helper
tags: [atlas, backup, prometheus_backup]
ansible.builtin.copy:
src: atlas-prometheus-prune.py
dest: /usr/local/libexec/atlas-prometheus-prune
owner: root
group: root
mode: "0750"
when: atlas_manage_prometheus_backup_pull | bool
- name: Install Atlas Prometheus backup pull systemd units
tags: [atlas, backup, prometheus_backup]
ansible.builtin.template:
src: "{{ item }}.j2"
dest: "/etc/systemd/system/{{ item }}"
owner: root
group: root
mode: "0644"
loop:
- atlas-prometheus-pull.service
- atlas-prometheus-pull.timer
loop_control:
label: "{{ item }}"
register: atlas_prometheus_pull_units
when: atlas_manage_prometheus_backup_pull | bool
- name: Reload systemd after Atlas Prometheus pull unit changes
tags: [atlas, backup, prometheus_backup]
ansible.builtin.systemd:
daemon_reload: true
when:
- atlas_manage_prometheus_backup_pull | bool
- atlas_prometheus_pull_units is changed
- not ansible_check_mode
- name: Enable Atlas Prometheus pull timer only after explicit activation
tags: [atlas, backup, prometheus_backup]
ansible.builtin.systemd:
name: atlas-prometheus-pull.timer
enabled: true
state: started
when:
- atlas_manage_prometheus_backup_pull | bool
- atlas_prometheus_pull_start_timer | bool
- not ansible_check_mode

View File

@@ -0,0 +1,27 @@
---
- name: Validate requested Atlas post-restore relabel paths
tags: [atlas, restorecon, recovery]
ansible.builtin.assert:
that:
- item is string
- item.startswith(atlas_mount_root ~ '/')
- item != atlas_mount_root
fail_msg: >-
Post-restore relabeling accepts only explicit paths below the Atlas pool
mount root. Do not relabel the whole pool during routine provisioning.
loop: "{{ atlas_restorecon_paths }}"
when: atlas_restorecon_paths | length > 0
- name: Restore SELinux labels on explicitly restored Atlas paths
tags: [atlas, restorecon, recovery]
ansible.builtin.command:
argv:
- restorecon
- -RFv
- "{{ item }}"
register: atlas_restorecon_result
changed_when: atlas_restorecon_result.stdout | length > 0
loop: "{{ atlas_restorecon_paths }}"
when:
- atlas_restorecon_paths | length > 0
- not ansible_check_mode

View File

@@ -0,0 +1,64 @@
---
- name: Require an existing Unix account for Atlas Samba
ansible.builtin.getent:
database: passwd
key: "{{ atlas_samba_account.username }}"
- name: Read Atlas Samba account
ansible.builtin.command:
argv:
- pdbedit
- --list
- --user
- "{{ atlas_samba_account.username }}"
register: atlas_samba_account_check
changed_when: false
failed_when: false
- name: Check Atlas Samba password marker
ansible.builtin.stat:
path: "{{ atlas_samba_password_marker_dir }}/{{ atlas_samba_account.username }}.sha256"
register: atlas_samba_password_marker_stat
- name: Read Atlas Samba password marker
ansible.builtin.slurp:
src: "{{ atlas_samba_password_marker_dir }}/{{ atlas_samba_account.username }}.sha256"
register: atlas_samba_password_marker_content
no_log: true
when: atlas_samba_password_marker_stat.stat.exists
- name: Determine whether Atlas Samba credentials must change
ansible.builtin.set_fact:
atlas_samba_password_digest: "{{ atlas_samba_account.password | hash('sha256') }}"
atlas_samba_password_update_required: >-
{{
atlas_samba_account_check.rc != 0
or not atlas_samba_password_marker_stat.stat.exists
or (
atlas_samba_password_marker_content.content | default('')
| b64decode | trim
) != (atlas_samba_account.password | hash('sha256'))
}}
no_log: true
- name: Set Atlas Samba account password
ansible.builtin.command:
argv:
- smbpasswd
- -s
- -a
- "{{ atlas_samba_account.username }}"
stdin: "{{ atlas_samba_account.password }}\n{{ atlas_samba_account.password }}"
changed_when: true
no_log: true
when: atlas_samba_password_update_required | bool
- name: Record managed Atlas Samba password digest
ansible.builtin.copy:
content: "{{ atlas_samba_password_digest }}\n"
dest: "{{ atlas_samba_password_marker_dir }}/{{ atlas_samba_account.username }}.sha256"
owner: root
group: root
mode: "0600"
no_log: true
when: atlas_samba_password_update_required | bool

View File

@@ -0,0 +1,271 @@
- name: Require completed Atlas firewall placeholders
tags: [atlas, sharing, services]
ansible.builtin.assert:
that:
- atlas_lan_subnet != 'CHANGEME_LAN_SUBNET'
- atlas_aegis_ip != 'CHANGEME_AEGIS_IP'
- atlas_firewalld_zone | length > 0
- ansible_facts.default_ipv4.interface | default('') | length > 0
fail_msg: Replace the Atlas LAN, Aegis and firewall-zone placeholders.
when: atlas_manage_firewall | bool
- name: Apply Atlas firewalld rich rules
tags: [atlas, sharing, services]
ansible.posix.firewalld:
rich_rule: "{{ item }}"
permanent: true
immediate: true
state: enabled
zone: "{{ atlas_firewalld_zone }}"
loop: "{{ atlas_firewalld_rich_rules }}"
loop_control:
label: "{{ item }}"
when: atlas_manage_firewall | bool
- name: Assign primary Atlas LAN interface to managed firewalld zone
tags: [atlas, sharing, services]
ansible.posix.firewalld:
interface: "{{ ansible_facts.default_ipv4.interface }}"
zone: "{{ atlas_firewalld_zone }}"
state: enabled
permanent: true
immediate: true
when: atlas_manage_firewall | bool
- name: Remove unrestricted Atlas services from firewalld zone
tags: [atlas, sharing, services]
ansible.posix.firewalld:
service: "{{ item }}"
permanent: true
immediate: true
state: disabled
zone: "{{ atlas_firewalld_zone }}"
loop: "{{ atlas_firewalld_restricted_services }}"
loop_control:
label: "{{ item }}"
when: atlas_manage_firewall | bool
- name: Render Atlas NFS exports
tags: [atlas, sharing]
ansible.builtin.template:
src: atlas.exports.j2
dest: /etc/exports.d/atlas.exports
owner: root
group: root
mode: "0644"
notify: Reload NFS exports
when: atlas_manage_sharing | bool
- name: Ensure Atlas NFS configuration drop-in directory exists
tags: [atlas, sharing]
ansible.builtin.file:
path: /etc/nfs.conf.d
state: directory
owner: root
group: root
mode: "0755"
when: atlas_manage_sharing | bool
- name: Configure Atlas NFSv4-only service
tags: [atlas, sharing]
ansible.builtin.template:
src: atlas-nfs.conf.j2
dest: /etc/nfs.conf.d/atlas.conf
owner: root
group: root
mode: "0644"
notify: Restart NFS server
when: atlas_manage_sharing | bool
- name: Mask Atlas NFSv3 RPC services
tags: [atlas, sharing, services]
ansible.builtin.systemd:
name: "{{ item }}"
enabled: false
state: stopped
masked: true
loop:
- rpc-statd.service
- rpcbind.service
- rpcbind.socket
loop_control:
label: "{{ item }}"
when: atlas_manage_sharing | bool
- name: Ensure Atlas NFS mount daemon drop-in directory exists
tags: [atlas, sharing, services]
ansible.builtin.file:
path: /etc/systemd/system/nfs-mountd.service.d
state: directory
owner: root
group: root
mode: "0755"
when: atlas_manage_sharing | bool
- name: Disable Atlas NFSv3 mount daemon listeners
tags: [atlas, sharing, services]
ansible.builtin.template:
src: nfs-mountd-v4only.conf.j2
dest: /etc/systemd/system/nfs-mountd.service.d/v4only.conf
owner: root
group: root
mode: "0644"
notify: Restart NFS mount daemon
when: atlas_manage_sharing | bool
- name: Enable SELinux access for Atlas file sharing
tags: [atlas, sharing, services]
ansible.posix.seboolean:
name: "{{ item }}"
state: true
persistent: true
loop: "{{ atlas_selinux_booleans }}"
loop_control:
label: "{{ item }}"
when:
- atlas_manage_sharing | bool
- (ansible_facts['selinux'] | default({})).get('status', 'disabled') == 'enabled'
- name: Render Atlas Samba configuration
tags: [atlas, sharing]
ansible.builtin.template:
src: smb.conf.j2
dest: /etc/samba/smb.conf
owner: root
group: root
mode: "0644"
validate: "testparm --suppress-prompt %s"
notify: Restart Samba service
when: atlas_manage_sharing | bool
- name: Require Vault-backed Atlas Samba accounts
tags: [atlas, sharing]
ansible.builtin.assert:
that:
- atlas_samba_encryption in ['required', 'desired']
- atlas_samba_accounts | length > 0
- >-
atlas_samba_accounts | map(attribute='username') | list
| difference(atlas_samba_valid_users) | length == 0
- >-
atlas_samba_valid_users
| difference(atlas_samba_accounts | map(attribute='username') | list) | length == 0
- atlas_samba_accounts | selectattr('password', 'equalto', '') | list | length == 0
fail_msg: Define every authorized Samba account and its Vault-backed password.
no_log: true
when: atlas_manage_sharing | bool
- name: Ensure Atlas Samba private state directory exists
tags: [atlas, sharing]
ansible.builtin.file:
path: "{{ atlas_samba_password_marker_dir }}"
state: directory
owner: root
group: root
mode: "0700"
when: atlas_manage_sharing | bool
- name: Manage Vault-backed Atlas Samba credentials
tags: [atlas, sharing]
ansible.builtin.include_tasks: samba_account.yml
loop: "{{ atlas_samba_accounts }}"
loop_control:
loop_var: atlas_samba_account
label: "{{ atlas_samba_account.username }}"
no_log: true
when: atlas_manage_sharing | bool
- name: Enable Atlas file-sharing services
tags: [atlas, sharing, services]
ansible.builtin.systemd:
name: "{{ item }}"
enabled: true
state: started
loop:
- nfs-server.service
- smb.service
loop_control:
label: "{{ item }}"
when: atlas_manage_sharing | bool
- name: Gather effective Atlas firewalld zone configuration
tags: [atlas, sharing, services, security]
ansible.posix.firewalld_info:
zones:
- "{{ atlas_firewalld_zone }}"
register: atlas_firewalld_info
when:
- atlas_manage_firewall | bool
- not ansible_check_mode
- name: Verify effective Atlas firewalld restrictions
tags: [atlas, sharing, services, security]
ansible.builtin.assert:
that:
- atlas_firewalld_zone in atlas_firewalld_info.firewalld_info.zones
- >-
ansible_facts.default_ipv4.interface
in atlas_firewalld_info.firewalld_info.zones[atlas_firewalld_zone].interfaces
- >-
atlas_firewalld_restricted_services
| intersect(atlas_firewalld_info.firewalld_info.zones[atlas_firewalld_zone].services)
| length == 0
fail_msg: >-
The effective Atlas firewalld zone does not restrict the primary LAN
interface and unmanaged service exposure as declared.
when:
- atlas_manage_firewall | bool
- not ansible_check_mode
- name: Apply pending Atlas sharing handlers before verification
tags: [atlas, sharing, services, security]
ansible.builtin.meta: flush_handlers
when:
- atlas_manage_sharing | bool
- not ansible_check_mode
- name: Check active Atlas file-sharing services
tags: [atlas, sharing, services, security]
ansible.builtin.command:
argv:
- systemctl
- is-active
- --quiet
- "{{ item }}"
loop:
- nfs-server.service
- smb.service
loop_control:
label: "{{ item }}"
register: atlas_file_sharing_service_activity
changed_when: false
failed_when: false
when:
- atlas_manage_sharing | bool
- not ansible_check_mode
- name: Require active Atlas file-sharing services
tags: [atlas, sharing, services, security]
ansible.builtin.assert:
that:
- atlas_file_sharing_service_activity.results | map(attribute='rc') | list == [0, 0]
fail_msg: Atlas NFSv4 or SMB3 did not start after its managed configuration was applied.
when:
- atlas_manage_sharing | bool
- not ansible_check_mode
- name: Require Atlas file-sharing TCP listeners
tags: [atlas, sharing, services, security]
ansible.builtin.wait_for:
host: 127.0.0.1
port: "{{ item }}"
state: started
timeout: 15
loop:
- 2049
- 445
loop_control:
label: "{{ item }}/tcp"
when:
- atlas_manage_sharing | bool
- not ansible_check_mode

View File

@@ -0,0 +1,146 @@
---
- name: Define Atlas ZFS datasets
tags: [atlas, storage]
ansible.builtin.set_fact:
atlas_zfs_datasets:
- name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_archive }}"
mountpoint: "{{ atlas_archive_mountpoint }}"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
properties:
compression: zstd
mountpoint: "{{ atlas_archive_mountpoint }}"
- name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_services }}"
mountpoint: "{{ atlas_services_mountpoint }}"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
properties:
compression: zstd
mountpoint: "{{ atlas_services_mountpoint }}"
- name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_app_data }}"
mountpoint: "{{ atlas_app_data_mountpoint }}"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
properties:
compression: zstd
mountpoint: "{{ atlas_app_data_mountpoint }}"
- name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_navidrome }}"
mountpoint: "{{ atlas_navidrome_mountpoint }}"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
properties:
compression: zstd
mountpoint: "{{ atlas_navidrome_mountpoint }}"
- name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_syncthing }}"
mountpoint: "{{ atlas_syncthing_mountpoint }}"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
properties:
compression: lz4
mountpoint: "{{ atlas_syncthing_mountpoint }}"
- name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_media }}"
mountpoint: "{{ atlas_media_mountpoint }}"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
properties:
compression: lz4
mountpoint: "{{ atlas_media_mountpoint }}"
- name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_music }}"
mountpoint: "{{ atlas_music_mountpoint }}"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
properties:
compression: lz4
mountpoint: "{{ atlas_music_mountpoint }}"
- name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_backup }}"
mountpoint: "{{ atlas_backup_mountpoint }}"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
properties:
compression: lz4
mountpoint: "{{ atlas_backup_mountpoint }}"
reservation: "{{ atlas_zfs_backup_reservation }}"
- name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_host_backups }}"
mountpoint: "{{ atlas_host_backups_mountpoint }}"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
properties:
compression: lz4
mountpoint: "{{ atlas_host_backups_mountpoint }}"
- name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_backup_prometheus }}"
mountpoint: "{{ atlas_backup_prometheus_mountpoint }}"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
properties:
compression: lz4
mountpoint: "{{ atlas_backup_prometheus_mountpoint }}"
- name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_photobook }}"
mountpoint: "{{ atlas_photobook_mountpoint }}"
owner: "{{ atlas_immich_username }}"
group: "{{ atlas_immich_group }}"
properties:
compression: lz4
mountpoint: "{{ atlas_photobook_mountpoint }}"
- name: Require completed Atlas storage placeholders
tags: [atlas, storage]
ansible.builtin.assert:
that:
- atlas_zfs_pool != 'CHANGEME_ZFS_POOL'
- atlas_mount_root != '/CHANGEME_ATLAS_MOUNT_ROOT'
- atlas_mount_root.startswith('/')
- (atlas_zfs_datasets | map(attribute='name') | unique | list | length) == (atlas_zfs_datasets | length)
- (atlas_zfs_datasets | map(attribute='mountpoint') | unique | list | length) == (atlas_zfs_datasets | length)
fail_msg: >-
Replace the Atlas ZFS pool and mount-root placeholders, then set
atlas_manage_storage to true before managing datasets.
when: atlas_manage_storage | bool
- name: Check whether the configured Atlas ZFS pool exists
tags: [atlas, storage]
ansible.builtin.command:
argv:
- zpool
- list
- -H
- -o
- name
- "{{ atlas_zfs_pool }}"
register: atlas_zfs_pool_check
changed_when: false
failed_when: false
when: atlas_manage_storage | bool
- name: Require the configured Atlas ZFS pool
tags: [atlas, storage]
ansible.builtin.assert:
that:
- atlas_zfs_pool_check.rc == 0
fail_msg: >-
The configured Atlas ZFS pool does not exist. The Atlas profile only
manages child datasets and never creates pools.
when: atlas_manage_storage | bool
- name: Manage Atlas ZFS datasets declaratively
tags: [atlas, storage]
community.general.zfs:
name: "{{ item.name }}"
state: present
extra_zfs_properties: "{{ item.properties }}"
loop: "{{ atlas_zfs_datasets }}"
loop_control:
label: "{{ item.name }}"
when: atlas_manage_storage | bool
- name: Set Atlas dataset ownership
tags: [atlas, storage]
ansible.builtin.file:
path: "{{ item.mountpoint }}"
state: directory
owner: "{{ item.owner }}"
group: "{{ item.group }}"
mode: "0770"
loop: "{{ atlas_zfs_datasets }}"
loop_control:
label: "{{ item.mountpoint }}"
when: atlas_manage_storage | bool

View File

@@ -0,0 +1,145 @@
---
- name: Validate Atlas offline USB backup configuration
tags: [atlas, storage, backup, usb_backup]
ansible.builtin.assert:
that:
- atlas_manage_storage | bool
- atlas_zfs_pool != 'CHANGEME_ZFS_POOL'
- atlas_mount_root.startswith('/')
- atlas_usb_backup_luks_uuid is match('^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$')
- atlas_usb_backup_fs_uuid is match('^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$')
- atlas_usb_backup_luks_uuid != atlas_usb_backup_fs_uuid
- atlas_usb_backup_mapper_name is match('^[a-z][a-z0-9_-]*$')
- atlas_usb_backup_min_free_bytes | int > 0
- atlas_usb_backup_snapshot_prefix is match('^[a-z0-9][a-z0-9_-]*$')
- atlas_usb_backup_snapshot_prefix != atlas_borg_snapshot_prefix
- atlas_usb_backup_snapshot_prefix != atlas_zfs_snapshot_prefix
fail_msg: >-
The manual Atlas USB backup needs verified LUKS and ext4 UUIDs, a safe
mapper name, positive free-space reserve, and a unique snapshot prefix.
when: atlas_manage_usb_backup | bool
- name: Install rsync for the Atlas offline USB backup
tags: [atlas, storage, backup, usb_backup]
ansible.builtin.dnf:
name: rsync
state: present
when: atlas_manage_usb_backup | bool
- name: Install the manual Atlas offline USB backup helper
tags: [atlas, storage, backup, usb_backup]
ansible.builtin.template:
src: atlas-usb-backup.sh.j2
dest: /usr/local/sbin/atlas-usb-backup
owner: root
group: root
mode: "0750"
when: atlas_manage_usb_backup | bool
- name: Install the Atlas USB snapshot cleanup helper
tags: [atlas, storage, backup, usb_backup]
ansible.builtin.template:
src: atlas-usb-snapshot-cleanup.sh.j2
dest: /usr/local/sbin/atlas-usb-snapshot-cleanup
owner: root
group: root
mode: "0750"
when: atlas_manage_usb_backup | bool
- name: Install the manual Atlas offline USB backup service
tags: [atlas, storage, backup, usb_backup]
ansible.builtin.template:
src: atlas-usb-backup.service.j2
dest: /etc/systemd/system/atlas-usb-backup.service
owner: root
group: root
mode: "0644"
when: atlas_manage_usb_backup | bool
- name: Reload systemd for the Atlas offline USB backup service
tags: [atlas, storage, backup, usb_backup]
ansible.builtin.systemd:
daemon_reload: true
when:
- atlas_manage_usb_backup | bool
- not ansible_check_mode
- name: Validate the 45Drives Atlas USB reminder configuration
tags: [atlas, backup, usb_reminder]
ansible.builtin.assert:
that:
- atlas_manage_usb_backup | bool
- atlas_usb_reminder_calendar | length > 0
- atlas_usb_reminder_notifier.startswith('/opt/45drives/houston/')
fail_msg: >-
Enable the manual USB backup and declare a systemd calendar before
enabling its 45Drives Alerts reminder.
when: atlas_manage_usb_reminder | bool
- name: Validate the Atlas USB reminder calendar
tags: [atlas, backup, usb_reminder]
ansible.builtin.command:
argv:
- systemd-analyze
- calendar
- "{{ atlas_usb_reminder_calendar }}"
changed_when: false
check_mode: false
when: atlas_manage_usb_reminder | bool
- name: Inspect the existing 45Drives notifier
tags: [atlas, backup, usb_reminder]
ansible.builtin.stat:
path: "{{ atlas_usb_reminder_notifier }}"
register: atlas_usb_reminder_notifier_file
when: atlas_manage_usb_reminder | bool
- name: Require the configured 45Drives notifier for USB reminders
tags: [atlas, backup, usb_reminder]
ansible.builtin.assert:
that:
- atlas_usb_reminder_notifier_file.stat.executable | default(false)
fail_msg: >-
The existing 45Drives Houston notifier must be executable.
when: atlas_manage_usb_reminder | bool
- name: Install the 45Drives Atlas USB reminder helper
tags: [atlas, backup, usb_reminder]
ansible.builtin.template:
src: atlas-usb-reminder.py.j2
dest: /usr/local/libexec/atlas-usb-reminder
owner: root
group: root
mode: "0750"
when: atlas_manage_usb_reminder | bool
- name: Install the 45Drives Atlas USB reminder service
tags: [atlas, backup, usb_reminder]
ansible.builtin.template:
src: atlas-usb-reminder.service.j2
dest: /etc/systemd/system/atlas-usb-reminder.service
owner: root
group: root
mode: "0644"
when: atlas_manage_usb_reminder | bool
- name: Install the 45Drives Atlas USB reminder timer
tags: [atlas, backup, usb_reminder]
ansible.builtin.template:
src: atlas-usb-reminder.timer.j2
dest: /etc/systemd/system/atlas-usb-reminder.timer
owner: root
group: root
mode: "0644"
when: atlas_manage_usb_reminder | bool
- name: Enable only the Atlas USB notification reminder timer
tags: [atlas, backup, usb_reminder]
ansible.builtin.systemd:
name: atlas-usb-reminder.timer
enabled: true
state: started
daemon_reload: true
when:
- atlas_manage_usb_reminder | bool
- not ansible_check_mode

View File

@@ -0,0 +1,175 @@
---
- name: Validate Atlas ZFS snapshot policy
tags: [atlas, storage, snapshots]
ansible.builtin.assert:
that:
- atlas_manage_storage | bool
- atlas_zfs_pool != 'CHANGEME_ZFS_POOL'
- atlas_zfs_snapshot_prefix is match('^[a-z0-9][a-z0-9_-]*$')
- atlas_zfs_snapshot_policies | length > 0
- >-
(atlas_zfs_snapshot_policies | map(attribute='name') | unique | list | length)
== (atlas_zfs_snapshot_policies | length)
fail_msg: >-
Enable Atlas storage and declare a non-empty snapshot policy with a safe
prefix and unique policy names before managing automatic snapshots.
when: atlas_manage_zfs_snapshots | bool
- name: Validate Atlas ZFS snapshot policy entries
tags: [atlas, storage, snapshots]
ansible.builtin.assert:
that:
- item.name is match('^[a-z][a-z0-9_-]*$')
- item.keep | int > 0
- item.calendar | length > 0
fail_msg: >-
Every Atlas snapshot policy needs a safe name, a positive retention
count, and a systemd calendar expression.
loop: "{{ atlas_zfs_snapshot_policies }}"
loop_control:
label: "{{ item.name | default('unnamed') }}"
when: atlas_manage_zfs_snapshots | bool
- name: Validate Atlas ZFS snapshot calendars
tags: [atlas, storage, snapshots]
ansible.builtin.command:
argv:
- systemd-analyze
- calendar
- "{{ item.calendar }}"
loop: "{{ atlas_zfs_snapshot_policies }}"
loop_control:
label: "{{ item.name }}: {{ item.calendar }}"
changed_when: false
check_mode: false
when: atlas_manage_zfs_snapshots | bool
- name: Install Atlas ZFS snapshot and retention helper
tags: [atlas, storage, snapshots]
ansible.builtin.template:
src: atlas-zfs-snapshot.sh.j2
dest: /usr/local/sbin/atlas-zfs-snapshot
owner: root
group: root
mode: "0750"
when: atlas_manage_zfs_snapshots | bool
- name: Install Atlas ZFS snapshot systemd service
tags: [atlas, storage, snapshots]
ansible.builtin.template:
src: atlas-zfs-snapshot@.service.j2
dest: /etc/systemd/system/atlas-zfs-snapshot@.service
owner: root
group: root
mode: "0644"
when: atlas_manage_zfs_snapshots | bool
- name: Install Atlas ZFS snapshot systemd timers
tags: [atlas, storage, snapshots]
ansible.builtin.template:
src: atlas-zfs-snapshot.timer.j2
dest: "/etc/systemd/system/atlas-zfs-snapshot-{{ item.name }}.timer"
owner: root
group: root
mode: "0644"
loop: "{{ atlas_zfs_snapshot_policies }}"
loop_control:
label: "{{ item.name }}"
when: atlas_manage_zfs_snapshots | bool
- name: Enable Atlas ZFS snapshot systemd timers
tags: [atlas, storage, snapshots]
ansible.builtin.systemd:
name: "atlas-zfs-snapshot-{{ item.name }}.timer"
enabled: true
state: started
daemon_reload: true
loop: "{{ atlas_zfs_snapshot_policies }}"
loop_control:
label: "{{ item.name }}"
when:
- atlas_manage_zfs_snapshots | bool
- not ansible_check_mode
- name: Validate Atlas ZFS scrub policy
tags: [atlas, storage, scrub]
ansible.builtin.assert:
that:
- atlas_manage_storage | bool
- atlas_zfs_pool != 'CHANGEME_ZFS_POOL'
- atlas_zfs_scrub_calendar | length > 0
fail_msg: >-
Enable Atlas storage and declare a systemd calendar expression before
managing periodic ZFS scrubs.
when: atlas_manage_zfs_scrub | bool
- name: Validate Atlas ZFS scrub calendar
tags: [atlas, storage, scrub]
ansible.builtin.command:
argv:
- systemd-analyze
- calendar
- "{{ atlas_zfs_scrub_calendar }}"
changed_when: false
check_mode: false
when: atlas_manage_zfs_scrub | bool
- name: Require OpenZFS scrub systemd units
tags: [atlas, storage, scrub]
ansible.builtin.command:
argv:
- systemctl
- cat
- "{{ item }}"
loop:
- "zfs-scrub@{{ atlas_zfs_pool }}.service"
- "zfs-scrub-monthly@{{ atlas_zfs_pool }}.timer"
- "zfs-scrub-weekly@{{ atlas_zfs_pool }}.timer"
loop_control:
label: "{{ item }}"
changed_when: false
check_mode: false
when: atlas_manage_zfs_scrub | bool
- name: Create Atlas ZFS scrub timer override directory
tags: [atlas, storage, scrub]
ansible.builtin.file:
path: "/etc/systemd/system/zfs-scrub-monthly@{{ atlas_zfs_pool }}.timer.d"
state: directory
owner: root
group: root
mode: "0755"
when: atlas_manage_zfs_scrub | bool
- name: Configure Atlas ZFS monthly scrub schedule
tags: [atlas, storage, scrub]
ansible.builtin.template:
src: atlas-zfs-scrub-timer.conf.j2
dest: >-
/etc/systemd/system/zfs-scrub-monthly@{{ atlas_zfs_pool }}.timer.d/override.conf
owner: root
group: root
mode: "0644"
when: atlas_manage_zfs_scrub | bool
- name: Disable the conflicting weekly OpenZFS scrub timer
tags: [atlas, storage, scrub]
ansible.builtin.systemd:
name: "zfs-scrub-weekly@{{ atlas_zfs_pool }}.timer"
enabled: false
state: stopped
daemon_reload: true
when:
- atlas_manage_zfs_scrub | bool
- not ansible_check_mode
- name: Enable the Atlas monthly OpenZFS scrub timer
tags: [atlas, storage, scrub]
ansible.builtin.systemd:
name: "zfs-scrub-monthly@{{ atlas_zfs_pool }}.timer"
enabled: true
state: started
daemon_reload: true
when:
- atlas_manage_zfs_scrub | bool
- not ansible_check_mode

View File

@@ -0,0 +1,24 @@
# Managed by Ansible. Do not edit manually.
PermitRootLogin no
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
AuthenticationMethods publickey
AuthorizedKeysFile {{
atlas_admin_ssh_keys
| map(attribute='name')
| map('regex_replace', '^', '%h/.ssh/authorized_keys.d/')
| join(' ')
}}
X11Forwarding no
AllowTcpForwarding {{ atlas_ssh_allow_tcp_forwarding }}
AllowAgentForwarding no
GatewayPorts no
PermitTunnel no
PermitUserEnvironment no
MaxAuthTries 3
LoginGraceTime 30
ClientAliveInterval 300
ClientAliveCountMax 2
LogLevel VERBOSE
AllowUsers {{ atlas_admin_username }}

View File

@@ -0,0 +1,41 @@
[Unit]
Description=Back up Atlas ZFS datasets to the encrypted Borg repository
Documentation=man:borg-create(1) man:borg-prune(1) man:borg-compact(1)
Requires=zfs.target
Wants=network-online.target
After=zfs.target network-online.target
StartLimitIntervalSec=6h
StartLimitBurst=3
ConditionFileIsExecutable=/usr/local/sbin/atlas-borg-backup
ConditionPathExists={{ atlas_borg_passphrase_path }}
ConditionPathExists={{ atlas_borg_ssh_private_key_path }}
ConditionPathExists={{ atlas_borg_known_hosts_path }}
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/atlas-borg-backup
ExecStopPost=+/usr/local/sbin/atlas-borg-snapshot-cleanup
User=root
Group=root
UMask=0077
SuccessExitStatus=1
Restart=on-failure
RestartSec=30m
TimeoutStartSec=infinity
RuntimeDirectory=atlas-borg
RuntimeDirectoryMode=0750
Nice=15
IOSchedulingClass=best-effort
IOSchedulingPriority=7
NoNewPrivileges=true
PrivateMounts=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict
ReadWritePaths={{ atlas_borg_cache_dir }} {{ atlas_borg_config_dir }} /run/atlas-borg /run/lock
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictRealtime=true
LockPersonality=true

View File

@@ -0,0 +1,215 @@
#!/usr/bin/env bash
set -Eeuo pipefail
export LC_ALL=C.utf8
export PATH=/usr/sbin:/usr/bin:/sbin:/bin
export BORG_CACHE_DIR={{ atlas_borg_cache_dir | quote }}
export BORG_CONFIG_DIR={{ atlas_borg_config_dir | quote }}
export BORG_PASSCOMMAND={{ ('cat ' ~ atlas_borg_passphrase_path) | quote }}
export BORG_RSH={{ atlas_borg_ssh_wrapper_path | quote }}
readonly pool={{ atlas_zfs_pool | quote }}
readonly mount_root={{ atlas_mount_root | quote }}
readonly repository={{ ('ssh://' ~ atlas_borg_repository_user ~ '@' ~ atlas_borg_repository_host
~ ':' ~ (atlas_borg_repository_port | string) ~ '/' ~ atlas_borg_repository_path) | quote }}
readonly remote_path={{ atlas_borg_remote_path | quote }}
readonly archive_prefix={{ atlas_borg_archive_prefix | quote }}
readonly snapshot_prefix={{ atlas_borg_snapshot_prefix | quote }}
readonly compression={{ atlas_borg_compression | quote }}
readonly stage=/run/atlas-borg/source
readonly snapshot_marker=/run/atlas-borg/snapshot-name
readonly borg_user={{ atlas_borg_username | quote }}
readonly borg_group={{ atlas_borg_group | quote }}
readonly borg_home={{ atlas_borg_home | quote }}
readonly borg_lock={{ atlas_borg_lock_path | quote }}
readonly progress_filter=/usr/local/libexec/atlas-borg-progress
snapshot_name=""
mounted_targets=()
# Invoked through the EXIT trap below.
# shellcheck disable=SC2329
cleanup() {
local status=$?
local cleanup_status=0
local index
local source_mount_failed=false
trap - EXIT HUP INT TERM
set +e
{% raw %}
for ((index = ${#mounted_targets[@]} - 1; index >= 0; index--)); do
{% endraw %}
if mountpoint -q "${mounted_targets[$index]}" && ! umount -R "${mounted_targets[$index]}"; then
printf 'Source snapshot mount cleanup failed: %s\n' "${mounted_targets[$index]}" >&2
source_mount_failed=true
fi
if mountpoint -q "${mounted_targets[$index]}"; then
printf 'Source snapshot mount is still active: %s\n' "${mounted_targets[$index]}" >&2
source_mount_failed=true
else
rmdir -- "${mounted_targets[$index]}" 2>/dev/null || true
fi
done
if [[ "$source_mount_failed" == false ]]; then
if [[ -d "$stage" ]]; then
rmdir -- "$stage" 2>/dev/null || cleanup_status=2
fi
else
cleanup_status=2
printf 'Source bind mount cleanup failed; keeping the snapshot for recovery\n' >&2
fi
if ((status == 0 && cleanup_status != 0)); then
status=$cleanup_status
fi
exit "$status"
}
trap cleanup EXIT
trap 'exit 143' HUP INT TERM
run_as_borg() {
setpriv \
--reuid "$borg_user" \
--regid "$borg_group" \
--clear-groups \
--inh-caps=-all,+dac_read_search \
--ambient-caps=-all,+dac_read_search \
--bounding-set=-all,+dac_read_search \
-- env HOME="$borg_home" USER="$borg_user" LOGNAME="$borg_user" "$@"
}
exec 8>"$borg_lock"
flock 8
exec 9>/run/lock/atlas-zfs-snapshot.lock
zpool list -H -o name "$pool" >/dev/null
rm -rf "$stage"
mkdir -p "$stage"
# Keep systemd's root:root ownership of RuntimeDirectory: changing it makes
# ExecStopPost re-chown its contents, which SELinux denies for the marker.
setfacl -m "u:${borg_user}:rx" /run/atlas-borg
chown root:"$borg_group" "$stage"
chmod 0750 /run/atlas-borg "$stage"
flock 9
while IFS= read -r stale_snapshot; do
stale_suffix="${stale_snapshot#"${pool}@${snapshot_prefix}-"}"
if [[ "$stale_suffix" =~ ^[0-9]{8}T[0-9]{6}Z$ ]]; then
zfs destroy -r "$stale_snapshot"
printf 'Removed stale Borg source snapshot %s\n' "$stale_snapshot"
fi
done < <(
zfs list -H -t snapshot -o name -r "$pool" |
grep -E "^${pool}@${snapshot_prefix}-[0-9]{8}T[0-9]{6}Z$" || true
)
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
readonly timestamp
snapshot_name="${snapshot_prefix}-${timestamp}"
readonly snapshot_name
printf '%s\n' "$snapshot_name" >"$snapshot_marker"
zfs snapshot -r "${pool}@${snapshot_name}"
flock -u 9
printf 'Created recursive Borg source snapshot %s@%s\n' "$pool" "$snapshot_name"
while IFS=$'\t' read -r dataset dataset_mountpoint mounted; do
if [[ "$mounted" != yes ]]; then
printf 'Dataset %s is not mounted; refusing an incomplete backup\n' "$dataset" >&2
exit 65
fi
if [[ "$dataset_mountpoint" != "$mount_root" && "$dataset_mountpoint" != "$mount_root/"* ]]; then
printf 'Dataset %s has unexpected mountpoint %s\n' "$dataset" "$dataset_mountpoint" >&2
exit 65
fi
dataset_suffix="${dataset#"$pool"}"
source_path="${dataset_mountpoint}/.zfs/snapshot/${snapshot_name}"
target_path="${stage}${dataset_suffix}"
mkdir -p "$target_path"
mount --bind "$source_path" "$target_path"
mounted_targets+=("$target_path")
mount -o remount,bind,ro "$target_path"
done < <(zfs list -H -o name,mountpoint,mounted -s name -r "$pool")
estimated_source_bytes=0
while IFS=$'\t' read -r source_snapshot logical_bytes; do
if [[ "$source_snapshot" == *"@${snapshot_name}" ]]; then
[[ "$logical_bytes" =~ ^[0-9]+$ ]] || {
printf 'Invalid logical size for Borg source snapshot %s\n' "$source_snapshot" >&2
exit 74
}
estimated_source_bytes=$((estimated_source_bytes + logical_bytes))
fi
done < <(zfs list -H -p -t snapshot -o name,logicalreferenced -r "$pool")
((estimated_source_bytes > 0)) || {
printf 'Could not estimate the Borg source snapshot size\n' >&2
exit 74
}
printf 'Estimated Borg source logical size: %s bytes (ZFS; progress percentage is approximate)\n' \
"$estimated_source_bytes"
archive="${archive_prefix}-${timestamp}"
readonly archive
borg_status=0
printf 'Starting Borg archive %s from snapshot %s@%s\n' "$archive" "$pool" "$snapshot_name"
set +e
(
cd /run/atlas-borg
run_as_borg borg --remote-path "$remote_path" --lock-wait 600 --log-json --progress create \
--show-rc \
--stats \
--checkpoint-interval 900 \
--compression "$compression" \
"${repository}::${archive}" \
source 2>&1
) | /usr/bin/python3 -u "$progress_filter" --estimated-total-bytes "$estimated_source_bytes"
create_pipeline_status=("${PIPESTATUS[@]}")
set -e
create_status=${create_pipeline_status[0]}
if ((create_pipeline_status[1] != 0)); then
printf 'Borg progress logging failed with status %s\n' "${create_pipeline_status[1]}" >&2
exit 2
fi
if ((create_status >= 2)); then
exit "$create_status"
fi
borg_status=$create_status
printf 'Borg archive %s created; applying retention\n' "$archive"
set +e
run_as_borg borg --remote-path "$remote_path" --lock-wait 600 prune \
--show-rc \
--list \
--glob-archives "${archive_prefix}-*" \
--keep-daily {{ atlas_borg_keep_daily | int }} \
--keep-weekly {{ atlas_borg_keep_weekly | int }} \
--keep-monthly {{ atlas_borg_keep_monthly | int }} \
"$repository"
prune_status=$?
set -e
if ((prune_status >= 2)); then
exit "$prune_status"
fi
if ((prune_status > borg_status)); then
borg_status=$prune_status
fi
printf 'Borg retention complete; compacting repository\n'
set +e
run_as_borg borg --remote-path "$remote_path" --lock-wait 600 compact \
--show-rc \
"$repository"
compact_status=$?
set -e
if ((compact_status >= 2)); then
exit "$compact_status"
fi
if ((compact_status > borg_status)); then
borg_status=$compact_status
fi
printf 'Borg backup %s completed with status %s\n' "$archive" "$borg_status"
exit "$borg_status"

View File

@@ -0,0 +1,12 @@
[Unit]
Description=Schedule the encrypted Atlas Borg backup
[Timer]
OnCalendar={{ atlas_borg_backup_calendar }}
Persistent=true
RandomizedDelaySec={{ atlas_borg_randomized_delay }}
AccuracySec=1min
Unit=atlas-borg-backup.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,32 @@
[Unit]
Description=Check the encrypted Atlas Borg repository
Documentation=man:borg-check(1)
Wants=network-online.target
After=network-online.target atlas-borg-backup.service
ConditionFileIsExecutable=/usr/local/sbin/atlas-borg-check
ConditionPathExists={{ atlas_borg_passphrase_path }}
ConditionPathExists={{ atlas_borg_ssh_private_key_path }}
ConditionPathExists={{ atlas_borg_known_hosts_path }}
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/atlas-borg-check
User={{ atlas_borg_username }}
Group={{ atlas_borg_group }}
UMask=0077
SuccessExitStatus=1
TimeoutStartSec=infinity
Nice=15
IOSchedulingClass=best-effort
IOSchedulingPriority=7
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict
ReadWritePaths={{ atlas_borg_cache_dir }} {{ atlas_borg_config_dir }}
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictRealtime=true
LockPersonality=true

View File

@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -Eeuo pipefail
export LC_ALL=C
export PATH=/usr/sbin:/usr/bin:/sbin:/bin
export BORG_CACHE_DIR={{ atlas_borg_cache_dir | quote }}
export BORG_CONFIG_DIR={{ atlas_borg_config_dir | quote }}
export BORG_PASSCOMMAND={{ ('cat ' ~ atlas_borg_passphrase_path) | quote }}
export BORG_RSH={{ atlas_borg_ssh_wrapper_path | quote }}
readonly repository={{ ('ssh://' ~ atlas_borg_repository_user ~ '@' ~ atlas_borg_repository_host
~ ':' ~ (atlas_borg_repository_port | string) ~ '/' ~ atlas_borg_repository_path) | quote }}
readonly remote_path={{ atlas_borg_remote_path | quote }}
readonly archive_prefix={{ atlas_borg_archive_prefix | quote }}
readonly borg_lock={{ atlas_borg_lock_path | quote }}
exec 8>"$borg_lock"
flock 8
exec borg --remote-path "$remote_path" --lock-wait 600 check \
--show-rc \
--glob-archives "${archive_prefix}-*" \
"$repository"

View File

@@ -0,0 +1,12 @@
[Unit]
Description=Schedule checks of the encrypted Atlas Borg repository
[Timer]
OnCalendar={{ atlas_borg_check_calendar }}
Persistent=true
RandomizedDelaySec={{ atlas_borg_randomized_delay }}
AccuracySec=1min
Unit=atlas-borg-check.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env bash
set -Eeuo pipefail
export PATH=/usr/sbin:/usr/bin:/sbin:/bin
readonly pool={{ atlas_zfs_pool | quote }}
readonly mount_root={{ atlas_mount_root | quote }}
readonly snapshot_prefix={{ atlas_borg_snapshot_prefix | quote }}
readonly marker=/run/atlas-borg/snapshot-name
[[ -e "$marker" ]] || exit 0
[[ -f "$marker" && ! -L "$marker" ]] || {
printf 'Unsafe Atlas Borg snapshot marker; leaving snapshots unchanged\n' >&2
exit 2
}
IFS= read -r snapshot_name <"$marker"
[[ "$snapshot_name" =~ ^${snapshot_prefix}-[0-9]{8}T[0-9]{6}Z$ ]] || {
printf 'Invalid Atlas Borg snapshot marker; leaving snapshots unchanged\n' >&2
exit 2
}
exec 9>/run/lock/atlas-zfs-snapshot.lock
flock 9
if zfs list -H -t snapshot -o name "${pool}@${snapshot_name}" >/dev/null 2>&1; then
# The private bind mounts are gone, but ZFS may leave its on-demand
# .zfs/snapshot mounts in the host namespace until explicitly unmounted.
snapshot_mounts=()
snapshot_sources=()
while IFS=$'\t' read -r dataset dataset_mountpoint; do
[[ "$dataset_mountpoint" == "$mount_root" || "$dataset_mountpoint" == "$mount_root/"* ]] || continue
snapshot_mounts+=("${dataset_mountpoint}/.zfs/snapshot/${snapshot_name}")
snapshot_sources+=("${dataset}@${snapshot_name}")
done < <(zfs list -H -o name,mountpoint -s name -r "$pool")
{% raw %}
for ((index = ${#snapshot_mounts[@]} - 1; index >= 0; index--)); do
{% endraw %}
mounted_source="$(findmnt -rn -M "${snapshot_mounts[$index]}" -o SOURCE || true)"
[[ -n "$mounted_source" ]] || continue
[[ "$mounted_source" == "${snapshot_sources[$index]}" ]] || {
printf 'Unexpected source on Atlas Borg snapshot mount: %s\n' \
"${snapshot_mounts[$index]}" >&2
exit 2
}
umount "${snapshot_mounts[$index]}"
done
zfs destroy -r "${pool}@${snapshot_name}"
printf 'Removed recursive Atlas Borg source snapshot %s@%s after backup exit\n' \
"$pool" "$snapshot_name"
fi

View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
# Borg receives CAP_DAC_READ_SEARCH only for local snapshot traversal. Drop it
# before starting the network transport so SSH runs as the plain service user.
exec setpriv \
--inh-caps=-all \
--ambient-caps=-all \
-- /usr/bin/ssh \
-i {{ atlas_borg_ssh_private_key_path | quote }} \
-p {{ atlas_borg_repository_port | int }} \
-o BatchMode=yes \
-o IdentitiesOnly=yes \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile={{ atlas_borg_known_hosts_path | quote }} \
-o ConnectTimeout=30 \
-o ServerAliveInterval=60 \
-o ServerAliveCountMax=3 \
"$@"

View File

@@ -0,0 +1,30 @@
# Managed by Ansible. Staging does not start automatically.
[Unit]
Description=Atlas rootless Gitea
RequiresMountsFor={{ atlas_gitea_mountpoint }}
[Container]
ContainerName=atlas-gitea
Image={{ atlas_gitea_image }}
UserNS=keep-id:uid={{ atlas_gitea_container_uid }},gid={{ atlas_gitea_container_gid }}
{% if atlas_gitea_production_enabled | bool %}
PublishPort={{ atlas_gitea_bind_address }}:{{ atlas_gitea_http_port }}:3000
PublishPort={{ atlas_gitea_bind_address }}:{{ atlas_gitea_ssh_port }}:2222
{% else %}
PublishPort={{ atlas_gitea_staging_bind_address }}:{{ atlas_gitea_staging_http_port }}:3000
PublishPort={{ atlas_gitea_staging_bind_address }}:{{ atlas_gitea_staging_ssh_port }}:2222
{% endif %}
Volume={{ atlas_gitea_mountpoint }}/data:/var/lib/gitea:Z
Volume={{ atlas_gitea_mountpoint }}/config:/etc/gitea:Z
NoNewPrivileges=true
DropCapability=all
[Service]
Restart=on-failure
RestartSec=10
TimeoutStartSec=900
{% if atlas_gitea_production_enabled | bool %}
[Install]
WantedBy=default.target
{% endif %}

View File

@@ -0,0 +1,20 @@
{
"pool": {{ atlas_zfs_pool | to_json }},
"backup_dataset": {{ (atlas_zfs_pool ~ '/' ~ atlas_zfs_dataset_backup) | to_json }},
"notifier": {{ atlas_monitor_notifier | to_json }},
"smart_devices": {{ atlas_monitor_smart_devices | to_json }},
"timers": {{ atlas_monitor_effective_timers | to_json }},
"failure_units": {{ atlas_monitor_effective_failure_units | to_json }},
"remote_capacity": {{ atlas_monitor_remote_capacity | to_json }},
"pool_warning_percent": {{ atlas_monitor_pool_warning_percent | int }},
"pool_critical_percent": {{ atlas_monitor_pool_critical_percent | int }},
"root_warning_percent": {{ atlas_monitor_root_warning_percent | int }},
"root_critical_percent": {{ atlas_monitor_root_critical_percent | int }},
"snapshot_warning_percent": {{ atlas_monitor_snapshot_warning_percent | int }},
"snapshot_critical_percent": {{ atlas_monitor_snapshot_critical_percent | int }},
"snapshot_growth_warning_gib_day": {{ atlas_monitor_snapshot_growth_warning_gib_day | int }},
"backup_growth_warning_gib_day": {{ atlas_monitor_backup_growth_warning_gib_day | int }},
"cpu_warning_c": {{ atlas_monitor_cpu_warning_c | int }},
"cpu_critical_c": {{ atlas_monitor_cpu_critical_c | int }},
"borg_max_runtime_days": {{ atlas_monitor_borg_max_runtime_days | int }}
}

View File

@@ -0,0 +1,25 @@
[Unit]
Description=Check Atlas pool, disks, capacity, temperatures and maintenance jobs
Wants=houston-dbus.service network-online.target
After=zfs.target houston-dbus.service network-online.target
ConditionFileIsExecutable=/usr/local/libexec/atlas-health-monitor
[Service]
Type=oneshot
ExecStart=/usr/local/libexec/atlas-health-monitor
User=root
Group=root
UMask=0077
StateDirectory=atlas-health-monitor
StateDirectoryMode=0700
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict
ReadWritePaths=/var/lib/atlas-health-monitor
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictRealtime=true
LockPersonality=true

View File

@@ -0,0 +1,11 @@
[Unit]
Description=Schedule Atlas health checks
[Timer]
OnCalendar={{ atlas_monitor_calendar }}
Persistent=true
RandomizedDelaySec=5min
Unit=atlas-health-monitor.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,14 @@
# Managed by Ansible. Password, keyring and MFA cookies are stored separately in /config.
apple_id={{ vault_atlas_icloudpd_apple_id }}
authentication_type=MFA
user=user
user_id=1000
group=group
group_id=1000
download_path=/home/user/iCloud
folder_structure={:%Y/%m/%d}
directory_permissions=750
file_permissions=640
download_interval=86400
auto_delete=false
delete_after_download=false

View File

@@ -0,0 +1,25 @@
# Managed by Ansible. Start automatically with the lingering admin user manager.
[Unit]
Description=Atlas rootless iCloud Photos Downloader
RequiresMountsFor={{ atlas_icloudpd_state_dir }} {{ atlas_icloudpd_photos_dir }}
[Container]
ContainerName=atlas-icloudpd
Image={{ atlas_icloudpd_image }}
UserNS=keep-id:uid=1000,gid=1000
# The image initialises its unprivileged UID 1000 account as container root.
User=0
# Upstream launcher requires traceroute for its iCloud reachability check.
AddCapability=NET_RAW
Environment=TZ={{ atlas_icloudpd_timezone }}
Volume={{ atlas_icloudpd_photos_dir }}:/home/user/iCloud:z
Volume={{ atlas_icloudpd_config_dir }}:/config:Z
NoNewPrivileges=true
[Service]
Restart=on-failure
RestartSec=300
TimeoutStartSec=900
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,27 @@
# Managed by Ansible. Do not edit manually.
[Unit]
Description=Atlas Immich machine learning
[Container]
ContainerName=atlas-immich-machine-learning
Image={{ atlas_immich_machine_learning_image }}
User={{ atlas_immich_uid }}:{{ atlas_immich_gid }}
{% for group_name in atlas_immich_supplementary_groups %}
GroupAdd={{ ansible_facts['getent_group'][group_name][1] }}
{% endfor %}
AddDevice=/dev/dri
Network=atlas-media.network
NetworkAlias=atlas-immich-machine-learning
Environment=TZ={{ atlas_timezone }}
Volume={{ atlas_immich_model_cache_dir }}:/cache:Z
Volume={{ atlas_immich_machine_learning_config_dir }}:/.config:Z
Volume={{ atlas_immich_machine_learning_cache_dir }}:/.cache:Z
NoNewPrivileges=true
DropCapability=NET_RAW
[Service]
Restart=always
TimeoutStartSec=900
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,22 @@
# Managed by Ansible. Do not edit manually.
[Unit]
Description=Atlas Immich PostgreSQL database
[Container]
ContainerName=atlas-immich-postgres
Image={{ atlas_immich_postgres_image }}
User={{ atlas_immich_uid }}:{{ atlas_immich_gid }}
Network=atlas-media.network
NetworkAlias=atlas-immich-postgres
EnvironmentFile=/etc/immich/immich.env
Volume={{ atlas_immich_postgres_data_dir }}:/var/lib/postgresql/data:Z
ShmSize=128m
NoNewPrivileges=true
DropCapability=NET_RAW
[Service]
Restart=always
TimeoutStartSec=900
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,20 @@
# Managed by Ansible. Do not edit manually.
[Unit]
Description=Atlas Immich Redis-compatible cache
[Container]
ContainerName=atlas-immich-redis
Image={{ atlas_immich_redis_image }}
User={{ atlas_immich_uid }}:{{ atlas_immich_gid }}
Network=atlas-media.network
NetworkAlias=atlas-immich-redis
Volume={{ atlas_immich_redis_data_dir }}:/data:Z
NoNewPrivileges=true
DropCapability=NET_RAW
[Service]
Restart=always
TimeoutStartSec=900
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,29 @@
# Managed by Ansible. Do not edit manually.
[Unit]
Description=Atlas Immich server
Requires=atlas-immich-redis.service atlas-immich-postgres.service atlas-immich-machine-learning.service
After=atlas-immich-redis.service atlas-immich-postgres.service atlas-immich-machine-learning.service
[Container]
ContainerName=atlas-immich-server
Image={{ atlas_immich_server_image }}
User={{ atlas_immich_uid }}:{{ atlas_immich_gid }}
{% for group_name in atlas_immich_supplementary_groups %}
GroupAdd={{ ansible_facts['getent_group'][group_name][1] }}
{% endfor %}
AddDevice=/dev/dri
Network=atlas-media.network
NetworkAlias=atlas-immich-server
EnvironmentFile=/etc/immich/immich.env
Volume={{ atlas_immich_upload_dir }}:/data:Z
Volume={{ atlas_photobook_mountpoint }}:/external/photobook:ro,Z
Volume=/etc/localtime:/etc/localtime:ro
NoNewPrivileges=true
DropCapability=NET_RAW
[Service]
Restart=always
TimeoutStartSec=900
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,3 @@
# Managed by Ansible. Do not edit manually.
[Network]
NetworkName=atlas-media

View File

@@ -0,0 +1,2 @@
[Unit]
OnFailure=atlas-monitor-failure@%n.service

View File

@@ -0,0 +1,19 @@
[Unit]
Description=Submit a 45Drives Alert for failed Atlas job %i
Requires=houston-dbus.service
After=houston-dbus.service
ConditionFileIsExecutable=/usr/local/libexec/atlas-health-monitor
[Service]
Type=oneshot
ExecStart=/usr/local/libexec/atlas-health-monitor --job-failed %i
User=root
Group=root
UMask=0077
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict
RestrictAddressFamilies=AF_UNIX
RestrictRealtime=true
LockPersonality=true

View File

@@ -0,0 +1,3 @@
# Managed by Ansible. Do not edit manually.
[nfsd]
vers3 = n

Some files were not shown because too many files have changed in this diff Show More