Compare commits

...

30 Commits

Author SHA1 Message Date
Fabio Scotto di Santolo
def3dbf313 Deploy temporary Atlas Nextcloud and ONLYOFFICE stack 2026-10-03 17:42:16 +02:00
Fabio Scotto di Santolo
a00602973c Remove completed Atlas Gitea migration tooling 2026-10-03 15:48:41 +02:00
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
67 changed files with 3419 additions and 532 deletions

245
AGENTS.md
View File

@@ -25,6 +25,9 @@ Ansible-driven personal infrastructure repo for Fedora and Void desktops, Fedora
- Preserve layering `all -> platform -> role -> desktop -> host`. - Preserve layering `all -> platform -> role -> desktop -> host`.
- Keep `ansible/site.yml` small; orchestration belongs there, implementation belongs in roles. - Keep `ansible/site.yml` small; orchestration belongs there, implementation belongs in roles.
- Prefer minimal, targeted edits. Preserve idempotency and existing ordering. - Prefer minimal, targeted edits. Preserve idempotency and existing ordering.
- 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, - 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 `hotfix/` for urgent production fixes, `release/` for release preparation, and `support/` for
maintained release lines. Do not use abbreviated prefixes such as `feat/`. maintained release lines. Do not use abbreviated prefixes such as `feat/`.
@@ -54,9 +57,24 @@ Ansible-driven personal infrastructure repo for Fedora and Void desktops, Fedora
- Emacs is disabled by default; temporary Emacs check: `ansible-playbook ansible/site.yml --limit <host> --tags emacs --check --diff -e emacs_enabled=true` - 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` - 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` - Mail bootstrap: `sh -n scripts/bootstrap_mail.sh` and `shellcheck scripts/bootstrap_mail.sh`
- Server compose render: `podman-compose -f /opt/docker/server/docker-compose.yml config` and `systemctl status podman-compose-server` - 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: - Atlas media stack:
`ansible-playbook ansible/site.yml --limit atlas --tags storage,sharing,containers --check --diff` `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 Nextcloud/ONLYOFFICE steady state:
`ansible-playbook ansible/site.yml --limit atlas --tags nextcloud --check --diff`
- Atlas iCloudPD storage and boot-started Quadlet:
`ansible-playbook ansible/site.yml --limit atlas --tags icloudpd --check --diff`
- Ongoing Gitea proxy configuration:
`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: - Atlas network/share hardening:
`ansible-playbook ansible/site.yml --limit atlas --tags hardening,sharing --check --diff` `ansible-playbook ansible/site.yml --limit atlas --tags hardening,sharing --check --diff`
- Atlas ZFS snapshot retention and scrub timers: - Atlas ZFS snapshot retention and scrub timers:
@@ -73,7 +91,8 @@ Ansible-driven personal infrastructure repo for Fedora and Void desktops, Fedora
`ansible-playbook ansible/site.yml --limit atlas --tags restorecon --check -e '{"atlas_restorecon_paths":["/zpool/archive"]}'` `ansible-playbook ansible/site.yml --limit atlas --tags restorecon --check -e '{"atlas_restorecon_paths":["/zpool/archive"]}'`
- Prometheus/Aegis WireGuard gateway: - Prometheus/Aegis WireGuard gateway:
`ansible-playbook ansible/site.yml --limit prometheus,aegis --tags wireguard --check --diff` `ansible-playbook ansible/site.yml --limit prometheus,aegis --tags wireguard --check --diff`
- DuckDNS config only: `ansible-playbook ansible/site.yml --limit prometheus --tags duckdns --check --diff` - Prometheus NPM Quadlet steady state (does not perform a cutover):
`ansible-playbook ansible/site.yml --limit prometheus --tags npm_quadlet --check --diff`
## Conventions ## Conventions
- Use FQCN Ansible modules. - Use FQCN Ansible modules.
@@ -117,21 +136,29 @@ The dotfile vars follow the same split: `desktop_common_dotfiles` carries mode-i
- Windows applications are installed manually and are not managed from the WSL profile. - Windows applications are installed manually and are not managed from the WSL profile.
## Rocky Server Notes ## Rocky Server Notes
- DuckDNS is rendered by `profile_server` from host-local `server_duckdns_domain` and - DuckDNS support is removed from the server profile, not feature-gated. No updater tasks,
`vault_duckdns_token`. Keep the rotated token in encrypted Vault or untracked local vars, never in templates or enablement variables remain. Prometheus uses its static IP and `fscotto.co`;
dotfiles. The private `~/duckdns/duck.sh` keeps the existing entrypoint; rendering uses `no_log` the local updater, log and cron job were already retired. External DuckDNS account/name
and disables diffs. Provisioning does not execute the updater or change its external schedule. and existing encrypted token are outside this removal and remain untouched.
- `rocky_server` is a child of both `platform_rocky` and `server`; `prometheus` is its active target. - `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 target must already provide `server_username` with local sudo access before the profile runs.
- The Rocky profile installs Podman and podman-compose, uses firewalld, preserves SELinux enforcement, and renders the - The Rocky profile installs Podman and podman-compose. Prometheus explicitly retires the legacy
existing Nginx Proxy Manager/Gitea Compose stack with a `podman-compose-server` systemd unit. PostgreSQL and Compose unit, files and final-export helper with `server_legacy_stack_retired: true`.
Navidrome are no longer part of the desired Prometheus configuration. The role does not stop or remove legacy Its approved opt-in cleanup removed old application data on 2026-10-03; normal runs do not
containers, delete `/opt/postgres/data`, start the Compose stack, update DNS, or cut over traffic. 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 - 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. `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. 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 - The completed Ubuntu-to-Rocky data migration script and its operational instructions
default and requires explicit source-stack quiescing before copying persistent Docker data with rsync. have been removed; current provisioning does not provide that one-time migration path.
- Completed Gitea owner-migration, migration-restore and final-export tasks, helpers and flags
are removed. Current Gitea marker/ownership checks, recurring backups and proxy configuration
remain intact. `server_gitea_proxy_enabled` controls ongoing proxy management only.
- Atlas-only OpenZFS, NFS, Samba, and Syncthing stay selected through Atlas host variables and must not - 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 leak into `rocky_server`. Cockpit plus its Navigator and Podman extensions are selected explicitly for
Prometheus through its host variables. Prometheus through its host variables.
@@ -164,7 +191,9 @@ The dotfile vars follow the same split: `desktop_common_dotfiles` carries mode-i
- `profile_backend_phase1` temporarily runs rootless Navidrome and Syncthing on Atlas until Uranus replaces - `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 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 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. 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 - `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 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 handshake. Prometheus opens `51820/udp`; Aegis is the LAN gateway. Its persistent IPv4 forwarding, narrowly scoped
@@ -253,26 +282,190 @@ successfully. The first monthly scrub remains a runtime check.
files are deployed; live read-only SSH, shell denial, and write denial were verified. On 2026-09-30 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 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 databases passed integrity checks and a restored Git repository passed `git fsck`. Both daily
timers are enabled for 02:00/03:00 Europe/Rome; their first scheduled results remain unverified. 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) - [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 remain intentionally distinct; `docs/atlas-sharing-decision.md` records the decision. No ACL or export
change is authorized by this decision. change is authorized by this decision.
### Priority 3 - Service expansion ### Priority 3 - Service expansion
- [ ] After data protection and recovery are validated, populate `/zpool/media/music` and validate Navidrome. - [x] Populate `/zpool/media/music` and validate Navidrome. On 2026-09-30, 21,158 files
- [ ] Design and deploy Nextcloud as another explicitly temporary Atlas service before Uranus. Give it (93,937,810,350 regular-file bytes) were copied from `/zpool/archive/Music` using a temporary
separate persistent application, database, and cache storage; keep credentials in Vault; publish it only ZFS snapshot; a checksum-based rsync dry run found no differences or extra files. Navidrome saw
through NPM over the Prometheus--Aegis gateway; and define backup, upgrade, and eventual Uranus-migration all files through its read-only mount, completed a scan, indexed 18,168 tracks, and responded
procedures before exposing user data. Do not deploy Nextcloud before the data-protection checklist is complete. 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.
- [x] Design and deploy the empty temporary Atlas Nextcloud/ONLYOFFICE stack on 2026-10-03.
The operator explicitly authorized empty internal service startup before the first scrub;
this does not close the scrub or protection checks. Four rootless Quadlets, separate
component datasets, pinned images/apps, Vault secrets, standard fabio/chiara users, a
separate application admin and the Famiglia folder are deployed. Cron and internal Office
connection checks succeeded; repeat deployment changed nothing. See `docs/atlas-nextcloud.md`.
- [x] Complete the authorized empty-stack public cutover on 2026-10-03 after operator
DNS/NPM configuration. Both hostnames passed TLS and HTTPS redirects; authenticated
web login, WebDAV, private-file isolation, Famiglia cross-user create/read/update/delete and
CalDAV/CardDAV discovery passed. The Office connector and public health/API asset passed.
Temporary test files were removed; no iCloud data was imported.
- [ ] Complete Nextcloud desktop/mobile editing and synchronization acceptance, and
application-consistent backup/restore validation. Close the first actual scrub and
protection checks before importing family data.
iCloud migration and future Uranus transfer remain separate operations, not playbook flags.
- [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 support was subsequently removed entirely; 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`, - [ ] Keep `atlas_manage_media_stack` disabled until the future Immich deployment has validated `/dev/dri`,
container paths, and the required Vault database secret. container paths, and the required Vault database secret.
### Priority 4 - Optional workflows ### Priority 4 - Optional workflows
- [ ] After data protection is validated, move iCloudPD photo ingestion from Aegis to Atlas as a - [x] Deploy the declared Atlas iCloudPD state dataset and inactive rootless `admin` Quadlet.
temporary service until Uranus is ready. Plan to store photos in `/zpool/archive/Pictures` and Photos belong under `/zpool/archive/Pictures/iCloudPD`; private config/MFA state belongs in
persistent application/MFA state outside `Archive`; validate permissions, SELinux, backups and `zpool/services/data/icloudpd`. Photobook remains reserved for Immich. Ansible now renders
recovery before cutover. Keep the current Aegis service and Photobook NFS export unchanged until `icloudpd.conf` with the Apple ID from the existing Vault key, but does not store the password
the Atlas workflow is tested, then retire them explicitly if no longer needed. 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 Management Node (Deferred)
`cerberus` is postponed until the office in the new house is physically set up. It is not an inventory `cerberus` is postponed until the office in the new house is physically set up. It is not an inventory
@@ -348,5 +541,5 @@ validated exports of older historical data will use a dedicated Atlas NFS datase
`/etc/resolv.conf` linked to `/run/systemd/resolve/resolv.conf`. LAN clients may use AdGuard, but `/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 Aegis must use the independent upstream DNS declared by `aegis_host_dns_servers` so Greenboot does
not depend on the AdGuard container during startup. not depend on the AdGuard container during startup.
- iCloudPD requires post-deployment interactive MFA initialization; its cookie/configuration state is - Aegis iCloudPD has been retired and is no longer managed by this role. Its service, Quadlet,
persisted in `/var/lib/icloudpd/config`. data, and MFA state were removed with the operator's explicit authorization.

View File

@@ -182,6 +182,10 @@ Le applicazioni Windows sono installate e gestite manualmente; il profilo WSL no
## Server ## 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: Sistema operativo:
- Rocky Linux 9 - Rocky Linux 9
@@ -201,51 +205,36 @@ Lo stato attuale del profilo server include:
- installazione pacchetti Rocky via DNF, EPEL e CRB - installazione pacchetti Rocky via DNF, EPEL e CRB
- installazione di Podman e podman-compose - installazione di Podman e podman-compose
- abilitazione dei servizi systemd dichiarati in inventory/group vars - abilitazione dei servizi systemd dichiarati in inventory/group vars
- copia dei dotfiles server e rendering del `docker-compose.yml` per Nginx Proxy Manager e Gitea, - copia dei dotfiles server e rendering del Quadlet rootful `prometheus-npm.service` per Nginx Proxy
piu l'unita `podman-compose-server` (attivazione manuale) Manager; il vecchio fallback Compose è stato rimosso con autorizzazione esplicita
- attivazione di firewalld con SSH, Cockpit (`9090/tcp`), HTTP e HTTPS abilitati - attivazione di firewalld con SSH, Cockpit (`9090/tcp`), HTTP e HTTPS abilitati
- Syncthing escluso dal profilo server Rocky - Syncthing escluso dal profilo server Rocky
Il Compose desiderato su Prometheus non include piu Navidrome ne il database PostgreSQL obsoleto. Il 2026-10-03 la pulizia opt-in autorizzata ha rimosso dati e immagini precedenti di Gitea,
Navidrome e Syncthing appartengono ad Atlas; Navidrome ufficiale usa invece SQLite. Il profilo non Navidrome e PostgreSQL, directory obsolete vuote, helper finale Gitea e fallback Compose NPM.
arresta o rimuove automaticamente eventuali container legacy e non elimina `/opt/postgres/data`. 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 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`. 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`. Nextcloud resta disabilitato e il profilo non crea directory `/srv/nextcloud`.
La fase 1 su Atlas non modifica questo deployment NPM ne i suoi dati persistenti. Dopo aver attivato La fase 1 su Atlas non modifica i dati persistenti NPM. I proxy host NPM usano gli upstream LAN
WireGuard e i servizi Atlas, configurare i proxy host NPM correnti con upstream Navidrome `http://192.168.178.55:4533` per Navidrome e `http://192.168.178.55:8384` per la GUI Syncthing;
`http://10.0.0.2:4533` e upstream per la GUI Syncthing `http://10.0.0.2:8384`. Solo la GUI web di Prometheus li raggiunge attraverso Aegis come gateway WireGuard. Solo la GUI web di Syncthing usa
Syncthing usa NPM; il traffico di sincronizzazione resta sulle porte native pubblicate esplicitamente solo NPM; il traffico di sincronizzazione resta sulle porte native esposte sulla LAN dichiarata.
sull'indirizzo WireGuard di Atlas. Configurare l'autenticazione Syncthing e una policy di accesso NPM adeguata prima di pubblicare la GUI. Mantenere l'autenticazione Syncthing e una policy di accesso NPM adeguata.
### DuckDNS ### Rimozione DuckDNS
`profile_server` genera `~/duckdns/duck.sh` con permessi `0700`, mantenendo il percorso dello Il supporto DuckDNS è stato rimosso dal profilo server: non restano task, template,
script e `duck.log`. Definire `server_duckdns_domain` negli host vars del server e salvare il variabili o flag di abilitazione. Prometheus usa IP statico e `fscotto.co`.
**nuovo token rigenerato** in `vault_duckdns_token`, nel Vault cifrato `secrets/vault.yml` Updater locale, log e cron erano già stati rimossi. Nome/account DuckDNS esterni
(`ansible-vault edit secrets/vault.yml`) oppure negli override non versionati `secrets/vault.local.yml`. ed eventuale token cifrato esistente restano invariati per un possibile uso futuro.
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: Utente del profilo server:
@@ -311,7 +300,12 @@ l'interfaccia amministrativa resta su `127.0.0.1:81`, raggiungibile via tunnel S
Atlas ospita temporaneamente Navidrome e Syncthing rootless fino alla sostituzione con Uranus. I 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 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/services/data/navidrome` e `/zpool/services/data/syncthing`; la musica in
`/zpool/media/music` viene popolata separatamente. Sono vincolati all'indirizzo LAN di Atlas `/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`) (`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. 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 Prometheus apre `51820/udp`; Aegis inoltra soltanto il traffico overlay→LAN dichiarato e applica
@@ -322,6 +316,14 @@ alla LAN. Dopo la verifica dei servizi, configurare manualmente i Proxy Host NPM
negli `AllowedIPs`; aggiungere la VIP Uranus quando esisterà. Dopo il reload di firewalld, Ansible 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. 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: Validare il gateway con:
```bash ```bash
@@ -504,8 +506,9 @@ viene recuperato quando il timer torna attivo.
`atlas-usb-backup.service` **non ha timer** e va avviato manualmente. Il timer del fornitore `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 `zfs-scrub-weekly@zpool.timer` è disabilitato a favore dello scrub mensile. Il timer di preparazione
su Prometheus è attivo alle 02:00 Europe/Rome; export, pull e ripristino temporaneo manuali sono su Prometheus è attivo alle 02:00 Europe/Rome; il primo ciclo pianificato è riuscito il 2026-10-01.
riusciti il 2026-09-30, ma il primo ciclo pianificato va ancora verificato. Durante un backup Borg attivo, 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. `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. Per vedere la pianificazione corrente: `systemctl list-timers --all` su Atlas.
@@ -514,12 +517,15 @@ della protezione dei dati: richiede storage applicativo, database e cache separa
pubblicazione solo tramite NPM e Aegis, procedure di backup, aggiornamento e migrazione. Non pubblicazione solo tramite NPM e Aegis, procedure di backup, aggiornamento e migrazione. Non
distribuirlo prima di completare la checklist di protezione dei dati. distribuirlo prima di completare la checklist di protezione dei dati.
La destinazione futura per l'importazione foto iCloud è Atlas, non Aegis. Dopo la validazione dei Atlas è la destinazione dichiarata per iCloudPD. Ansible gestisce dataset, Quadlet rootless e
backup, pianificare una migrazione esplicita di iCloudPD con foto sotto `/zpool/archive/Pictures` e `icloudpd.conf` privato con Apple ID dal Vault: foto in `/zpool/archive/Pictures/iCloudPD`,
stato applicativo/MFA fuori da `Archive`; testare permessi, SELinux, backup e restore prima del stato in `zpool/services/data/icloudpd`. Il primo avvio è stato manuale; password e MFA restano
cutover. L'attuale iCloudPD su Aegis e l'export NFS Photobook restano configurati fino gestiti interattivamente, senza avvio automatico al boot. L'inizializzazione è stata completata e
all'approvazione e alla verifica di questa migrazione separata. Anche il servizio Atlas sarà il download iniziale di foto e video è terminato il 2026-10-03. Su Aegis
temporaneo in attesa di Uranus. 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 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, restano da verificare. Il 2026-09-30 una VM Rocky isolata ha superato ricostruzione OS con Ansible,
@@ -622,8 +628,8 @@ Questo significa che, allo stato attuale:
- `deadalus` riceve il profilo Fedora WSL tramite play dev dedicati - `deadalus` riceve il profilo Fedora WSL tramite play dev dedicati
- il server Rocky (`prometheus`) e gestito con pacchetti, servizi, dotfiles server e firewalld - 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 - il NAS Rocky (`atlas`) usa un pool ZFS gia esistente, condivisioni NFSv4/SMB limitate alla LAN e Cockpit/45Drives
- lo stack Compose server include soltanto `gitea` e `nginx-proxy-manager`; Navidrome e Syncthing - NPM è un Quadlet rootful su Prometheus, mentre Gitea, Navidrome e Syncthing sono Quadlet
della fase 1 sono Quadlet rootless su Atlas rootless su Atlas; il fallback Compose server è stato rimosso
# Dotfiles # Dotfiles
@@ -729,9 +735,10 @@ ansible-playbook ansible/site.yml --limit <host> --tags <tag1>,<tag2> --check --
ansible-playbook ansible/site.yml --limit <host> --start-at-task "<task name>" --check --diff ansible-playbook ansible/site.yml --limit <host> --start-at-task "<task name>" --check --diff
ansible-lint ansible/roles/<role> ansible-lint ansible/roles/<role>
yamllint ansible/path/to/file.yml yamllint ansible/path/to/file.yml
podman-compose -f /opt/docker/server/docker-compose.yml config 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 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 backend_phase1 --check --diff
ansible-playbook ansible/site.yml --limit atlas --tags music_sync --check --diff
``` ```
## Tag supportati dal playbook ## Tag supportati dal playbook

112
README.md
View File

@@ -125,16 +125,26 @@ That gives it Fedora packages through DNF, Docker from the official repository,
## Server ## Server
The public service domain transition to `fscotto.co`, Gitea canonical URL
management, and remaining DuckDNS retirement steps are documented in
[`docs/domain-fscotto-co.md`](docs/domain-fscotto-co.md).
`prometheus` is the Rocky Linux 9 server. It has no graphical environment and gets server-specific `prometheus` is the Rocky Linux 9 server. It has no graphical environment and gets server-specific
dotfiles and templates. The profile provisions configuration only: it does not transfer data, start dotfiles and templates. The profile does not transfer application data, update DNS, or perform an
the Compose stack, update DNS, or perform a cutover. implicit service cutover.
The server profile installs platform-specific packages, Podman and podman-compose, declared systemd The server profile installs platform-specific packages, Podman and podman-compose, declared systemd
services, and firewalld. The manually activated `podman-compose-server` unit contains the existing services, and firewalld. Nginx Proxy Manager runs as the rootful `prometheus-npm.service` Quadlet.
Nginx Proxy Manager and Gitea services. The desired Compose file no longer includes Navidrome, On 2026-10-03 the operator-approved opt-in cleanup removed old Gitea, Navidrome and PostgreSQL
Syncthing, or the obsolete Navidrome PostgreSQL database; their temporary Atlas deployment is managed data/images, empty legacy directories, the Gitea final-export helper and the Compose rollback files.
by `profile_backend_phase1`. Applying the profile does not stop or remove legacy containers and does The migrated services stay on Atlas. `server_legacy_stack_retired: true` prevents normal runs from
not delete `/opt/postgres/data`. recreating retired files. Data deletion requires `--tags server_legacy_cleanup` and
`-e server_legacy_cleanup=true`; image-only cleanup has its own `server_image_cleanup` tag and flag.
Active NPM resources and existing backup archives remain preserved.
The post-cleanup export/pull and isolated SQLite restore passed; the first unattended cycle remains
pending. Evidence and recovery boundaries:
[`docs/prometheus-npm-quadlet.md`](docs/prometheus-npm-quadlet.md).
Firewalld enables SSH, Cockpit (`9090/tcp`), HTTP and HTTPS. Nginx Proxy Manager publishes only Firewalld enables SSH, Cockpit (`9090/tcp`), HTTP and HTTPS. Nginx Proxy Manager publishes only
`80/tcp` and `443/tcp`; its administration interface is bound to `127.0.0.1:81` and can be reached `80/tcp` and `443/tcp`; its administration interface is bound to `127.0.0.1:81` and can be reached
@@ -159,45 +169,12 @@ The target must already provide `server_username` with local sudo access.
Prometheus authorizes its declared SSH public keys through separate files below Prometheus authorizes its declared SSH public keys through separate files below
`~/.ssh/authorized_keys.d/`, while `sshd` is configured to read those files directly. `~/.ssh/authorized_keys.d/`, while `sshd` is configured to read those files directly.
### DuckDNS ### DuckDNS retirement
`profile_server` renders `~/duckdns/duck.sh` with mode `0700`, keeping the existing updater path DuckDNS support has been removed from the server profile: no tasks, templates,
and `duck.log`. Set `server_duckdns_domain` in the server's host vars and store the **rotated** variables or enablement flags remain. Prometheus uses its static IP and `fscotto.co`.
`vault_duckdns_token` in encrypted `secrets/vault.yml` (using `ansible-vault edit secrets/vault.yml`) The local updater, log and cron job were already removed. The external DuckDNS
or untracked `secrets/vault.local.yml`. Never commit the rendered script or put the token on a name/account and existing encrypted token remain untouched for possible future use.
command line. Rendering hides secret output/diffs; the updater verifies TLS and passes the token
to curl through stdin. The playbook neither runs the updater nor changes its external schedule.
```bash
ansible-playbook ansible/site.yml --limit prometheus --tags duckdns --check --diff
ansible-playbook ansible/site.yml --limit prometheus --tags duckdns
```
An exposed token must be revoked/regenerated on DuckDNS: deleting it from Git history does not
revoke it. After a history cleanup, re-clone other checkouts rather than merging the old history
back in; preserve any uncommitted work separately without copying secrets.
### Data migration
Provision Rocky first, then run the migration script **on the retired Ubuntu source host**. It is
dry-run by default and requires an explicit source-stack stop before it can copy application data:
```bash
sudo ./scripts/migrate_prometheus_data.sh \
--destination rocky@179.237.102.172 \
--identity /root/.ssh/id_ed25519
sudo ./scripts/migrate_prometheus_data.sh \
--destination rocky@179.237.102.172 \
--identity /root/.ssh/id_ed25519 \
--quiesce-source --execute
```
The script copies only Nginx Proxy Manager and Gitea data. It does not delete data, move
Navidrome/Syncthing, copy `/home/git/.ssh`, start containers, update DNS, or perform a cutover. The
destination SSH host key must already be trusted and the destination account needs passwordless sudo
for `rsync`. It preserves ACLs but not extended attributes, so source SELinux labels are not
transferred; the Rocky Compose bind mounts apply their own `:Z` labels when containers start.
## DNS Filter ## DNS Filter
@@ -210,8 +187,8 @@ ansible/bootstrap/generate-aegis-ign.sh --write IMAGE DEVICE
``` ```
The controller manages it remotely as `pi@aegis`; unlike local desktop profiles, Aegis is The controller manages it remotely as `pi@aegis`; unlike local desktop profiles, Aegis is
intentionally an SSH inventory target. `profile_aegis` manages rootful Podman Quadlets for AdGuard intentionally an SSH inventory target. `profile_aegis` manages a rootful Podman Quadlet for AdGuard
Home and iCloudPD, persistent data under `/var/lib`, the Podman auto-update timer, LAN-restricted Home, its persistent data under `/var/lib`, the Podman auto-update timer, LAN-restricted
firewalld rules, SSH key-only access for `pi`, the `nfs-utils` and `wireguard-tools` rpm-ostree layers, firewalld rules, SSH key-only access for `pi`, the `nfs-utils` and `wireguard-tools` rpm-ostree layers,
and `wake-ikaros`. `wireguard_overlay` makes Aegis the internal endpoint and LAN gateway for Prometheus: and `wake-ikaros`. `wireguard_overlay` makes Aegis the internal endpoint and LAN gateway for Prometheus:
it enables persistent IPv4 forwarding, installs a scoped WireGuard-to-LAN firewalld policy, and source-NATs it enables persistent IPv4 forwarding, installs a scoped WireGuard-to-LAN firewalld policy, and source-NATs
@@ -224,9 +201,8 @@ opened and closed manually during initial setup. The profile disables the local
stub and points `/etc/resolv.conf` to its full resolver data, freeing port 53 for AdGuard. LAN clients stub and points `/etc/resolv.conf` to its full resolver data, freeing port 53 for AdGuard. LAN clients
may use AdGuard on Aegis, while Aegis itself uses the independent upstream DNS declared by may use AdGuard on Aegis, while Aegis itself uses the independent upstream DNS declared by
`aegis_host_dns_servers`; this prevents Greenboot from depending on the AdGuard container during `aegis_host_dns_servers`; this prevents Greenboot from depending on the AdGuard container during
startup. Reboot Aegis after changing its NetworkManager DNS profile. Define startup. Reboot Aegis after changing its NetworkManager DNS profile. iCloudPD was retired from Aegis;
`vault_aegis_icloudpd_apple_id` in Vault before applying it. iCloudPD still requires interactive MFA the Aegis role no longer contains iCloudPD tasks. Atlas iCloudPD config is Vault-backed; MFA is manual.
initialization after its first deployment.
New Aegis images create the `admin` account in Butane. Before configuring a newly imaged node, run its New Aegis images create the `admin` account in Butane. Before configuring a newly imaged node, run its
first playbook execution with `-e ansible_user=admin`; the SSH hardening role then permits that same first playbook execution with `-e ansible_user=admin`; the SSH hardening role then permits that same
@@ -296,7 +272,20 @@ Atlas temporarily hosts rootless Navidrome and Syncthing until Uranus replaces t
Atlas' LAN address (`192.168.178.55`); WireGuard remains exclusively between Prometheus (`10.0.0.1`) Atlas' LAN address (`192.168.178.55`); WireGuard remains exclusively between Prometheus (`10.0.0.1`)
and Aegis (`10.0.0.2`). Their state is initialized ex novo in `/zpool/services/data/navidrome` and and Aegis (`10.0.0.2`). Their state is initialized ex novo in `/zpool/services/data/navidrome` and
`/zpool/services/data/syncthing`; no source application state is migrated. The music library at `/zpool/services/data/syncthing`; no source application state is migrated. The music library at
`/zpool/media/music` is populated separately. `/zpool/media/music` was populated separately from `/zpool/archive/Music` on 2026-09-30;
Navidrome completed its library scan. The rootless `atlas-music-sync.timer` copies new and changed
files daily at 00:45 Europe/Rome, without deleting destination-only files. Both ZFS datasets must
be mounted. Its first scheduled run succeeded on 2026-10-02. Some source playlists still contain
obsolete Windows paths.
The Gitea move from Prometheus to Atlas is tracked in
[`docs/atlas-gitea-migration.md`](docs/atlas-gitea-migration.md). The final consistent copy runs in
Atlas' dedicated dataset under `admin`'s rootless user Quadlet. Its pinned derived image uses an
internal Unix user named `gitea` (UID/GID 1000), while clone URLs keep `git@`. NPM remains on Prometheus and the primary
public HTTPS route serves Atlas. Public SSH/2222 authenticates the `ikaros` key and serves
`git ls-remote`; the operator also confirmed SSH pull and push. HTTPS login and Git writes were
confirmed on 2026-10-03. The old Gitea data remains on Prometheus, but its container
is absent from the desired stack.
The separate `wireguard_overlay` role manages `wg0` between Prometheus (`10.0.0.1`) and Aegis The separate `wireguard_overlay` role manages `wg0` between Prometheus (`10.0.0.1`) and Aegis
(`10.0.0.2`), generating private keys once on their respective hosts and exchanging only public keys (`10.0.0.2`), generating private keys once on their respective hosts and exchanging only public keys
@@ -519,8 +508,9 @@ scheduled after the timer becomes active again.
`atlas-usb-backup.service` has **no timer**: the encrypted USB backup must be started manually. `atlas-usb-backup.service` has **no timer**: the encrypted USB backup must be started manually.
The vendor's `zfs-scrub-weekly@zpool.timer` is intentionally disabled in favor of the monthly scrub. The vendor's `zfs-scrub-weekly@zpool.timer` is intentionally disabled in favor of the monthly scrub.
The Prometheus export timer runs at 02:00 Europe/Rome; its first scheduled run and the Atlas pull The Prometheus export timer runs at 02:00 Europe/Rome. Its first scheduled export and Atlas pull
remain to be observed. A manual export, pull, and temporary restore passed. While a passed on 2026-10-01; a manual post-NPM-Quadlet export, pull, and temporary restore passed on
2026-10-03. The first scheduled cycle after that cutover remains to be observed. While a
Borg backup is still running, `systemctl list-timers` may show `-` for its next trigger; this does not Borg backup is still running, `systemctl list-timers` may show `-` for its next trigger; this does not
mean the timer has been disabled. Inspect the current schedule on Atlas with mean the timer has been disabled. Inspect the current schedule on Atlas with
`systemctl list-timers --all`. `systemctl list-timers --all`.
@@ -530,11 +520,14 @@ declared persistent application, database, and cache storage, Vault-backed crede
publishing through Aegis, and defined backup, upgrade, and eventual migration procedures. Do not deploy publishing through Aegis, and defined backup, upgrade, and eventual migration procedures. Do not deploy
it before the data-protection checklist is complete. it before the data-protection checklist is complete.
The desired future iCloud photo-ingestion host is Atlas, not Aegis. After data-protection validation, Atlas is the declared iCloud photo-ingestion host. Ansible manages the rootless Quadlet, a private
plan an explicit iCloudPD migration with photos under `/zpool/archive/Pictures` and application/MFA Vault-backed `icloudpd.conf`, photos under `/zpool/archive/Pictures/iCloudPD`, and separate state in
state outside `Archive`, then test permissions, SELinux, backups and recovery before cutting over. `zpool/services/data/icloudpd`. The service was started manually; Ansible does not enable automatic
The current Aegis iCloudPD service and Atlas Photobook NFS export remain configured until that startup or manage the password and MFA keyring. The operator initialized MFA interactively; on
separate migration is approved and validated; the eventual Atlas service is temporary until Uranus. 2026-10-03 the initial photo/video download completed. Aegis iCloudPD, including its service data,
has been removed and verified; the Aegis role no longer manages it. Backup/restore and SMB access
for the new data remain unverified. The Photobook NFS export remains untouched. See
[`docs/atlas-icloudpd-migration.md`](docs/atlas-icloudpd-migration.md).
The first scheduled Prometheus backup runs and production-size disaster-recovery tests remain follow-up work. The prioritized The first scheduled Prometheus backup runs and production-size disaster-recovery tests remain follow-up work. The prioritized
operational backlog is kept in `AGENTS.md`. operational backlog is kept in `AGENTS.md`.
@@ -720,8 +713,9 @@ ansible-playbook ansible/site.yml --limit <host> --tags <tag1>,<tag2> --check --
ansible-playbook ansible/site.yml --limit <host> --start-at-task "<task name>" --check --diff ansible-playbook ansible/site.yml --limit <host> --start-at-task "<task name>" --check --diff
ansible-lint ansible/roles/<role> ansible-lint ansible/roles/<role>
yamllint ansible/path/to/file.yml yamllint ansible/path/to/file.yml
podman-compose -f /opt/docker/server/docker-compose.yml config 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 storage,sharing,containers --check --diff
ansible-playbook ansible/site.yml --limit atlas --tags music_sync --check --diff
``` ```
## Tags ## Tags

View File

@@ -6,6 +6,10 @@ effective_username: "{{ server_username }}"
effective_user_group: "{{ server_user_group }}" effective_user_group: "{{ server_user_group }}"
effective_user_home: "{{ server_user_home }}" effective_user_home: "{{ server_user_home }}"
server_container_stack_dir: /opt/docker/server server_container_stack_dir: /opt/docker/server
server_npm_quadlet_stage: false
server_npm_quadlet_cutover: false
server_legacy_stack_retired: false
server_legacy_cleanup: false
ai_agents: {} ai_agents: {}
vim_plugins_enabled: false vim_plugins_enabled: false
@@ -87,23 +91,29 @@ server_backup_export_root: /var/lib/prometheus-backup-export
server_backup_rrsync_path: /usr/share/doc/rsync/support/rrsync server_backup_rrsync_path: /usr/share/doc/rsync/support/rrsync
server_backup_export_calendar: "*-*-* 02:00:00 Europe/Rome" server_backup_export_calendar: "*-*-* 02:00:00 Europe/Rome"
server_backup_export_start_timer: false server_backup_export_start_timer: false
# Ongoing public Gitea proxy configuration.
server_gitea_proxy_enabled: 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_source_keep: 3
server_backup_export_paths: server_backup_export_paths: >-
- opt/npm/data {{ ['opt/npm/data', 'opt/npm/letsencrypt']
- opt/npm/letsencrypt + ([] if server_gitea_on_atlas | bool else ['opt/gitea/data', 'home/git/.ssh'])
- opt/gitea/data + ([] if server_legacy_stack_retired | bool else
- home/git/.ssh ['opt/docker/server/docker-compose.yml',
- opt/docker/server/docker-compose.yml 'etc/systemd/system/podman-compose-server.service'])
- etc/systemd/system/podman-compose-server.service + (['etc/containers/systemd/prometheus-npm.container',
- etc/ssh/sshd_config 'etc/containers/systemd/server-web.network']
- etc/ssh/sshd_config.d if server_npm_quadlet_stage | bool else [])
- etc/firewalld + ['etc/ssh/sshd_config', 'etc/ssh/sshd_config.d',
- etc/wireguard/wg0.conf 'etc/firewalld', 'etc/wireguard/wg0.conf'] }}
server_backup_export_excludes: server_backup_export_excludes: >-
- opt/npm/data/logs {{ ['opt/npm/data/logs']
- opt/gitea/data/gitea/log + ([] if server_gitea_on_atlas | bool else
- opt/gitea/data/gitea/tmp ['opt/gitea/data/gitea/log', 'opt/gitea/data/gitea/tmp',
- opt/gitea/data/gitea/sessions 'opt/gitea/data/gitea/sessions', 'opt/gitea/data/gitea/indexers']) }}
- opt/gitea/data/gitea/indexers
server_ssh_authorized_keys: [] server_ssh_authorized_keys: []
server_ssh_authorized_key_directory: "{{ server_user_home }}/.ssh/authorized_keys.d" server_ssh_authorized_key_directory: "{{ server_user_home }}/.ssh/authorized_keys.d"

View File

@@ -42,5 +42,3 @@ aegis_ssh_authorized_keys:
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEH/7GJfGt0ZVmKeEzceoFkFkeCXFryKK9vAbaip+HCx nymph" key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEH/7GJfGt0ZVmKeEzceoFkFkeCXFryKK9vAbaip+HCx nymph"
- name: siren - name: siren
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA95wYlzpfN3rjUhpMeP4KHn8I6ZrjQXoDTgwgRIa++b siren" key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA95wYlzpfN3rjUhpMeP4KHn8I6ZrjQXoDTgwgRIa++b siren"
aegis_icloudpd_apple_id: "{{ vault_aegis_icloudpd_apple_id | default('') }}"

View File

@@ -49,6 +49,42 @@ atlas_zfs_backup_reservation: 500G
atlas_zfs_dataset_photobook: media/photobook atlas_zfs_dataset_photobook: media/photobook
atlas_mount_root: /zpool atlas_mount_root: /zpool
atlas_manage_storage: true atlas_manage_storage: true
atlas_manage_nextcloud: true
atlas_nextcloud_domain: cloud.fscotto.co
atlas_onlyoffice_domain: office.fscotto.co
# Resolved official amd64 images on 2026-10-03; updates are deliberate.
atlas_nextcloud_image: docker.io/library/nextcloud:33.0.9-apache@sha256:a97666d6ae931bde78a80cfba8abdf46d436d7b540f31895803f6fb0a012d689
atlas_nextcloud_postgres_image: docker.io/library/postgres:17-bookworm@sha256:639ab7ceb90e13123085b741fb31ef493fba25463002f6da665352e7b534b652
atlas_nextcloud_redis_image: docker.io/library/redis:7.4-bookworm@sha256:c6eabf748fc7a61dbb5a705c78bcf3d6377b1127a97d0ce965c11c44ba46896f
atlas_onlyoffice_image: docker.io/onlyoffice/documentserver:9.4.0.1@sha256:3ab6ebc7c605e5a32b7ae3ff19daed4925090245acc8100ce2230bd766c88212
atlas_nextcloud_users:
- username: fabio
display_name: Fabio
password: "{{ vault_nextcloud_fabio_password }}"
- username: chiara
display_name: Chiara
password: "{{ vault_nextcloud_chiara_password }}"
atlas_nextcloud_apps:
- id: groupfolders
version: 21.0.9
url: https://github.com/nextcloud-releases/groupfolders/releases/download/v21.0.9/groupfolders-v21.0.9.tar.gz
checksum: sha256:d8b95f0778425f646f2311ba5b42d8e2fcfdf37dc2fd35fcac8d3f01bde38a21
- id: onlyoffice
version: 10.2.1
url: https://github.com/ONLYOFFICE/onlyoffice-nextcloud/releases/download/v10.2.1/onlyoffice.tar.gz
checksum: sha256:144998af0610ccd17ee8d7025e2f8001472da03f6dab90ff38039247825e3a9b
- id: contacts
version: 8.9.1
url: https://github.com/nextcloud-releases/contacts/releases/download/v8.9.1/contacts-v8.9.1.tar.gz
checksum: sha256:a25cdf448b192631b8e8eb7addc31b40382b33871b10521f4308ac5a6e0457bf
- id: calendar
version: 6.6.2
url: https://github.com/nextcloud-releases/calendar/releases/download/v6.6.2/calendar-v6.6.2.tar.gz
checksum: sha256:7e83632d4436d3037a34d1c73cbc06d5ccb6e8fc10f096a86a43a0515588529c
# 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_prometheus_pull_start_timer: true
atlas_manage_zfs_snapshots: true atlas_manage_zfs_snapshots: true
atlas_zfs_snapshot_prefix: atlas-auto atlas_zfs_snapshot_prefix: atlas-auto
@@ -137,8 +173,8 @@ atlas_monitor_remote_capacity:
atlas_manage_sharing: true atlas_manage_sharing: true
atlas_manage_media_stack: false atlas_manage_media_stack: false
# Planned after data-protection validation: move iCloudPD photo ingestion from # Planned after data-protection validation: move iCloudPD photo ingestion from
# Aegis to Atlas, with photos under /zpool/archive/Pictures and persistent # Aegis to Atlas, with photos under /zpool/archive/Pictures/iCloudPD and
# application/MFA state outside Archive. Do not deploy or cut over yet. # 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 # WireGuard is retired on Atlas. These rootless services are a temporary home
# until Uranus replaces them. # until Uranus replaces them.
@@ -148,6 +184,7 @@ backend_phase1_bind_address: "{{ ansible_host }}"
backend_phase1_firewalld_zone: "{{ atlas_firewalld_zone }}" backend_phase1_firewalld_zone: "{{ atlas_firewalld_zone }}"
backend_phase1_npm_source_ip: "{{ atlas_aegis_ip }}" backend_phase1_npm_source_ip: "{{ atlas_aegis_ip }}"
backend_phase1_syncthing_native_subnet: "{{ atlas_lan_subnet }}" backend_phase1_syncthing_native_subnet: "{{ atlas_lan_subnet }}"
backend_phase1_music_sync_enabled: true
rocky_manage_openzfs_repo: true rocky_manage_openzfs_repo: true
rocky_manage_syncthing_binary: false rocky_manage_syncthing_binary: false

View File

@@ -6,9 +6,26 @@ ansible_port: 22
ansible_ssh_private_key_file: /home/fscotto/.ssh/id_ed25519 ansible_ssh_private_key_file: /home/fscotto/.ssh/id_ed25519
server_username: rocky 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_enabled: true
server_backup_export_start_timer: true server_backup_export_start_timer: true
server_duckdns_domain: fscotto # Install the final-copy helper only; it is never run by a normal playbook invocation.
server_gitea_proxy_enabled: true
server_gitea_on_atlas: true
server_gitea_npm_domains:
- git.fscotto.duckdns.org
server_ssh_authorized_keys: server_ssh_authorized_keys:
- name: ikaros - name: ikaros
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINrIxXjA3ffPwziKGR5gzc4gAoBehQPlnEMcXF4Wl0ZS ikaros" key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINrIxXjA3ffPwziKGR5gzc4gAoBehQPlnEMcXF4Wl0ZS ikaros"

View File

@@ -39,11 +39,41 @@
state: enabled state: enabled
when: "'workstation_dev_wsl' in group_names" 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: "'graphical_desktop' in group_names"
- 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 - name: Refresh dnf package metadata
tags: [packages] tags: [packages]
ansible.builtin.dnf: ansible.builtin.dnf:
update_cache: true 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 - name: Install packages on Fedora
tags: [packages] tags: [packages]
ansible.builtin.dnf: ansible.builtin.dnf:

View File

@@ -8,10 +8,6 @@ aegis_network_connection_uuid: ""
aegis_host_dns_servers: [] aegis_host_dns_servers: []
aegis_host_dns_search_domains: [] aegis_host_dns_search_domains: []
aegis_adguard_image: docker.io/adguard/adguardhome:latest aegis_adguard_image: docker.io/adguard/adguardhome:latest
aegis_icloudpd_image: docker.io/boredazfcuk/icloudpd:latest
aegis_icloudpd_folder_structure: '{:%Y/%m/%d}'
aegis_icloudpd_synchronisation_interval: 86400
aegis_icloudpd_apple_id: ""
aegis_ikaros_mac_address: aa:bb:cc:dd:ee:ff aegis_ikaros_mac_address: aa:bb:cc:dd:ee:ff
aegis_wol_port: 9 aegis_wol_port: 9

View File

@@ -9,13 +9,12 @@
name: sshd.service name: sshd.service
state: reloaded state: reloaded
- name: Restart Aegis Quadlet services - name: Restart Aegis AdGuard Quadlet
ansible.builtin.systemd: ansible.builtin.systemd:
name: "{{ item }}" name: "{{ item }}"
state: restarted state: restarted
daemon_reload: true daemon_reload: true
loop: loop:
- adguardhome.service - adguardhome.service
- icloudpd.service
loop_control: loop_control:
label: "{{ item }}" label: "{{ item }}"

View File

@@ -13,14 +13,6 @@
msg: Reboot Aegis to activate the newly layered packages, then rerun the playbook. msg: Reboot Aegis to activate the newly layered packages, then rerun the playbook.
when: aegis_layered_packages_result.needs_reboot | default(false) when: aegis_layered_packages_result.needs_reboot | default(false)
- name: Require Aegis iCloudPD Apple ID
tags: [aegis, icloudpd]
ansible.builtin.assert:
that:
- aegis_icloudpd_apple_id | length > 0
fail_msg: Define vault_aegis_icloudpd_apple_id before applying the Aegis profile.
no_log: true
- name: Require completed Aegis network placeholders - name: Require completed Aegis network placeholders
tags: [aegis, dns, firewall, network, services] tags: [aegis, dns, firewall, network, services]
ansible.builtin.assert: ansible.builtin.assert:
@@ -119,8 +111,6 @@
loop: loop:
- /var/lib/adguard/work - /var/lib/adguard/work
- /var/lib/adguard/conf - /var/lib/adguard/conf
- /var/lib/icloudpd/data
- /var/lib/icloudpd/config
- name: Create Quadlet configuration directory - name: Create Quadlet configuration directory
tags: [aegis, containers] tags: [aegis, containers]
@@ -142,12 +132,9 @@
loop: loop:
- src: adguardhome.container.j2 - src: adguardhome.container.j2
dest: adguardhome.container dest: adguardhome.container
- src: icloudpd.container.j2
dest: icloudpd.container
loop_control: loop_control:
label: "{{ item.dest }}" label: "{{ item.dest }}"
no_log: "{{ item.dest == 'icloudpd.container' }}" notify: Restart Aegis AdGuard Quadlet
notify: Restart Aegis Quadlet services
- name: Create Aegis systemd-resolved configuration directory - name: Create Aegis systemd-resolved configuration directory
tags: [aegis, adguard, dns, services] tags: [aegis, adguard, dns, services]
@@ -168,7 +155,7 @@
mode: "0644" mode: "0644"
notify: notify:
- Restart Aegis systemd-resolved - Restart Aegis systemd-resolved
- Restart Aegis Quadlet services - Restart Aegis AdGuard Quadlet
- name: Point Aegis resolver at the full systemd-resolved configuration - name: Point Aegis resolver at the full systemd-resolved configuration
tags: [aegis, adguard, dns, services] tags: [aegis, adguard, dns, services]
@@ -361,7 +348,7 @@
group: root group: root
mode: "0755" mode: "0755"
- name: Enable Aegis Quadlet services and automatic updates - name: Enable Aegis AdGuard Quadlet and automatic updates
tags: [aegis, containers, services] tags: [aegis, containers, services]
ansible.builtin.systemd: ansible.builtin.systemd:
name: "{{ item }}" name: "{{ item }}"
@@ -370,7 +357,6 @@
daemon_reload: true daemon_reload: true
loop: loop:
- adguardhome.service - adguardhome.service
- icloudpd.service
- podman-auto-update.timer - podman-auto-update.timer
loop_control: loop_control:
label: "{{ item }}" label: "{{ item }}"

View File

@@ -1,20 +0,0 @@
# Managed by Ansible. Do not edit manually.
[Unit]
Description=iCloud Photos Downloader
Wants=network-online.target
After=network-online.target
[Container]
Image={{ aegis_icloudpd_image }}
Environment=apple_id={{ aegis_icloudpd_apple_id }}
Environment=folder_structure={{ aegis_icloudpd_folder_structure }}
Environment=synchronisation_interval={{ aegis_icloudpd_synchronisation_interval }}
Volume=/var/lib/icloudpd/data:/home/root/iCloud:Z
Volume=/var/lib/icloudpd/config:/config:Z
AutoUpdate=registry
[Service]
Restart=always
[Install]
WantedBy=multi-user.target

View File

@@ -1,5 +1,29 @@
--- ---
atlas_manage_storage: false atlas_manage_storage: false
atlas_manage_nextcloud: false
atlas_nextcloud_root: "{{ atlas_app_data_mountpoint }}/nextcloud"
atlas_nextcloud_dataset: "{{ atlas_zfs_pool }}/services/data/nextcloud"
atlas_nextcloud_domain: ""
atlas_onlyoffice_domain: ""
atlas_nextcloud_http_port: 8080
atlas_onlyoffice_http_port: 8081
atlas_nextcloud_network_subnet: 10.90.10.0/24
atlas_nextcloud_network_gateway: 10.90.10.1
atlas_nextcloud_quadlet_dir: "{{ atlas_admin_home }}/.config/containers/systemd"
atlas_nextcloud_private_dir: "{{ atlas_admin_home }}/.config/atlas-nextcloud"
atlas_nextcloud_app_cache: "{{ atlas_admin_home }}/.cache/atlas-nextcloud-apps"
atlas_nextcloud_image: ""
atlas_nextcloud_postgres_image: ""
atlas_nextcloud_redis_image: ""
atlas_onlyoffice_image: ""
atlas_nextcloud_admin: admin
atlas_nextcloud_users: []
atlas_nextcloud_apps: []
atlas_nextcloud_services:
- atlas-nextcloud-db.service
- atlas-nextcloud-redis.service
- atlas-nextcloud.service
- atlas-onlyoffice.service
atlas_manage_sharing: false atlas_manage_sharing: false
# Destructive first-boot action; normally false once the pool exists. # Destructive first-boot action; normally false once the pool exists.
atlas_create_pool: false atlas_create_pool: false
@@ -165,6 +189,41 @@ atlas_prometheus_pull_keep_monthly: 12
atlas_prometheus_pull_max_age_hours: 24 atlas_prometheus_pull_max_age_hours: 24
atlas_photobook_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_photobook }}" 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_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
# 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_url: https://repo.45drives.com/repofiles/rocky/45drives-enterprise.repo
atlas_45drives_repo_file: /etc/yum.repos.d/45drives-enterprise.repo atlas_45drives_repo_file: /etc/yum.repos.d/45drives-enterprise.repo
atlas_45drives_packages: atlas_45drives_packages:

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

@@ -1,4 +1,14 @@
--- ---
- 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 - name: Reload SSH service
ansible.builtin.systemd: ansible.builtin.systemd:
name: sshd name: sshd

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: >-
The production dataset must already belong to admin before enabling
the Quadlet; normal provisioning must not chown an active legacy service.
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,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,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

@@ -14,6 +14,18 @@
- name: Import Atlas storage tasks - name: Import Atlas storage tasks
ansible.builtin.import_tasks: storage.yml ansible.builtin.import_tasks: storage.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 Nextcloud steady-state stack
ansible.builtin.import_tasks: nextcloud.yml
- name: Import Atlas iCloudPD storage and boot-started Quadlet tasks
ansible.builtin.import_tasks: icloudpd.yml
- name: Import Atlas ZFS maintenance tasks - name: Import Atlas ZFS maintenance tasks
ansible.builtin.import_tasks: zfs_maintenance.yml ansible.builtin.import_tasks: zfs_maintenance.yml

View File

@@ -0,0 +1,309 @@
---
- name: Manage the empty Atlas Nextcloud and ONLYOFFICE stack
tags: [atlas, nextcloud]
when: atlas_manage_nextcloud | bool
block:
- name: Validate dedicated paths, domains and pinned images
ansible.builtin.assert:
that:
- atlas_manage_storage | bool
- atlas_manage_firewall | bool
- atlas_nextcloud_root == atlas_app_data_mountpoint ~ '/nextcloud'
- atlas_nextcloud_dataset == atlas_zfs_pool ~ '/services/data/nextcloud'
- atlas_nextcloud_domain is match('^[a-z0-9.-]+$')
- atlas_onlyoffice_domain is match('^[a-z0-9.-]+$')
- atlas_nextcloud_domain != atlas_onlyoffice_domain
- atlas_nextcloud_http_port | int > 1024
- atlas_onlyoffice_http_port | int > 1024
- atlas_nextcloud_http_port != atlas_onlyoffice_http_port
- "['calendar', 'contacts', 'onlyoffice', 'groupfolders'] | difference(atlas_nextcloud_apps | map(attribute='id') | list) | length == 0"
- atlas_nextcloud_users | length > 0
- atlas_nextcloud_admin not in (atlas_nextcloud_users | map(attribute='username') | list)
- atlas_nextcloud_users | map(attribute='username') | unique | list | length == atlas_nextcloud_users | length
- item is search('@sha256:[0-9a-f]{64}$')
loop:
- "{{ atlas_nextcloud_image }}"
- "{{ atlas_nextcloud_postgres_image }}"
- "{{ atlas_nextcloud_redis_image }}"
- "{{ atlas_onlyoffice_image }}"
- name: Require dedicated Vault secrets without exposing them
ansible.builtin.assert:
that:
- item | default('') is match('^[a-zA-Z0-9]{32,}$')
loop: >-
{{ [vault_nextcloud_database_password | default(''),
vault_nextcloud_redis_password | default(''),
vault_nextcloud_admin_password | default(''),
vault_nextcloud_onlyoffice_jwt | default('')] +
(atlas_nextcloud_users | map(attribute='password') | list) }}
no_log: true
- name: Verify the existing application-data parent is mounted
community.general.zfs_facts:
name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_app_data }}"
properties: name,mounted,mountpoint
register: atlas_nextcloud_parent
- name: Require the verified application-data parent
ansible.builtin.assert:
that:
- atlas_nextcloud_parent.ansible_facts.ansible_zfs_datasets | length == 1
- atlas_nextcloud_parent.ansible_facts.ansible_zfs_datasets[0].mounted == 'yes'
- atlas_nextcloud_parent.ansible_facts.ansible_zfs_datasets[0].mountpoint == atlas_app_data_mountpoint
- name: Create the dedicated Nextcloud namespace and component datasets
community.general.zfs:
name: "{{ atlas_nextcloud_dataset }}{{ item }}"
state: present
extra_zfs_properties:
compression: zstd
mountpoint: "{{ atlas_nextcloud_root }}{{ item }}"
loop: ['', /app, /files, /database, /cache, /office]
- name: Inspect component directories before seeding ownership
ansible.builtin.stat:
path: "{{ atlas_nextcloud_root }}{{ item }}"
follow: false
get_checksum: false
loop: [/app, /files, /database, /cache, /office]
register: atlas_nextcloud_component_paths
- name: Seed only root-owned new dataset roots without recursive ownership changes
ansible.builtin.file:
path: "{{ item.stat.path }}"
state: directory
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "0700"
loop: "{{ atlas_nextcloud_component_paths.results }}"
loop_control:
label: "{{ item.item }}"
when:
- item.stat.exists
- item.stat.uid | default(-1) | int == 0
- name: Ensure private rootless stack configuration directories exist
ansible.builtin.file:
path: "{{ item.path }}"
state: directory
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "{{ item.mode }}"
loop:
- {path: "{{ atlas_nextcloud_private_dir }}", mode: "0700"}
- {path: "{{ atlas_nextcloud_app_cache }}", mode: "0755"}
- {path: "{{ atlas_nextcloud_quadlet_dir }}", mode: "0700"}
- {path: "{{ atlas_admin_home }}/.config/systemd/user", mode: "0700"}
- name: Inspect the dedicated ONLYOFFICE bind directories
ansible.builtin.stat:
path: "{{ atlas_nextcloud_root }}/office/{{ item }}"
follow: false
get_checksum: false
loop: [data, lib, logs, database]
register: atlas_onlyoffice_bind_paths
- name: Create ONLYOFFICE bind directories only when absent
ansible.builtin.file:
path: "{{ item.invocation.module_args.path }}"
state: directory
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "0700"
loop: "{{ atlas_onlyoffice_bind_paths.results }}"
loop_control:
label: "{{ item.item }}"
when: not item.stat.exists
- name: Store private mounted password files inside a restricted host directory
ansible.builtin.copy:
content: "{{ item.value }}\n"
dest: "{{ atlas_nextcloud_private_dir }}/{{ item.name }}"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "0644"
loop:
- {name: postgres-password, value: "{{ vault_nextcloud_database_password }}"}
- {name: redis-password, value: "{{ vault_nextcloud_redis_password }}"}
- {name: admin-password, value: "{{ vault_nextcloud_admin_password }}"}
- {name: onlyoffice-jwt, value: "{{ vault_nextcloud_onlyoffice_jwt }}"}
no_log: true
diff: false
register: atlas_nextcloud_secret_files
- name: Render private Redis and ONLYOFFICE configuration
ansible.builtin.template:
src: "{{ item.src }}"
dest: "{{ atlas_nextcloud_private_dir }}/{{ item.dest }}"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "{{ item.mode }}"
loop:
- {src: atlas-nextcloud-redis.conf.j2, dest: redis.conf, mode: "0644"}
- {src: atlas-onlyoffice.env.j2, dest: onlyoffice.env, mode: "0600"}
no_log: true
diff: false
register: atlas_nextcloud_private_configuration
- name: Download checksum-pinned compatible application releases
ansible.builtin.get_url:
url: "{{ item.url }}"
dest: "{{ atlas_nextcloud_app_cache }}/{{ item.id }}-{{ item.version }}.tar.gz"
checksum: "{{ item.checksum }}"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "0644"
loop: "{{ atlas_nextcloud_apps }}"
loop_control:
label: "{{ item.id }} {{ item.version }}"
when: not ansible_check_mode
- name: Admit only the Aegis gateway to the Nextcloud and Office HTTP listeners
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
permanent: true
immediate: true
loop: ["{{ atlas_nextcloud_http_port }}", "{{ atlas_onlyoffice_http_port }}"]
- name: Enable lingering for the declared rootless owner
ansible.builtin.command:
argv: [loginctl, enable-linger, "{{ atlas_admin_username }}"]
creates: "/var/lib/systemd/linger/{{ atlas_admin_username }}"
- name: Render Nextcloud component and network Quadlets
ansible.builtin.template:
src: "{{ item }}.j2"
dest: "{{ atlas_nextcloud_quadlet_dir }}/{{ item }}"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "0644"
loop:
- atlas-nextcloud.network
- atlas-nextcloud-db.container
- atlas-nextcloud-redis.container
- atlas-nextcloud.container
- atlas-onlyoffice.container
register: atlas_nextcloud_quadlets
- name: Render recurring Nextcloud cron user units
ansible.builtin.template:
src: "{{ item }}.j2"
dest: "{{ atlas_admin_home }}/.config/systemd/user/{{ item }}"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "0644"
loop: [atlas-nextcloud-cron.service, atlas-nextcloud-cron.timer]
register: atlas_nextcloud_cron_units
- name: Manage and verify rootless Nextcloud services
become_user: "{{ atlas_admin_username }}"
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
block:
- name: Pull the pinned images before starting services
containers.podman.podman_image:
name: "{{ item }}"
state: present
loop:
- "{{ atlas_nextcloud_image }}"
- "{{ atlas_nextcloud_postgres_image }}"
- "{{ atlas_nextcloud_redis_image }}"
- "{{ atlas_onlyoffice_image }}"
- name: Reload the user manager to generate component units
ansible.builtin.systemd:
scope: user
daemon_reload: true
- name: Start the declared Nextcloud and ONLYOFFICE services
ansible.builtin.systemd:
scope: user
name: "{{ item }}"
state: >-
{{ 'restarted' if (atlas_nextcloud_quadlets is changed or
atlas_nextcloud_private_configuration is changed or
atlas_nextcloud_secret_files is changed) else 'started' }}
loop: "{{ atlas_nextcloud_services }}"
- name: Wait for the application configuration directory to be initialized
become: true
become_user: root
ansible.builtin.wait_for:
path: "{{ atlas_nextcloud_root }}/app/config/config.php"
timeout: 600
- name: Derive container web-user host IDs from the actual rootless maps
ansible.builtin.command:
argv:
- podman
- unshare
- python3
- -c
- >-
import json;
print(json.dumps({k: next(int(b)+33-int(a) for a,b,n in
(l.split() for l in open('/proc/self/'+k+'_map'))
if int(a)<=33<int(a)+int(n)) for k in ['uid','gid']}))
register: atlas_nextcloud_web_mapping
changed_when: false
- name: Read the current application SELinux label without changing it
become: true
become_user: root
ansible.builtin.command:
argv: [stat, -c, '%C', "{{ atlas_nextcloud_root }}/app/config"]
register: atlas_nextcloud_config_label
changed_when: false
- name: Maintain the managed Nextcloud configuration include
become: true
become_user: root
ansible.builtin.template:
src: atlas-nextcloud.config.php.j2
dest: "{{ atlas_nextcloud_root }}/app/config/atlas.config.php"
owner: "{{ (atlas_nextcloud_web_mapping.stdout | from_json).uid }}"
group: "{{ (atlas_nextcloud_web_mapping.stdout | from_json).gid }}"
mode: "0640"
seuser: "{{ atlas_nextcloud_config_label.stdout.split(':')[0] }}"
serole: "{{ atlas_nextcloud_config_label.stdout.split(':')[1] }}"
setype: "{{ atlas_nextcloud_config_label.stdout.split(':')[2] }}"
selevel: "{{ atlas_nextcloud_config_label.stdout.split(':')[3:] | join(':') }}"
diff: false
- name: Wait for Nextcloud to complete its initial installation
ansible.builtin.command:
argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, status, --output=json]
register: atlas_nextcloud_status
changed_when: false
retries: 60
delay: 10
until: >-
atlas_nextcloud_status.rc == 0 and
atlas_nextcloud_status.stdout.startswith('{') and
(atlas_nextcloud_status.stdout | from_json).installed | default(false)
- name: Import declared ongoing application and account configuration
ansible.builtin.include_tasks: nextcloud_application.yml
- name: Enable and start the recurring Nextcloud cron timer
ansible.builtin.systemd:
scope: user
name: atlas-nextcloud-cron.timer
state: "{{ 'restarted' if atlas_nextcloud_cron_units is changed else 'started' }}"
enabled: true
- name: Verify ONLYOFFICE local health without publishing the domain
ansible.builtin.uri:
url: "http://127.0.0.1:{{ atlas_onlyoffice_http_port }}/healthcheck"
return_content: true
register: atlas_onlyoffice_health
retries: 60
delay: 10
until: atlas_onlyoffice_health.status | default(0) == 200 and atlas_onlyoffice_health.content | default('') | trim == 'true'

View File

@@ -0,0 +1,171 @@
---
- name: Inspect installed application state
ansible.builtin.command:
argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, app:list, --output=json]
register: atlas_nextcloud_current_apps
changed_when: false
- name: Record enabled and disabled application versions
ansible.builtin.set_fact:
atlas_nextcloud_installed_apps: >-
{{ (atlas_nextcloud_current_apps.stdout | from_json).enabled |
combine((atlas_nextcloud_current_apps.stdout | from_json).disabled) }}
- name: Refuse implicit application upgrades or downgrades
ansible.builtin.assert:
that:
- item.id not in atlas_nextcloud_installed_apps or atlas_nextcloud_installed_apps[item.id] == item.version
fail_msg: Application versions must be changed in a deliberate upgrade window.
loop: "{{ atlas_nextcloud_apps }}"
loop_control:
label: "{{ item.id }}"
- name: Install only absent checksum-verified application archives
ansible.builtin.command:
argv:
- podman
- exec
- --user
- '33'
- atlas-nextcloud
- tar
- -xzf
- "/mnt/atlas-apps/{{ item.id }}-{{ item.version }}.tar.gz"
- -C
- /var/www/html/custom_apps
loop: "{{ atlas_nextcloud_apps }}"
loop_control:
label: "{{ item.id }}"
when: item.id not in atlas_nextcloud_installed_apps
changed_when: true
- name: Enable the declared applications
ansible.builtin.command:
argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, app:enable, "{{ item.id }}"]
loop: "{{ atlas_nextcloud_apps }}"
loop_control:
label: "{{ item.id }}"
when: item.id not in (atlas_nextcloud_current_apps.stdout | from_json).enabled
changed_when: true
- name: Inspect existing application users without exposing passwords
ansible.builtin.command:
argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, user:list, --output=json]
register: atlas_nextcloud_current_users
changed_when: false
- name: Ensure the two standard users exist without resetting existing passwords
ansible.builtin.command:
argv:
- podman
- exec
- --user
- '33'
- --env
- OC_PASS
- atlas-nextcloud
- php
- occ
- user:add
- --password-from-env
- --display-name
- "{{ item.display_name }}"
- "{{ item.username }}"
environment:
OC_PASS: "{{ item.password }}"
loop: "{{ atlas_nextcloud_users }}"
when: item.username not in (atlas_nextcloud_current_users.stdout | from_json)
changed_when: true
no_log: true
diff: false
- name: Inspect standard-user group membership and quota
ansible.builtin.command:
argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, user:info, --output=json, "{{ item.username }}"]
loop: "{{ atlas_nextcloud_users }}"
loop_control:
label: "{{ item.username }}"
register: atlas_nextcloud_user_info
changed_when: false
no_log: true
- name: Require that family users are not administrators
ansible.builtin.assert:
that:
- "'admin' not in (item.stdout | from_json).groups"
loop: "{{ atlas_nextcloud_user_info.results }}"
no_log: true
- name: Maintain unlimited initial standard-user quotas
ansible.builtin.command:
argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, user:setting, "{{ item.item.username }}", files, quota, none]
loop: "{{ atlas_nextcloud_user_info.results }}"
when: (item.stdout | from_json).quota != 'none'
changed_when: true
no_log: true
- name: Inspect the family group
ansible.builtin.command:
argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, group:list, --output=json]
register: atlas_nextcloud_groups
changed_when: false
- name: Ensure the family group exists
ansible.builtin.command:
argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, group:add, famiglia]
when: "'famiglia' not in (atlas_nextcloud_groups.stdout | from_json)"
changed_when: true
- name: Ensure both standard users belong to the family group
ansible.builtin.command:
argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, group:adduser, famiglia, "{{ item.username }}"]
loop: "{{ atlas_nextcloud_users }}"
when: item.username not in ((atlas_nextcloud_groups.stdout | from_json).get('famiglia', []))
changed_when: true
no_log: true
- name: Inspect configured family folders
ansible.builtin.command:
argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, groupfolders:list, --output=json]
register: atlas_nextcloud_folders_before
changed_when: false
- name: Ensure a shared Famiglia folder exists
ansible.builtin.command:
argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, groupfolders:create, Famiglia]
when: >-
(atlas_nextcloud_folders_before.stdout | from_json |
selectattr('mountPoint', 'equalto', 'Famiglia') | list | length) == 0
changed_when: true
- name: Inspect the resulting family folder
ansible.builtin.command:
argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, groupfolders:list, --output=json]
register: atlas_nextcloud_folders_after
changed_when: false
- name: Select the existing family folder without changing unrelated folders
ansible.builtin.set_fact:
atlas_nextcloud_family_folder: >-
{{ atlas_nextcloud_folders_after.stdout | from_json |
selectattr('mountPoint', 'equalto', 'Famiglia') | first }}
- name: Maintain family read, create, write and delete permissions
ansible.builtin.command:
argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, groupfolders:group,
"{{ atlas_nextcloud_family_folder.id }}", famiglia, write, delete]
when: (atlas_nextcloud_family_folder.groups_list | default({}, true)).get('famiglia', 0) | int != 15
changed_when: true
- name: Inspect the background job mode
ansible.builtin.command:
argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, config:app:get, core, backgroundjobs_mode]
register: atlas_nextcloud_background_mode
changed_when: false
failed_when: atlas_nextcloud_background_mode.rc not in [0, 1]
- name: Maintain cron background processing
ansible.builtin.command:
argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, background:cron]
when: atlas_nextcloud_background_mode.stdout | trim != 'cron'
changed_when: true

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,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,10 @@
[Unit]
Description=Atlas recurring Nextcloud background jobs
Requires=atlas-nextcloud.service
After=atlas-nextcloud.service
[Service]
Type=oneshot
ExecStart=/usr/bin/podman exec --user 33 atlas-nextcloud php -f /var/www/html/cron.php
TimeoutStartSec=15min
NoNewPrivileges=true

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Run Nextcloud background jobs every five minutes
[Timer]
OnBootSec=5min
OnUnitActiveSec=5min
Unit=atlas-nextcloud-cron.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,27 @@
[Unit]
Description=Atlas Nextcloud PostgreSQL
RequiresMountsFor={{ atlas_nextcloud_root }}/database
[Container]
ContainerName=atlas-nextcloud-db
Image={{ atlas_nextcloud_postgres_image }}
Network=atlas-nextcloud.network
NetworkAlias=atlas-nextcloud-db
Environment=POSTGRES_DB=nextcloud
Environment=POSTGRES_USER=nextcloud
Environment=POSTGRES_PASSWORD_FILE=/run/secrets/postgres-password
Volume={{ atlas_nextcloud_private_dir }}/postgres-password:/run/secrets/postgres-password:ro,z
Volume={{ atlas_nextcloud_root }}/database:/var/lib/postgresql/data:Z
PodmanArgs=--memory=1g
HealthCmd=pg_isready -U nextcloud -d nextcloud
HealthInterval=30s
HealthStartPeriod=60s
NoNewPrivileges=true
[Service]
Restart=on-failure
RestartSec=10
TimeoutStartSec=900
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,8 @@
bind 0.0.0.0
protected-mode yes
port 6379
requirepass {{ vault_nextcloud_redis_password }}
maxmemory 128mb
maxmemory-policy noeviction
save ""
appendonly no

View File

@@ -0,0 +1,22 @@
[Unit]
Description=Atlas Nextcloud private Redis
RequiresMountsFor={{ atlas_nextcloud_root }}/cache
[Container]
ContainerName=atlas-nextcloud-redis
Image={{ atlas_nextcloud_redis_image }}
Network=atlas-nextcloud.network
NetworkAlias=atlas-nextcloud-redis
Volume={{ atlas_nextcloud_private_dir }}/redis.conf:/usr/local/etc/redis/atlas.conf:ro,z
Volume={{ atlas_nextcloud_root }}/cache:/data:Z
Exec=redis-server /usr/local/etc/redis/atlas.conf
PodmanArgs=--memory=256m
NoNewPrivileges=true
[Service]
Restart=on-failure
RestartSec=10
TimeoutStartSec=900
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,23 @@
<?php
// Managed ongoing application settings; never import or migrate user data.
$CONFIG = [
'trusted_domains' => ['{{ atlas_nextcloud_domain }}', 'atlas-nextcloud'],
'trusted_proxies' => ['{{ atlas_aegis_ip }}', '{{ atlas_nextcloud_network_gateway }}'],
'overwrite.cli.url' => 'https://{{ atlas_nextcloud_domain }}',
'overwritehost' => '{{ atlas_nextcloud_domain }}',
'overwriteprotocol' => 'https',
'allow_local_remote_servers' => true,
'default_quota' => 'none',
'skeletondirectory' => '',
'maintenance_window_start' => 1,
'default_phone_region' => 'IT',
'twofactor_enforced' => false,
'onlyoffice' => [
'DocumentServerUrl' => 'https://{{ atlas_onlyoffice_domain }}/',
'DocumentServerInternalUrl' => 'http://atlas-onlyoffice/',
'StorageUrl' => 'http://atlas-nextcloud/',
'jwt_secret' => trim(file_get_contents('/run/secrets/onlyoffice-jwt')),
'jwt_header' => 'AuthorizationJwt',
'allow_local_address' => true,
],
];

View File

@@ -0,0 +1,42 @@
[Unit]
Description=Atlas Nextcloud
Requires=atlas-nextcloud-db.service atlas-nextcloud-redis.service
After=atlas-nextcloud-db.service atlas-nextcloud-redis.service
RequiresMountsFor={{ atlas_nextcloud_root }}/app {{ atlas_nextcloud_root }}/files
[Container]
ContainerName=atlas-nextcloud
Image={{ atlas_nextcloud_image }}
Network=atlas-nextcloud.network
NetworkAlias=atlas-nextcloud
PublishPort={{ ansible_host }}:{{ atlas_nextcloud_http_port }}:80
PublishPort=127.0.0.1:{{ atlas_nextcloud_http_port }}:80
Environment=POSTGRES_HOST=atlas-nextcloud-db
Environment=POSTGRES_DB=nextcloud
Environment=POSTGRES_USER=nextcloud
Environment=POSTGRES_PASSWORD_FILE=/run/secrets/postgres-password
Environment=NEXTCLOUD_ADMIN_USER={{ atlas_nextcloud_admin }}
Environment=NEXTCLOUD_ADMIN_PASSWORD_FILE=/run/secrets/admin-password
Environment="NEXTCLOUD_TRUSTED_DOMAINS={{ atlas_nextcloud_domain }} atlas-nextcloud"
Environment=REDIS_HOST=atlas-nextcloud-redis
Environment=REDIS_HOST_PASSWORD_FILE=/run/secrets/redis-password
Environment=APACHE_DISABLE_REWRITE_IP=1
Environment=PHP_MEMORY_LIMIT=512M
Environment=PHP_UPLOAD_LIMIT=2G
Volume={{ atlas_nextcloud_root }}/app:/var/www/html:Z
Volume={{ atlas_nextcloud_root }}/files:/var/www/html/data:Z
Volume={{ atlas_nextcloud_app_cache }}:/mnt/atlas-apps:ro,z
Volume={{ atlas_nextcloud_private_dir }}/postgres-password:/run/secrets/postgres-password:ro,z
Volume={{ atlas_nextcloud_private_dir }}/admin-password:/run/secrets/admin-password:ro,z
Volume={{ atlas_nextcloud_private_dir }}/redis-password:/run/secrets/redis-password:ro,z
Volume={{ atlas_nextcloud_private_dir }}/onlyoffice-jwt:/run/secrets/onlyoffice-jwt:ro,z
PodmanArgs=--memory=2g
NoNewPrivileges=true
[Service]
Restart=on-failure
RestartSec=10
TimeoutStartSec=900
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,5 @@
# Managed by Ansible: private rootless application network, no host services.
[Network]
NetworkName=atlas-nextcloud
Subnet={{ atlas_nextcloud_network_subnet }}
Gateway={{ atlas_nextcloud_network_gateway }}

View File

@@ -0,0 +1,26 @@
[Unit]
Description=Atlas ONLYOFFICE Docs Community
RequiresMountsFor={{ atlas_nextcloud_root }}/office
[Container]
ContainerName=atlas-onlyoffice
Image={{ atlas_onlyoffice_image }}
Network=atlas-nextcloud.network
NetworkAlias=atlas-onlyoffice
PublishPort={{ ansible_host }}:{{ atlas_onlyoffice_http_port }}:80
PublishPort=127.0.0.1:{{ atlas_onlyoffice_http_port }}:80
EnvironmentFile={{ atlas_nextcloud_private_dir }}/onlyoffice.env
Volume={{ atlas_nextcloud_root }}/office/data:/var/www/onlyoffice/Data:Z
Volume={{ atlas_nextcloud_root }}/office/lib:/var/lib/onlyoffice:Z
Volume={{ atlas_nextcloud_root }}/office/logs:/var/log/onlyoffice:Z
Volume={{ atlas_nextcloud_root }}/office/database:/var/lib/postgresql:Z
PodmanArgs=--memory=4g --shm-size=256m
NoNewPrivileges=true
[Service]
Restart=on-failure
RestartSec=10
TimeoutStartSec=1200
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,7 @@
JWT_ENABLED=true
JWT_SECRET={{ vault_nextcloud_onlyoffice_jwt }}
JWT_HEADER=AuthorizationJwt
ALLOW_PRIVATE_IP_ADDRESS=true
ALLOW_META_IP_ADDRESS=false
USE_UNAUTHORIZED_STORAGE=false
WOPI_ENABLED=false

View File

@@ -30,3 +30,7 @@ backend_phase1_timezone: Europe/Rome
backend_phase1_services: backend_phase1_services:
- atlas-navidrome.service - atlas-navidrome.service
- atlas-syncthing.service - atlas-syncthing.service
backend_phase1_music_sync_enabled: false
backend_phase1_music_source_dir: "{{ backend_phase1_archive_dir }}/Music"
backend_phase1_music_sync_calendar: "*-*-* 00:45:00 Europe/Rome"
backend_phase1_user_systemd_dir: "{{ backend_phase1_user_home }}/.config/systemd/user"

View File

@@ -18,21 +18,28 @@
- backend_phase1_app_data_root.startswith('/') - backend_phase1_app_data_root.startswith('/')
- backend_phase1_navidrome_data_dir.startswith(backend_phase1_app_data_root + '/') - backend_phase1_navidrome_data_dir.startswith(backend_phase1_app_data_root + '/')
- backend_phase1_syncthing_root.startswith(backend_phase1_app_data_root + '/') - backend_phase1_syncthing_root.startswith(backend_phase1_app_data_root + '/')
- >-
not (backend_phase1_music_sync_enabled | bool) or
(backend_phase1_music_source_dir.startswith(backend_phase1_archive_dir + '/')
and backend_phase1_music_sync_calendar | length > 0)
fail_msg: >- fail_msg: >-
Disable the rootful media-stack gate and provide the Atlas LAN bind Disable the rootful media-stack gate and provide the Atlas LAN bind
address, firewall sources, and absolute ZFS-backed paths before address, firewall sources, and absolute ZFS-backed paths before
enabling phase one. This role does not manage Prometheus or migrate enabling phase one. This role does not manage Prometheus or migrate
application data. application data.
tags: [music_sync]
- name: Read the rootless service account - name: Read the rootless service account
ansible.builtin.getent: ansible.builtin.getent:
database: passwd database: passwd
key: "{{ backend_phase1_username }}" key: "{{ backend_phase1_username }}"
tags: [music_sync]
- name: Record rootless service account IDs - name: Record rootless service account IDs
ansible.builtin.set_fact: ansible.builtin.set_fact:
backend_phase1_uid: "{{ ansible_facts['getent_passwd'][backend_phase1_username][1] }}" backend_phase1_uid: "{{ ansible_facts['getent_passwd'][backend_phase1_username][1] }}"
backend_phase1_gid: "{{ ansible_facts['getent_passwd'][backend_phase1_username][2] }}" backend_phase1_gid: "{{ ansible_facts['getent_passwd'][backend_phase1_username][2] }}"
tags: [music_sync]
- name: Read system service state before starting rootless Syncthing - name: Read system service state before starting rootless Syncthing
ansible.builtin.service_facts: ansible.builtin.service_facts:
@@ -65,6 +72,7 @@
loop_control: loop_control:
label: "{{ item.dataset }}" label: "{{ item.dataset }}"
register: backend_phase1_zfs_facts register: backend_phase1_zfs_facts
tags: [music_sync]
- name: Require mounted datasets at the declared paths - name: Require mounted datasets at the declared paths
ansible.builtin.assert: ansible.builtin.assert:
@@ -79,6 +87,23 @@
loop: "{{ backend_phase1_zfs_facts.results }}" loop: "{{ backend_phase1_zfs_facts.results }}"
loop_control: loop_control:
label: "{{ item.item.dataset }}" label: "{{ item.item.dataset }}"
tags: [music_sync]
- name: Inspect the music copy source
ansible.builtin.stat:
path: "{{ backend_phase1_music_source_dir }}"
register: backend_phase1_music_source_stat
when: backend_phase1_music_sync_enabled | bool
tags: [music_sync]
- name: Require an existing music source directory
ansible.builtin.assert:
that:
- backend_phase1_music_source_stat.stat.isdir | default(false)
fail_msg: >-
{{ backend_phase1_music_source_dir }} must exist before enabling the daily music copy.
when: backend_phase1_music_sync_enabled | bool
tags: [music_sync]
- name: Enable lingering for the rootless service account - name: Enable lingering for the rootless service account
ansible.builtin.command: ansible.builtin.command:
@@ -87,12 +112,14 @@
- enable-linger - enable-linger
- "{{ backend_phase1_username }}" - "{{ backend_phase1_username }}"
creates: "/var/lib/systemd/linger/{{ backend_phase1_username }}" creates: "/var/lib/systemd/linger/{{ backend_phase1_username }}"
tags: [music_sync]
- name: Start the rootless user systemd manager - name: Start the rootless user systemd manager
ansible.builtin.systemd: ansible.builtin.systemd:
name: "user@{{ backend_phase1_uid }}.service" name: "user@{{ backend_phase1_uid }}.service"
state: started state: started
when: not ansible_check_mode when: not ansible_check_mode
tags: [music_sync]
- name: Create rootless Quadlet and application directories - name: Create rootless Quadlet and application directories
ansible.builtin.file: ansible.builtin.file:
@@ -113,6 +140,43 @@
loop_control: loop_control:
label: "{{ item.path }}" label: "{{ item.path }}"
- name: Install rsync for the daily music copy
ansible.builtin.dnf:
name: rsync
state: present
when: backend_phase1_music_sync_enabled | bool
tags: [music_sync]
- name: Create the rootless user systemd directory
ansible.builtin.file:
path: "{{ backend_phase1_user_systemd_dir }}"
state: directory
owner: "{{ backend_phase1_username }}"
group: "{{ backend_phase1_user_group }}"
mode: "0700"
when: backend_phase1_music_sync_enabled | bool
tags: [music_sync]
- name: Install the daily music copy service
ansible.builtin.template:
src: atlas-music-sync.service.j2
dest: "{{ backend_phase1_user_systemd_dir }}/atlas-music-sync.service"
owner: "{{ backend_phase1_username }}"
group: "{{ backend_phase1_user_group }}"
mode: "0644"
when: backend_phase1_music_sync_enabled | bool
tags: [music_sync]
- name: Install the daily music copy timer
ansible.builtin.template:
src: atlas-music-sync.timer.j2
dest: "{{ backend_phase1_user_systemd_dir }}/atlas-music-sync.timer"
owner: "{{ backend_phase1_username }}"
group: "{{ backend_phase1_user_group }}"
mode: "0644"
when: backend_phase1_music_sync_enabled | bool
tags: [music_sync]
- name: Render the rootless Navidrome Quadlet - name: Render the rootless Navidrome Quadlet
ansible.builtin.template: ansible.builtin.template:
src: atlas-navidrome.container.j2 src: atlas-navidrome.container.j2
@@ -140,6 +204,7 @@
XDG_RUNTIME_DIR: "/run/user/{{ backend_phase1_uid }}" XDG_RUNTIME_DIR: "/run/user/{{ backend_phase1_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ backend_phase1_uid }}/bus" DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ backend_phase1_uid }}/bus"
when: not ansible_check_mode when: not ansible_check_mode
tags: [music_sync]
- name: Permit NPM access to phase-one web interfaces through Aegis - name: Permit NPM access to phase-one web interfaces through Aegis
ansible.posix.firewalld: ansible.posix.firewalld:
@@ -186,3 +251,19 @@
when: when:
- backend_phase1_start_services | bool - backend_phase1_start_services | bool
- not ansible_check_mode - not ansible_check_mode
- name: Enable the daily music copy timer
become_user: "{{ backend_phase1_username }}"
ansible.builtin.systemd:
name: atlas-music-sync.timer
scope: user
state: started
enabled: true
daemon_reload: true
environment:
XDG_RUNTIME_DIR: "/run/user/{{ backend_phase1_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ backend_phase1_uid }}/bus"
when:
- backend_phase1_music_sync_enabled | bool
- not ansible_check_mode
tags: [music_sync]

View File

@@ -0,0 +1,10 @@
# Managed by Ansible. Do not edit manually.
[Unit]
Description=Copy Atlas Archive music to the Navidrome library
[Service]
Type=oneshot
ExecStartPre=/usr/bin/mountpoint -q {{ backend_phase1_archive_dir }}
ExecStartPre=/usr/bin/mountpoint -q {{ backend_phase1_music_dir }}
ExecStartPre=/usr/bin/test -d {{ backend_phase1_music_source_dir }}
ExecStart=/usr/bin/rsync -aH --no-perms --no-owner --no-group --delay-updates --stats -- {{ backend_phase1_music_source_dir }}/ {{ backend_phase1_music_dir }}/

View File

@@ -0,0 +1,11 @@
# Managed by Ansible. Do not edit manually.
[Unit]
Description=Schedule the daily Atlas Navidrome music copy
[Timer]
OnCalendar={{ backend_phase1_music_sync_calendar }}
Persistent=true
Unit=atlas-music-sync.service
[Install]
WantedBy=timers.target

View File

@@ -44,13 +44,14 @@
when: server_backup_export_enabled | bool when: server_backup_export_enabled | bool
- name: Install Prometheus backup export helper - name: Install Prometheus backup export helper
tags: [services, backup, prometheus_backup] tags: [services, backup, prometheus_backup, gitea_cutover, npm_quadlet_backup]
ansible.builtin.template: ansible.builtin.template:
src: prometheus-backup-export.sh.j2 src: prometheus-backup-export.sh.j2
dest: /usr/local/sbin/prometheus-backup-export dest: /usr/local/sbin/prometheus-backup-export
owner: root owner: root
group: root group: root
mode: "0750" mode: "0750"
validate: "bash -n %s"
when: server_backup_export_enabled | bool when: server_backup_export_enabled | bool
- name: Install Prometheus backup export systemd units - name: Install Prometheus backup export systemd units

View File

@@ -1,33 +0,0 @@
---
- name: Require DuckDNS domain and Vault token before deployment
ansible.builtin.assert:
that:
- >-
server_duckdns_domain | default('') is
regex('[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?', match_type='fullmatch')
- >-
vault_duckdns_token | default('') is
regex('[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}', match_type='fullmatch')
fail_msg: >-
Define server_duckdns_domain in host_vars and the rotated vault_duckdns_token
in encrypted Vault or an untracked local vars file before deploying DuckDNS.
no_log: true
- name: Ensure private DuckDNS directory exists
ansible.builtin.file:
path: "{{ server_user_home }}/duckdns"
state: directory
owner: "{{ server_username }}"
group: "{{ server_user_group }}"
mode: "0700"
- name: Render DuckDNS updater with the Vault token
ansible.builtin.template:
src: duck.sh.j2
dest: "{{ server_user_home }}/duckdns/duck.sh"
owner: "{{ server_username }}"
group: "{{ server_user_group }}"
mode: "0700"
validate: /bin/sh -n %s
no_log: true
diff: false

View File

@@ -0,0 +1,59 @@
---
- name: Validate the NPM Gitea cutover override
tags: [services, gitea_cutover]
ansible.builtin.assert:
that:
- server_gitea_proxy_enabled | bool
- server_gitea_npm_domains | length > 0
- server_gitea_npm_domains | select('match', '^[a-zA-Z0-9.-]+$') | list | length == server_gitea_npm_domains | length
fail_msg: Declare the exact NPM Gitea hostnames before enabling the Atlas upstream.
when: server_gitea_on_atlas | bool
- name: Ensure the NPM custom configuration directory exists
tags: [services, gitea_cutover]
ansible.builtin.file:
path: /opt/npm/data/nginx/custom
state: directory
owner: root
group: root
mode: "0755"
when: server_gitea_proxy_enabled | bool
- name: Render the Gitea-only NPM runtime upstream override
tags: [services, gitea_cutover]
ansible.builtin.template:
src: prometheus-gitea-npm-proxy.conf.j2
dest: /opt/npm/data/nginx/custom/server_proxy.conf
owner: root
group: root
mode: "0644"
register: server_gitea_npm_override
when: server_gitea_on_atlas | bool
- name: Remove the Gitea NPM override when source routing is selected
tags: [services, gitea_cutover]
ansible.builtin.file:
path: /opt/npm/data/nginx/custom/server_proxy.conf
state: absent
when:
- server_gitea_proxy_enabled | bool
- not server_gitea_on_atlas | bool
- name: Validate NPM configuration after a Gitea upstream change
tags: [services, gitea_cutover]
ansible.builtin.command:
argv: [podman, exec, nginx-proxy-manager, nginx, -t]
changed_when: false
when:
- server_gitea_on_atlas | bool
- server_gitea_npm_override is changed
- not ansible_check_mode
- name: Reload NPM after validating the Gitea upstream change
tags: [services, gitea_cutover]
ansible.builtin.command:
argv: [podman, exec, nginx-proxy-manager, nginx, -s, reload]
when:
- server_gitea_on_atlas | bool
- server_gitea_npm_override is changed
- not ansible_check_mode

View File

@@ -0,0 +1,61 @@
---
- name: Validate the Prometheus Gitea SSH cutover inputs
tags: [services, gitea_cutover]
ansible.builtin.assert:
that:
- server_gitea_proxy_enabled | bool
- server_gitea_atlas_address is match('^[0-9]{1,3}(\.[0-9]{1,3}){3}$')
- server_gitea_ssh_public_port | int > 1024
- server_gitea_ssh_public_port | int < 65536
- server_gitea_ssh_target_port | int > 1024
- server_gitea_ssh_target_port | int < 65536
- server_gitea_ssh_public_port | int != 22
fail_msg: Keep administrative SSH on 22 and provide the Atlas rootless Gitea SSH endpoint.
when: server_gitea_on_atlas | bool
- name: Install the Gitea SSH socket proxy units without activating them
tags: [services, gitea_cutover]
ansible.builtin.template:
src: "{{ item }}.j2"
dest: "/etc/systemd/system/{{ item }}"
owner: root
group: root
mode: "0644"
loop:
- prometheus-gitea-ssh-proxy.socket
- prometheus-gitea-ssh-proxy.service
loop_control:
label: "{{ item }}"
register: server_gitea_ssh_proxy_units
when: server_gitea_proxy_enabled | bool
- name: Reload systemd after Gitea SSH proxy unit changes
tags: [services, gitea_cutover]
ansible.builtin.systemd:
daemon_reload: true
when:
- server_gitea_proxy_enabled | bool
- server_gitea_ssh_proxy_units is changed
- not ansible_check_mode
- name: Manage the public Gitea SSH socket separately from administrative SSH
tags: [services, gitea_cutover]
ansible.builtin.systemd:
name: prometheus-gitea-ssh-proxy.socket
state: "{{ 'started' if server_gitea_on_atlas | bool else 'stopped' }}"
enabled: "{{ server_gitea_on_atlas | bool }}"
when:
- server_gitea_proxy_enabled | bool
- not ansible_check_mode
- name: Open only the public Gitea SSH port after cutover
tags: [services, gitea_cutover]
ansible.posix.firewalld:
port: "{{ server_gitea_ssh_public_port }}/tcp"
zone: "{{ server_firewalld_zone }}"
state: "{{ 'enabled' if server_gitea_on_atlas | bool else 'disabled' }}"
permanent: true
immediate: true
when:
- server_gitea_proxy_enabled | bool
- server_firewall_backend == 'firewalld'

View File

@@ -0,0 +1,112 @@
---
- name: Require explicit retirement of the migrated Prometheus source
ansible.builtin.assert:
that:
- inventory_hostname == 'prometheus'
- server_legacy_stack_retired | bool
- server_gitea_on_atlas | bool
- server_npm_quadlet_cutover | bool
- server_backup_export_enabled | bool
- name: Verify legacy paths have no mounts or container users
ansible.builtin.command:
argv:
- python3
- -c
- |
import json, os, pathlib, subprocess
def run(*args):
return subprocess.check_output(args, text=True).strip()
paths = ['/opt/gitea', '/home/git/.ssh', '/opt/navidrome',
'/opt/postgres', '/opt/music', '/opt/containerd', '/opt/docker']
mounts = json.loads(run('findmnt', '--json', '--list', '-o', 'TARGET'))['filesystems']
for path in paths:
assert os.path.realpath(path) == path, 'Symlink in cleanup path: ' + path
for mount in mounts:
target = mount['target']
assert target != path and not target.startswith(path + '/'), 'Mounted cleanup path: ' + path
ids = run('podman', 'ps', '-aq').split()
containers = json.loads(run('podman', 'inspect', *ids)) if ids else []
for container in containers:
assert container['Name'].lstrip('/') == 'nginx-proxy-manager', 'Unexpected container; review before cleanup'
for mount in container.get('Mounts', []):
source = os.path.realpath(mount['Source'])
for path in paths:
assert source != path and not source.startswith(path + '/'), 'Container uses cleanup path: ' + path
for path in ['/opt/music', '/opt/containerd']:
if os.path.isdir(path):
for entry in pathlib.Path(path).rglob('*'):
assert entry.is_dir() and not entry.is_symlink(), 'Unexpected file in empty legacy path: ' + str(entry)
if os.path.isdir('/opt/docker'):
allowed = {'/opt/docker/server', '/opt/docker/server/docker-compose.yml'}
for entry in pathlib.Path('/opt/docker').rglob('*'):
assert str(entry) in allowed and not entry.is_symlink(), 'Unexpected legacy Docker content: ' + str(entry)
assert run('systemctl', 'is-active', 'prometheus-npm.service') == 'active'
assert subprocess.run(['systemctl', 'is-active', '--quiet', 'podman-compose-server.service']).returncode != 0
assert subprocess.run(['systemctl', 'is-active', '--quiet', 'prometheus-backup-export.service']).returncode != 0
print('Legacy cleanup preflight passed')
changed_when: false
check_mode: false
- name: Require the updated backup configuration before deleting fallback files
ansible.builtin.command:
argv:
- python3
- -c
- |
import pathlib, subprocess
unit = subprocess.check_output(['systemctl', 'show', 'prometheus-backup-export.service',
'-p', 'RequiresMountsFor', '--value'], text=True)
assert '/opt/gitea' not in unit, 'Backup unit still depends on legacy Gitea'
helper = pathlib.Path('/usr/local/sbin/prometheus-backup-export').read_text()
assert 'podman-compose-server' not in helper and 'opt/docker/server' not in helper
subprocess.run(['bash', '-n', '/usr/local/sbin/prometheus-backup-export'], check=True)
changed_when: false
when: not ansible_check_mode
- name: Delete only the explicitly approved legacy data and fallback files
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop:
- /opt/gitea
- /home/git/.ssh
- /opt/navidrome
- /opt/postgres
- /opt/music
- /opt/containerd
- /opt/docker
- /usr/local/sbin/prometheus-gitea-final-export
- /etc/systemd/system/podman-compose-server.service
register: server_legacy_deleted
diff: false
- name: Reload systemd after removing the inactive legacy unit
ansible.builtin.systemd:
daemon_reload: true
when:
- server_legacy_deleted is changed
- not ansible_check_mode
- name: Inspect the obsolete Git home without following symlinks
ansible.builtin.stat:
path: /home/git
follow: false
register: server_legacy_git_home
- name: Require the obsolete Git account to be absent before removing its empty home
ansible.builtin.command:
argv: [getent, passwd, git]
register: server_legacy_git_account
changed_when: false
failed_when: server_legacy_git_account.rc != 2
check_mode: false
when: server_legacy_git_home.stat.exists
# rmdir refuses any nonempty directory; never recursively delete this parent.
- name: Remove only the empty obsolete Git home
ansible.builtin.command:
argv: [rmdir, /home/git]
register: server_legacy_git_home_removed
changed_when: server_legacy_git_home_removed.rc == 0
when: server_legacy_git_home.stat.exists

View File

@@ -0,0 +1,33 @@
---
- name: Require the migrated Prometheus topology for image cleanup
ansible.builtin.assert:
that:
- inventory_hostname == 'prometheus'
- server_gitea_on_atlas | bool
- server_npm_quadlet_cutover | bool
- server_legacy_images | default([]) | length > 0
- >-
server_legacy_images | difference([
'docker.gitea.com/gitea:1.25.2',
'docker.io/deluan/navidrome:latest',
'docker.io/library/postgres:13']) | length == 0
- name: Check whether the explicitly selected legacy images exist
ansible.builtin.command:
argv: [podman, image, exists, "{{ item }}"]
loop: "{{ server_legacy_images }}"
register: server_legacy_image_presence
changed_when: false
failed_when: server_legacy_image_presence.rc not in [0, 1]
check_mode: false
# No --force: Podman must refuse images referenced by any existing container.
- name: Remove only unused explicitly selected legacy images
ansible.builtin.command:
argv: [podman, image, rm, "{{ item.item }}"]
loop: "{{ server_legacy_image_presence.results }}"
loop_control:
label: "{{ item.item }}"
when: item.rc == 0
register: server_legacy_image_removal
changed_when: server_legacy_image_removal.rc == 0

View File

@@ -8,10 +8,6 @@
fail_msg: >- fail_msg: >-
server_firewall_backend must be firewalld for the Rocky server profile. server_firewall_backend must be firewalld for the Rocky server profile.
- name: Configure DuckDNS updater
tags: [dotfiles, dotfiles:server, duckdns]
ansible.builtin.import_tasks: duckdns.yml
- name: Ensure server directories exist - name: Ensure server directories exist
tags: [dotfiles, services] tags: [dotfiles, services]
ansible.builtin.file: ansible.builtin.file:
@@ -23,6 +19,9 @@
loop: "{{ server_directories | default([]) }}" loop: "{{ server_directories | default([]) }}"
loop_control: loop_control:
label: "{{ item.path }}" label: "{{ item.path }}"
when:
- item.path != '/opt/gitea/data' or not server_gitea_on_atlas | bool
- item.path != server_container_stack_dir or not server_legacy_stack_retired | bool
- name: Copy server dotfiles - name: Copy server dotfiles
tags: [dotfiles, dotfiles:server] tags: [dotfiles, dotfiles:server]
@@ -37,7 +36,7 @@
label: "{{ item.dest }}" label: "{{ item.dest }}"
- name: Render server templates - name: Render server templates
tags: [dotfiles, dotfiles:server] tags: [dotfiles, dotfiles:server, gitea_cutover]
ansible.builtin.template: ansible.builtin.template:
src: "{{ item.src }}" src: "{{ item.src }}"
dest: "{{ item.dest if item.dest.startswith('/') else server_user_home ~ '/' ~ item.dest }}" dest: "{{ item.dest if item.dest.startswith('/') else server_user_home ~ '/' ~ item.dest }}"
@@ -48,16 +47,38 @@
loop_control: loop_control:
label: "{{ item.dest }}" label: "{{ item.dest }}"
no_log: "{{ item.no_log | default(false) }}" no_log: "{{ item.no_log | default(false) }}"
when: item.src != 'server/docker-compose.yml.j2' or not server_legacy_stack_retired | bool
- name: Manage Podman Compose stack - name: Manage Podman Compose stack
tags: [services, podman] tags: [services, podman]
ansible.builtin.include_tasks: podman-compose.yml ansible.builtin.include_tasks: podman-compose.yml
when: not server_legacy_stack_retired | bool
- name: Import staged NPM Quadlet tasks
ansible.builtin.import_tasks: npm_quadlet.yml
- name: Import explicit legacy server image cleanup
ansible.builtin.import_tasks: legacy_image_cleanup.yml
tags: [never, server_image_cleanup]
when: server_legacy_image_cleanup | default(false) | bool
- name: Import Prometheus backup export identity tasks - name: Import Prometheus backup export identity tasks
ansible.builtin.import_tasks: backup_export_identity.yml ansible.builtin.import_tasks: backup_export_identity.yml
- name: Import Prometheus backup export job tasks - name: Import Prometheus backup export job tasks
ansible.builtin.import_tasks: backup_export_job.yml ansible.builtin.import_tasks: backup_export_job.yml
tags: [server_legacy_cleanup]
- name: Import explicitly approved legacy server data cleanup
ansible.builtin.import_tasks: legacy_cleanup.yml
tags: [never, server_legacy_cleanup]
when: server_legacy_cleanup | bool
- name: Import Prometheus Gitea SSH proxy tasks
ansible.builtin.import_tasks: gitea_ssh_proxy.yml
- name: Import Prometheus Gitea NPM proxy override tasks
ansible.builtin.import_tasks: gitea_npm_proxy.yml
- name: Ensure server SSH authorized key fragments directory exists - name: Ensure server SSH authorized key fragments directory exists
tags: [services, ssh] tags: [services, ssh]

View File

@@ -0,0 +1,71 @@
---
- name: Require staged NPM Quadlet for an active cutover
tags: [services, npm_quadlet]
ansible.builtin.assert:
that:
- not server_npm_quadlet_cutover | bool or server_npm_quadlet_stage | bool
fail_msg: The NPM Quadlet cutover requires the staged container and network.
- name: Validate staged NPM Quadlet inputs
tags: [services, npm_quadlet]
ansible.builtin.assert:
that:
- server_npm_quadlet_image is defined
- server_npm_quadlet_image is match('^docker\.io/jc21/nginx-proxy-manager@sha256:[a-f0-9]{64}$')
- server_gitea_on_atlas | bool
fail_msg: Stage the exact running NPM image only after Gitea has left Compose.
when: server_npm_quadlet_stage | bool
- name: Ensure rootful Quadlet directory exists for NPM
tags: [services, npm_quadlet]
ansible.builtin.file:
path: /etc/containers/systemd
state: directory
owner: root
group: root
mode: "0755"
when: server_npm_quadlet_stage | bool
- name: Render staged NPM container and network Quadlets
tags: [services, npm_quadlet]
ansible.builtin.template:
src: "{{ item }}.j2"
dest: "/etc/containers/systemd/{{ item }}"
owner: root
group: root
mode: "0644"
loop:
- prometheus-npm.container
- server-web.network
loop_control:
label: "{{ item }}"
register: server_npm_quadlet_units
when: server_npm_quadlet_stage | bool
- name: Reload systemd after staging NPM Quadlets
tags: [services, npm_quadlet]
ansible.builtin.systemd:
daemon_reload: true
when:
- server_npm_quadlet_stage | bool
- server_npm_quadlet_units is changed
- not ansible_check_mode
- name: Verify the staged NPM Quadlet was generated
tags: [services, npm_quadlet]
ansible.builtin.command:
argv: [systemctl, show, prometheus-npm.service, --property=LoadState, --value]
register: server_npm_quadlet_load_state
changed_when: false
when:
- server_npm_quadlet_stage | bool
- not ansible_check_mode
- name: Reject an invalid staged NPM Quadlet
tags: [services, npm_quadlet]
ansible.builtin.assert:
that: server_npm_quadlet_load_state.stdout == 'loaded'
fail_msg: Quadlet generator did not produce prometheus-npm.service.
when:
- server_npm_quadlet_stage | bool
- not ansible_check_mode

View File

@@ -1,24 +0,0 @@
#!/bin/sh
# Managed by Ansible. Contains a Vault token; never copy this file into Git.
set -eu
umask 077
log_file={{ (server_user_home ~ '/duckdns/duck.log') | quote }}
# Keep the token out of process arguments and verify the HTTPS certificate.
if ! response=$(curl --fail --silent --show-error --connect-timeout 10 --max-time 30 --config - <<'DUCKDNS_CONFIG'
url = "https://www.duckdns.org/update?domains={{ server_duckdns_domain }}&token={{ vault_duckdns_token }}&ip="
DUCKDNS_CONFIG
); then
printf 'ERROR\n' > "$log_file"
exit 1
fi
case "$response" in
OK) printf 'OK\n' > "$log_file" ;;
*)
printf 'KO\n' > "$log_file"
printf 'DuckDNS update failed.\n' >&2
exit 1
;;
esac

View File

@@ -1,6 +1,6 @@
[Unit] [Unit]
Description=Prepare a read-only Prometheus application backup for Atlas Description=Prepare a read-only Prometheus application backup for Atlas
RequiresMountsFor=/opt/npm /opt/gitea {{ server_backup_export_root }} RequiresMountsFor=/opt/npm {% if not server_gitea_on_atlas | bool %}/opt/gitea {% endif %}{{ server_backup_export_root }}
ConditionFileIsExecutable=/usr/local/sbin/prometheus-backup-export ConditionFileIsExecutable=/usr/local/sbin/prometheus-backup-export
[Service] [Service]

View File

@@ -4,7 +4,24 @@ umask 077
export_root={{ server_backup_export_root | quote }} export_root={{ server_backup_export_root | quote }}
versions="$export_root/versions" versions="$export_root/versions"
stack_unit=podman-compose-server.service {% if server_legacy_stack_retired | bool %}
stack_unit=prometheus-npm.service
{% else %}
stack_unit=''
compose_active=false
quadlet_active=false
systemctl is-active --quiet podman-compose-server.service && compose_active=true
systemctl is-active --quiet prometheus-npm.service && quadlet_active=true
if [[ "$compose_active" == "$quadlet_active" ]]; then
echo 'Expected exactly one active NPM service (Compose or Quadlet)' >&2
exit 1
fi
if "$quadlet_active"; then
stack_unit=prometheus-npm.service
else
stack_unit=podman-compose-server.service
fi
{% endif %}
stamp=$(date -u +%Y%m%dT%H%M%SZ) stamp=$(date -u +%Y%m%dT%H%M%SZ)
stage='' stage=''
stack_stopped=false stack_stopped=false
@@ -33,7 +50,7 @@ trap 'exit 130' INT
trap 'exit 143' TERM trap 'exit 143' TERM
systemctl is-active --quiet "$stack_unit" || { systemctl is-active --quiet "$stack_unit" || {
echo 'The managed Compose stack must be active before preparing a backup' >&2 echo "The managed NPM unit $stack_unit must be active before preparing a backup" >&2
exit 1 exit 1
} }
@@ -59,7 +76,7 @@ stack_stopped=true
systemctl stop "$stack_unit" systemctl stop "$stack_unit"
tar --acls --xattrs --selinux "${excludes[@]}" -C / -cf "$stage/payload.tar" "${paths[@]}" tar --acls --xattrs --selinux "${excludes[@]}" -C / -cf "$stage/payload.tar" "${paths[@]}"
systemctl start "$stack_unit" systemctl start "$stack_unit"
for container in nginx-proxy-manager gitea; do for container in nginx-proxy-manager{% if not server_gitea_on_atlas | bool %} gitea{% endif %}; do
running=false running=false
for _ in {1..30}; do for _ in {1..30}; do
if [[ $(podman inspect --format '{{ '{{.State.Running}}' }}' "$container" 2>/dev/null) == true ]]; then if [[ $(podman inspect --format '{{ '{{.State.Running}}' }}' "$container" 2>/dev/null) == true ]]; then
@@ -70,6 +87,15 @@ for container in nginx-proxy-manager gitea; do
done done
"$running" || { echo "Container did not restart: $container" >&2; exit 1; } "$running" || { echo "Container did not restart: $container" >&2; exit 1; }
done done
ready=false
for _ in {1..60}; do
if curl -fsS --connect-timeout 2 --max-time 3 -o /dev/null http://127.0.0.1:81/; then
ready=true
break
fi
sleep 2
done
"$ready" || { echo 'NPM administration did not become ready after backup' >&2; exit 1; }
stack_stopped=false stack_stopped=false
tar -tf "$stage/payload.tar" >/dev/null tar -tf "$stage/payload.tar" >/dev/null

View File

@@ -0,0 +1,6 @@
# Managed by Ansible. NPM's variable proxy upstream uses Nginx DNS, not /etc/hosts.
{% for domain in server_gitea_npm_domains %}
if ($host = {{ domain }}) {
set $server {{ server_gitea_atlas_address }};
}
{% endfor %}

View File

@@ -0,0 +1,12 @@
[Unit]
Description=Forward public Gitea SSH to Atlas through Aegis
Requires=prometheus-gitea-ssh-proxy.socket
After=network-online.target wg-quick@wg0.service
[Service]
ExecStart=/usr/lib/systemd/systemd-socket-proxyd {{ server_gitea_atlas_address }}:{{ server_gitea_ssh_target_port }}
DynamicUser=true
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Public Gitea SSH socket on Prometheus
[Socket]
ListenStream=0.0.0.0:{{ server_gitea_ssh_public_port }}
NoDelay=true
[Install]
WantedBy=sockets.target

View File

@@ -0,0 +1,26 @@
[Unit]
Description=Nginx Proxy Manager on Prometheus
RequiresMountsFor=/opt/npm/data /opt/npm/letsencrypt
[Container]
Image={{ server_npm_quadlet_image }}
ContainerName=nginx-proxy-manager
Network=server-web.network
NetworkAlias=nginx-proxy-manager
AddHost=host.containers.internal:host-gateway
PublishPort=80:80
PublishPort=443:443
PublishPort=127.0.0.1:81:81
Volume=/opt/npm/data:/data
Volume=/opt/npm/letsencrypt:/etc/letsencrypt
Pull=missing
[Service]
Restart=always
TimeoutStartSec=180
TimeoutStopSec=120
{% if server_npm_quadlet_cutover | bool %}
[Install]
WantedBy=multi-user.target
{% endif %}

View File

@@ -0,0 +1,5 @@
[Network]
NetworkName=server_web
Driver=bridge
Subnet=10.89.0.0/24
Gateway=10.89.0.1

View File

@@ -4,7 +4,7 @@ name: server
services: services:
nginx-proxy-manager: nginx-proxy-manager:
image: docker.io/jc21/nginx-proxy-manager:latest image: {{ server_npm_quadlet_image if server_npm_quadlet_stage | bool else 'docker.io/jc21/nginx-proxy-manager:latest' }}
container_name: nginx-proxy-manager container_name: nginx-proxy-manager
restart: unless-stopped restart: unless-stopped
ports: ports:
@@ -38,6 +38,7 @@ services:
# networks: # networks:
# - web # - web
{% if not server_gitea_on_atlas | bool %}
gitea: gitea:
image: docker.gitea.com/gitea:1.25.2 image: docker.gitea.com/gitea:1.25.2
container_name: gitea container_name: gitea
@@ -55,6 +56,7 @@ services:
ports: ports:
- "3000:3000" - "3000:3000"
- "127.0.0.1:222:22" - "127.0.0.1:222:22"
{% endif %}
networks: networks:

View File

@@ -0,0 +1,246 @@
# Gitea migration from Prometheus to Atlas
Historical record: the completed owner-migration, migration-restore and final-export
tasks, helpers and flags have been removed from the repository. Commands below
record past execution, not currently supported migration entry points. Current
service safety checks, recurring backups and proxy configuration remain managed.
The later 2026-10-03 canonical-domain change to `git.fscotto.co` is recorded
in `docs/domain-fscotto-co.md`. Public SSH remains on TCP/2222; the old
DuckDNS Proxy Host was observed disabled. Earlier domain references below
describe migration evidence, not the current canonical URL.
This records the staged migration and its observed partial cutover. Gitea is
temporary on Atlas until Uranus; NPM remains on Prometheus. On 2026-10-03
the operator explicitly approved removal of the old Prometheus Gitea data,
SSH fragment and final-export helper. NPM now uses a rootful Quadlet with no
installed Compose fallback. The source-retention and rollback steps below
are historical migration gates, not current recovery instructions.
Existing backup archives were preserved; use current Atlas data and verified
backups for recovery. Do not recreate or restart stale source Gitea.
## Observed source before cutover and chosen topology (2026-10-01)
- Prometheus runs the rootful `docker.gitea.com/gitea:1.25.2` image in its
managed Compose stack. `/opt/gitea/data` is about 280 MiB, uses SQLite,
and contains 33 repositories. A live read-only SQLite `quick_check` passed.
`/home/git/.ssh` is a separate small bind mount; `/opt/gitea/data/ssh`
contains the existing SSH host keys. Neither tree may be discarded.
- Gitea answers HTTP 200 on Prometheus port 3000. NPM currently forwards
`git.fscotto.duckdns.org` and `git.ov-ad3410.infomaniak.ch` to the Compose
hostname `gitea:3000`. Public DNS resolves to Prometheus. The container's
SSH port is bound only to `127.0.0.1:222`; this is not a public Gitea SSH
listener. Prometheus' public port 22 remains administrative SSH.
- Atlas has a healthy pool and a verified, private Prometheus backup under
`/zpool/backup/hosts/prometheus/latest`. The 2026-10-01 scheduled export
and pull succeeded. The intended target is a separate
`/zpool/services/data/gitea` dataset, not `Archive` or the backup dataset.
- The approved cutover keeps NPM on Prometheus, changes the two HTTP Proxy
Hosts' effective upstream to Atlas over the Prometheus--Aegis gateway, and offers public Gitea
SSH on port 2222 via the same gateway. Prometheus port 22 is unchanged.
HTTPS and SSH must be validated together before declaring cutover.
- The initial staging ran as a **rootless user Quadlet** under a dedicated,
non-login Atlas account, using the pinned `1.25.2-rootless` image. This was an explicit
rootful-to-rootless **data-layout conversion**, not a drop-in image swap:
the target mounts `/var/lib/gitea` and `/etc/gitea`, and uses Gitea's
built-in SSH server instead of the source image's OpenSSH daemon. Keep the
application version unchanged until the conversion has passed an isolated
restore test. The host's rootful Quadlet directory must not be used.
## Phase 1: prepare without traffic changes
Preparation completed on 2026-10-01: Ansible created
`zpool/services/data/gitea`, a dedicated non-login `gitea` account (UID/GID
1101), separate subordinate IDs, parent-dataset traverse ACLs, and an inactive
user Quadlet under `/var/lib/atlas-gitea/.config/containers/systemd/`. The
Quadlet has no `[Install]` section and, until the final cutover, binds only
loopback staging ports 3001/2223 if started manually. A second targeted
Ansible run changed nothing; the generated service was inactive and neither
staging port listened.
The explicit rehearsal is managed by:
```bash
ansible-playbook ansible/site.yml --limit atlas --tags gitea_restore \
-e atlas_gitea_restore_test=true
```
On 2026-10-01 this selected the latest verified Prometheus backup, checked its
SHA-256, extracted only `opt/gitea/data`, moved `app.ini` into the rootless
config mount, rewrote `/data/` paths, enabled built-in SSH on internal port
2222, and retained the three source SSH host-key pairs. SQLite `quick_check`
passed, all 33 restored repositories passed `git fsck`, and each source/target
public host-key fingerprint matched. A temporary `1.25.2-rootless` container
with `--network none` answered HTTP internally and listened on internal
SSH/2222. The container was removed; the user Quadlet remains inactive, with
no staging listener. The second restore run changed nothing. This copy is
deliberately stale once new source writes occur and **must not** be used as the
final cutover copy.
Target backup checks on 2026-10-01: the managed recursive hourly ZFS snapshot
`atlas-auto-hourly-20261001T193401Z` contains the new dataset. The managed
Borg service completed archive `atlas-20261001T193420Z`, whose contents list
includes the staged Gitea database. A separate one-file restore from each
source into private `/var/tmp` directories matched the live staged database
and passed SQLite `quick_check`. Temporary files and the on-demand snapshot
mount were removed; the Borg temporary snapshot was cleaned up and the pool
remained healthy. This is file-level proof, **not** a full Gitea recovery.
The operator's UUID-bound offline USB run published version
`20261001T201220Z-254397` on 2026-10-02. A separate read-only mount and
temporary restore of `services/data/gitea/data/gitea/gitea.db` matched
contents, owner, group, mode, size, mtime and POSIX ACL; SQLite
`quick_check` returned `ok`. The temporary mount and copy were removed,
LUKS was closed, and the pool was healthy. This is a file-level restore test,
not a complete Gitea recovery rehearsal from USB.
1. Provision a dedicated target dataset and non-login service identity via
Ansible, keeping UID/GID distinct from Atlas' reserved Immich `1100`.
Install the user Quadlet in that identity's
`~/.config/containers/systemd/`, **without** an `[Install]` section;
do not enable, start, or expose it yet.
2. Verify the selected Atlas backup SHA-256 and metadata, then extract **only**
`opt/gitea/data` to private staging. Keep `home/git/.ssh` in the source
backup for rollback; the rootless image does not consume its OpenSSH mount.
Never unpack NPM,
WireGuard, or other host configuration from this sensitive tarball into a
live namespace. Convert the rootful `/data` tree on a disposable copy:
place application data under `/var/lib/gitea`, move `app.ini` to
`/etc/gitea`, and rewrite every absolute `/data/...` path for the new
layout. Enable `START_SSH_SERVER`, use internal SSH port 2222, and retain
the source host-key pairs for the built-in server only after verifying
their fingerprints and compatibility. Do not rely on the old
`/home/git/.ssh` OpenSSH mount in the rootless image. Set only the target
copy's ownership and path-scoped SELinux labels.
3. Validate SQLite integrity, repository count and representative `git fsck`,
LFS/attachment presence, permissions, and an isolated rootless test
container with no production ingress or outbound network. Because the
source stays active, this is a rehearsal copy, not the final cutover copy.
Regenerate Git hooks if the changed installation path requires it.
4. ZFS, Borg and UUID-bound offline USB inclusion and one-file restores have
passed. These do not replace the final consistent source copy.
## Phase 2: explicit final cutover
The opt-in `/usr/local/sbin/prometheus-gitea-final-export` helper was installed
on 2026-10-01 and passed `bash -n`. It refuses to
run while the scheduled Prometheus export timer is active. When explicitly
triggered, it stops only the source Gitea container, checks SQLite, publishes
a checksum-verified Gitea-only version for Atlas' existing pull, and leaves
the source stopped on success. NPM remains running. A failure before
completion restarts source Gitea. Its Ansible gate is
`--tags gitea_final_export -e server_gitea_final_export=true`.
After Atlas pulls that version, its separate
`--tags gitea_final_restore -e atlas_gitea_final_restore=true` gate accepts
only metadata marked `gitea-cutover`, validates a private staged replacement,
and swaps it for the marked rehearsal. The swap and its rollback path passed
synthetic tests on 2026-10-01; the live gate succeeded on 2026-10-02.
On 2026-10-02 the operator approved the outage. The final stopped-source
export `20261002T071525Z` passed the Atlas pull checksum; the guarded restore
replaced the rehearsal. SQLite `quick_check`, all 33 repository `git fsck`
checks, and the source/target SSH host-key comparison passed. The rootless
Atlas Quadlet serves LAN HTTP/3000 and SSH/2222, reachable from Prometheus
through Aegis; its firewall admits only Aegis. The final marker gates startup.
Prometheus now runs the NPM-only Compose stack. Both NPM database records still
say `gitea:3000`, but Nginx evaluates this variable upstream through its
runtime DNS resolver, which **does not** use a Compose `extra_hosts` alias.
The initial alias attempt returned 502. A managed `server_proxy.conf` override
sets `$server` to Atlas' IP for only the two declared Gitea domains; it passed
`nginx -t` and primary HTTPS/API returned 200 after a clean NPM restart
without the alias; a representative public `git ls-remote` also succeeded.
Navidrome and Syncthing Proxy Hosts still responded. No NPM SQLite records
or credentials were changed. The
secondary hostname `git.ov-ad3410.infomaniak.ch` did not resolve from Ikaros
and had no generated NPM config file at the time of inspection.
On 2026-10-03 the operator retired this unused secondary hostname. Its NPM
Proxy Host was already soft-deleted; Ansible now declares only
`git.fscotto.duckdns.org` and removes the secondary runtime override.
Prometheus' public TCP/2222 socket proxies to Atlas without changing admin
SSH/22. The local socket presents the preserved Gitea ED25519 host key, but
an external TCP/2222 connection from Ikaros initially timed out. During that
test no SYN reached Prometheus `eth0`; its socket and firewalld port were active.
After the VPS firewall was opened later on 2026-10-02, the public port connected,
its ED25519 host-key fingerprint matched Atlas, Gitea authenticated the `ikaros`
key as `fscotto`, and a public SSH `git ls-remote` for `fscotto/infra.git`
returned HEAD. The operator subsequently reported successful authenticated
SSH pull and push; the agent did not perform a write test. HTTPS write/login
remain untested. Do not
restart the stale source after public HTTPS has accepted target writes.
The Prometheus export timer resumed with NPM-only paths. A recursive ZFS
snapshot at `20261002T073032Z` and encrypted Borg archive
`atlas-20261002T073044Z` captured the Atlas target after cutover; Borg exited
successfully, cleaned its temporary snapshot, and the pool was healthy.
## Corrected Atlas service owner (2026-10-02)
The operator required the host Quadlet to belong to `admin`, while the Unix
user **inside** the container must be named `gitea`. The pinned derived
`Containerfile.gitea-rootless` changes only the base image's UID/GID 1000
passwd/group names from `git` to `gitea`; it retains the rootless image's
paths and entrypoint. Gitea's `RUN_USER` is `gitea`, while its built-in SSH
user and advertised clone user remain `git`, preserving `git@` URLs. The
selective restore helper now generates the same three settings for any future
explicit restore, instead of recreating a `RUN_USER = git` target.
A disposable, loopback-only container using a copy of a Gitea ZFS snapshot
passed HTTP, SQLite, internal-user and SSH host-key checks without touching
live data. After explicit outage approval, the opt-in
`--tags gitea_owner_migration -e atlas_gitea_owner_migration=true` run stopped
the old user service, took safety snapshot
`zpool/services/data/gitea@gitea-owner-migration-20261002T100104`, transferred
only the Gitea dataset to `admin`, tested an `admin` staging Quadlet on
loopback, then promoted it to the production LAN ports. The old Atlas Quadlet
was removed. The old host `gitea` account and its sub-ID range are retained
for a deliberate rollback; they must not restart stale Gitea. The parent
traverse ACL is removed by the normal Gitea role once the new owner is live.
The new service returned HTTP 200 locally and through public primary HTTPS;
Navidrome and Syncthing remained active under `admin`, the pool was healthy,
and a second normal Gitea Ansible run was idempotent. This does **not** close
the separate external TCP/2222 or authenticated clone/push validation gap.
1. Agree on an outage and record source/target versions, pool health, the
latest backups, SSH host-key fingerprints, and both current NPM routes.
Stop the Prometheus export timer for the change window so it cannot
restart the old Compose stack unexpectedly.
2. Quiesce source writes with the final-export helper: it stops Gitea before
the consistent export and leaves it stopped after success. Pull that export
to Atlas and verify checksum and timestamp. Keep
`/opt/gitea/data` and `/home/git/.ssh` intact for rollback. Do not allow
source Gitea to restart after accepting writes on Atlas.
3. Restore the final Gitea-only payload to the target and repeat integrity
checks. Verify its advertised SSH port is 2222, its existing HTTPS
`ROOT_URL`, repositories, LFS/attachments, and SSH host-key identity. Enable
the production Atlas Quadlet only after the final-restore marker exists;
its firewall permits only Aegis to reach HTTP and SSH. Validate local HTTP
and the target service before switching NPM.
4. Enable the public TCP/2222 socket proxy on Prometheus to Atlas over Aegis
without changing administrative TCP/22. Switch Prometheus to the desired
NPM-only Compose stack and use the managed Gitea-only NPM runtime upstream
override. Do not use Compose `extra_hosts`: Nginx bypasses it for the
variable upstream. The old Gitea data stays intact. Do not change public DNS.
5. Test HTTPS login, representative clone/push, LFS, and public SSH clone/push
on port 2222 from outside the Atlas LAN. Record the last source write and
first healthy target service times; do not claim RPO/RTO without measuring.
6. Resume the Prometheus NPM-only backup export timer after the desired stack
is active and verify its next result. Verify the next Atlas snapshot/Borg
run covers Gitea and test a restored target copy. Do not delete old source
data.
## Rollback gate
Before Atlas accepts writes, restore the old Compose definition and remove the
NPM override, disable the public 2222 proxy, and restart the unchanged source
Gitea if target validation fails. **After Atlas accepts writes, do not blindly restart the source:** its
SQLite database and repositories are stale. Quiesce Atlas, capture its new
data, and decide a reverse migration or an extended outage explicitly.
Upstream references: [rootful container layout](https://docs.gitea.com/1.25/installation/install-with-docker/),
[rootless image layout and incompatibility](https://docs.gitea.com/installation/install-with-docker-rootless/),
[rootless Podman Quadlet](https://docs.gitea.com/installation/install-with-podman-quadlet/),
[standard-image conversion](https://docs.gitea.com/1.24/installation/install-with-docker-rootless/),
and [restore and hook regeneration](https://docs.gitea.com/1.26/administration/backup-and-restore/).

View File

@@ -0,0 +1,173 @@
# iCloudPD: Aegis to Atlas
Atlas is the temporary ingestion host until Uranus. Aegis iCloudPD and its
state were retired. Ansible declares Atlas storage, the rootless Quadlet,
and a private `icloudpd.conf` with the Apple ID from the existing Vault key.
The password, keyring and MFA cookies remain application-managed; initialization
is interactive.
Do not place cookies, keyring files, passwords, or the Apple ID in this document,
unencrypted repository content, or a terminal transcript.
## Historical source and current destination (2026-10-02)
- Before retirement, Aegis' rootful `icloudpd.service` was active (no reported restarts, running
since 2026-07-25), but its declared data bind `/var/lib/icloudpd/data`
has **zero top-level entries** and is 4 KiB as observed on 2026-10-02.
Its persistent config has two top-level entries. `pi` cannot run passwordless
sudo, so the container's internal filesystem and root-only state have **not**
been audited. Do not conclude there are no photos to preserve: they could be
inside the container overlay because the declared bind targets the wrong
home. The current
Quadlet mounts that data directory at `/home/root/iCloud`; the image's
documented default is `/home/user/iCloud` with its default `user=user`.
- The non-secret `folder_structure` value in the persisted Aegis config is a
systemd generator path, **not** `{:%Y/%m/%d}`. The Quadlet passes percent
characters in `Environment=` without systemd escaping; that is the likely
cause. A running unit therefore does not prove that Aegis ingests photos.
Do not copy this config or assume that its MFA state is usable on Atlas.
- Atlas' `zpool` is healthy. `/zpool/archive/Pictures` already contains about
25 GiB of unrelated data; iCloudPD gets only a new managed
`/zpool/archive/Pictures/iCloudPD` subtree. Both that subtree and
`zpool/services/data/icloudpd` were created on 2026-10-02. Never rsync with `--delete` into
Pictures or adopt its existing contents. `/zpool/media/photobook` is reserved
for Immich and remains untouched, including its Aegis-only NFS export.
The upstream image documents `/config/icloudpd.conf` as its primary
configuration (environment configuration is deprecated), an exact
`/home/${user}/iCloud/.mounted` failsafe, and an interactive `--Initialise`
step for keyring and MFA cookies. The configuration must use the same download
path, user/UID, and folder format as the bind mounts. References:
[image configuration](https://github.com/boredazfcuk/docker-icloudpd/blob/master/CONFIGURATION.md),
[Podman user namespaces](https://docs.podman.io/en/latest/markdown/podman-pod.unit.5.html).
## Declared Atlas target
| Item | Location or policy |
| --- | --- |
| Downloaded photos | `/zpool/archive/Pictures/iCloudPD`, a new managed subtree of the SMB `Archive` dataset |
| Config, keyring, MFA cookies | `zpool/services/data/icloudpd` at `/zpool/services/data/icloudpd/config`, outside Archive |
| Host service owner | `admin` rootless user manager; no rootful Quadlet or published port |
| Container identity | Entry process root in its user namespace; downloader UID/GID 1000 maps to host `admin` |
| Image | Digest-pinned `docker.io/boredazfcuk/icloudpd`, with no registry auto-update |
| SELinux | Private `:Z` config bind; shared `:z` photo bind because Archive is also exposed through SMB and used by Syncthing. The label and SMB behavior require runtime testing. |
| Access | The new subtree is `admin:admin` mode 0750. No Photobook ownership, ACL, or export changes. |
| Sync policy | Daily interval; explicit directory/file modes 750/640; no iCloud deletion and no deletion of destination-only files |
The photo subtree receives a managed marker and the image's `.mounted` file.
An existing unmarked path is refused rather than taken over. The existing
Pictures tree is not chowned or emptied. The Quadlet now has `[Install]` with
`WantedBy=default.target`, so the lingering admin user manager starts it at boot.
Ansible keeps the service running. Ansible renders a mode-0600
`icloudpd.conf` with `no_log` and no diff, but does not pull the image,
initialize MFA, or run a cutover task. Boot startup was approved on 2026-10-03
after a reboot left the previously manual-started service inactive.
The previous gated check-mode tests and isolated Quadlet-generator test proved
only the proposed layout; they predate the simplified declarative role. They
were not a production deployment or an authentication test.
## Evidence already gathered without production writes
The digest-pinned image was pulled into **admin's** Atlas Podman store. An
isolated `/var/tmp` test ran with no network, a fake Apple ID, private temporary
config/photo mounts, `keep-id:uid=1000,gid=1000`, and no new privileges. Both
container root and UID 1000 wrote to the mounts; UID
1000's files mapped to host `admin`. A short-lived container remained running,
retained the intended `/home/user/iCloud` and literal `{:%Y/%m/%d}` config,
and saw an admin-owned `.mounted` marker. The container and temporary files
were removed. A second isolated test showed that dropping **all** container
capabilities prevents its root entrypoint from reading an admin-owned 0600
config; with the default rootless user-namespace capabilities it could read
and write that file. The Quadlet retains `NoNewPrivileges=true` but does not
drop every capability. This proves only the container layout and namespace mapping,
**not** Apple authentication, a real download, SMB visibility, scheduled
operation, backup coverage, or recovery.
The earlier disposable Photobook ACL test is superseded by the operator's
clarification that Photobook belongs to Immich. It is not evidence for the
current Archive destination, and the proposed Photobook ACL change was never
deployed.
Backup path review on 2026-10-02: the managed Borg and USB scripts snapshot
the pool recursively and bind every mounted child dataset, so both
`archive` and the proposed `services/data/icloudpd` fall within their
declared source scope. Borg's runner switches to the dedicated `borg` account
with only `CAP_DAC_READ_SEARCH`; a read-only check using those exact `setpriv`
capability flags could traverse/read Archive, whereas plain
`sudo -u borg` could not. USB copies as root and preserves POSIX ACLs, but not
generic xattrs/SELinux labels. **This was scope and permission evidence, not a
completed backup or restore of iCloudPD data**, which did not exist at the time.
## Validation status and remaining checks
- Aegis retirement is complete: `icloudpd.service` is `not-found`/`inactive`,
the rootful Quadlet and `/var/lib/icloudpd` are absent, and AdGuard is active.
The temporary retirement tasks are no longer in the Aegis role. The Podman
image cache may remain; it is not service data.
- Atlas storage and the `admin` Quadlet are deployed. The second Ansible
run changed nothing and did not start the service; a later manual start
generated the config. `/zpool/media/photobook` was unchanged.
- The image generated `/zpool/services/data/icloudpd/config/icloudpd.conf`
on first start. Ansible replaced that default file with a private template
using the Apple ID already in Vault. The operator initialized password
and MFA interactively; never put credentials or codes in the repository,
chat, or Ansible extra-vars. Automatic boot startup was separately approved
on 2026-10-03; this does not change the interactive MFA procedure.
- Initial ingestion completed on 2026-10-03. Still check folder structure,
ownership, SELinux and SMB access, no unintended deletions, the next daily
cycle, completed Borg and USB versions, and isolated restore of photos and
private state. A recursive hourly `zpool/archive` snapshot exists after
ingestion, but no iCloudPD-specific backup restore has passed. The first
real scrub and measured recovery targets are separate open items.
On 2026-10-02 Atlas storage and the inactive Quadlet were deployed; a second
Ansible run made zero changes. The generated service was inactive, and no
`icloudpd.conf` existed. Two interactive-sudo Aegis runs removed its service,
Quadlet and `/var/lib/icloudpd`, then cleared the failed-unit record left by a
SIGKILL during shutdown. Read-only verification found `LoadState=not-found`,
`ActiveState=inactive`, both paths absent, and AdGuard active.
On 2026-10-02 the operator requested the first manual start. The rootless
service stayed active, and the image generated `icloudpd.conf` under the
private config dataset. Its mode was tightened from 0644 to 0600. The generated
`apple_id` field is empty; no MFA or download is verified. The service has no
boot-time install target, so it is not configured for automatic startup.
The 2026-10-02 Atlas `icloudpd` run rendered the Vault-backed template without
printing its contents; the second run made zero changes. File owner is
`admin:admin`, mode 0600, and the Apple ID field is nonempty. The rootless
service remained active with zero restarts. At that point keyring initialization,
cookie creation and a real download were unverified. The template now reads
`vault_atlas_icloudpd_apple_id`, which is already present in the encrypted
Vault; no password or MFA code was added to the template.
The attempted interactive initialization then lost its container. Diagnosis
found that the image launcher requires `traceroute` to pass its iCloud
reachability check. Rootless Podman without `NET_RAW` returned `Operation not
permitted` despite working Atlas/container DNS and host HTTPS. An isolated
container with only `CAP_NET_RAW` passed the same check. The Quadlet now grants
that single capability while keeping `NoNewPrivileges=true`; a manual restart
passed `traceroute`, and the app stayed running. Logs then showed only the missing
keyring and a wait for `--Initialise` again. The app expanded the generated config
on startup, so Ansible now seeds it only when absent and idempotently maintains
only its declared options. A second live Ansible run made zero changes. At
that point MFA, actual ingestion, and backup/restore were unverified.
On 2026-10-03, after interactive initialization, the rootless service was
active and the previous 24h of logs showed download activity with no
authentication failures or errors. At 02:16 the application reported `All
photos and videos have been downloaded` and `Download complete for user`.
The destination contained 11,658 files totaling 86,020,430,015 bytes; this
is a filesystem file count, not a count of distinct iCloud assets. A later
read-only check found the service still active. This closes initial
authentication and ingestion only: a subsequent daily cycle and end-to-end
recovery of the new photos and private state remain untested.
On 2026-10-03 Atlas rebooted at 10:17 CEST; iCloudPD stayed inactive because
its Quadlet had no install target. A manual start restored the running service
and the application began listing iCloud files. The operator then approved
persistent boot startup. The managed Quadlet now declares
`WantedBy=default.target`; the live generator created
`default.target.wants/atlas-icloudpd.service`, admin has `Linger=yes`, and the
service remained active with zero restarts. No NAS reboot was performed to
test this change; actual post-reboot startup remains untested.

View File

@@ -0,0 +1,105 @@
# Nextcloud on Atlas — design draft
Status: the empty stack was deployed on 2026-10-03, explicitly before the first
scrub. The operator configured DNS/NPM and authorized public cutover; public TLS,
DAV and cross-user file checks passed. Client editing/sync acceptance and consistent
backup/restore validation remain open before family data. iCloud import remains a
separate operation. See `docs/atlas-nextcloud.md` for observed runtime state.
## Confirmed requirements
- Three family members are the eventual scope; provision only two standard user
accounts initially, `fabio` and `chiara`, with the third family user deferred.
Each initial user has a private file space and no administrator privileges.
- Add a separate Nextcloud application administrator account named `admin`, for
administration rather than daily document use. This is distinct from Atlas'
host account of the same name; credentials must not be reused.
- 2FA is optional, not enforced for the accounts. Offer enrollment and recovery
codes; encourage it for the administrator without silently imposing it.
- The three application accounts have been created in the empty deployment.
- No SMTP service is available. Initial deployment will not configure outbound
email or provision a mail server. Email notifications and email-based password
recovery are unavailable until SMTP is explicitly added. Document administrator-
assisted recovery for standard users and a private host-side admin recovery
procedure; do not expose a recovery endpoint or store plaintext passwords.
- Both initial users may add, edit and delete files in the shared `Famiglia`
folder. This does not imply sharing personal calendars or contacts.
- No initial per-user Nextcloud storage quota for `fabio` or `chiara`. Available
space is still bounded by the physical pool and any separately approved dataset
limits; monitor capacity and do not describe this as unlimited physical storage.
- Files, calendars, contacts and Office document editing in the browser.
- iPhone/iPad, Windows and Linux clients.
- Migrate iCloud Drive files, calendars and contacts. The operator estimates
approximately 50 GB of iCloud Drive files, excluding iCloudPD photos; this is
an estimate, not a measured inventory. The files include a mix of Fabio's and
Chiara's data. Migration is explicitly deferred to a separate later operation;
initial deployment must not import iCloud files, calendars or contacts.
Per-account mapping will be decided at migration time. Do not assume ongoing
two-way synchronization with iCloud or extend this scope to iCloud Photos.
- ONLYOFFICE is the chosen editor: browser editing on desktop and the existing
ONLYOFFICE app on iPhone/iPad. Mobile browser editing is not required.
- Temporary Atlas hosting, with eventual migration to Uranus.
- Completed one-time imports/migrations stay outside the steady-state playbook.
## Implemented architecture — public acceptance pending
- Nextcloud application with Files, Calendar, Contacts and an Office connector.
- PostgreSQL database and Redis for locking/cache; deployed versions and pinned
image digests are declared in Atlas host vars and documented in the runbook.
- Dedicated ONLYOFFICE Docs service and its Nextcloud connector. Test real
DOCX/XLSX/PPTX files in desktop browsers and opening/editing/saving through
the mobile ONLYOFFICE app before acceptance. Community Edition is deployed;
internal connector checks passed, but browser/mobile acceptance is still pending.
- Explicit Podman Quadlets managed by Ansible, preferably rootless like existing
Atlas services, subject to image/user namespace/SELinux validation.
- Separate persistent application/configuration, user files, database and cache
storage in the service namespace. Do not expose the managed Nextcloud data
directory as a writable SMB share or let Syncthing modify it directly.
- Approved names: `cloud.fscotto.co` for Nextcloud and `office.fscotto.co` for
ONLYOFFICE Docs. The operator configured DNS, certificates and NPM hosts;
public endpoint and routing checks passed on 2026-10-03.
- Public HTTPS through Prometheus NPM and the existing Aegis gateway only.
No public database/cache ports or directly exposed administrative interfaces.
- Office/Nextcloud callback routing, WebSockets, trusted proxies, JWT authentication
and upload limits must be tested end to end before publication.
- Credentials remain in Vault; never enter passwords or private keys in chat.
## Office decision
The operator already uses ONLYOFFICE on mobile and desktop and selected it for
this project. Desktop browser editing will use ONLYOFFICE Docs integrated with
Nextcloud; mobile editing will use the existing ONLYOFFICE app. The limitation
on Community mobile web editors does not conflict with that requirement.
App integration, permissions, document fidelity and reliable saves still require
acceptance tests; the app is not treated as proof of server-side compatibility.
## Data protection and rollout gates
- The operator explicitly authorized this empty deployment before the first scrub.
Close the data-protection checks before accepting live family data; this limited
exception does not mark the scrub or recovery checks complete.
- Re-check free RAM/CPU/storage and existing workload before choosing limits or quotas.
- Design consistent backups covering configuration, custom apps/themes, user files
and the database. ZFS snapshots alone do not establish application consistency.
- Define a coordinated maintenance/background-job pause and database dump/snapshot
procedure for recurring backups, with failure cleanup and monitoring.
- Confirm ZFS/Borg/USB coverage and independently restore into an isolated environment
before importing family data.
- Define deliberate upgrades and rollback boundaries; do not roll back a database
independently of its matching application/data backup.
- Start with a test account and representative documents; migrate iCloud content
explicitly only after client, sharing, Office and recovery tests pass.
- Plan Uranus transfer separately; do not add permanent one-time migration flags.
## Next decisions, one at a time
1. Validate desktop Office editing/saving, calendar/contact synchronization and
mobile ONLYOFFICE app integration; public empty-stack cutover is verified.
2. Complete protection gates and application-consistent backup/recovery tests.
3. Plan the deferred iCloud migration when explicitly requested.
## Primary references
- [Nextcloud Office installation](https://docs.nextcloud.com/server/stable/admin_manual/office/installation.html)
- [ONLYOFFICE mobile web editor restrictions](https://helpcenter.onlyoffice.com/mobile/android/mobile-web-editors/overview.aspx)
- [Nextcloud backup requirements](https://docs.nextcloud.com/server/stable/admin_manual/maintenance/backup.html)

127
docs/atlas-nextcloud.md Normal file
View File

@@ -0,0 +1,127 @@
# Atlas Nextcloud — public empty-stack cutover
## Observed state, 2026-10-03
The operator explicitly approved an empty deployment before the first monthly
scrub, and subsequently authorized public cutover. No iCloud files, calendars
or contacts have been imported. Public empty-stack validation is not acceptance
of production data before the outstanding protection and recovery checks.
Ansible manages the steady state through `profile_atlas` and the host-local
`atlas_manage_nextcloud: true` declaration. No migration/import flags or helpers
were added. An actual repeat run returned `changed=0`, with no failures.
- Rootless `admin` Quadlets: Nextcloud 33.0.9, PostgreSQL 17.11, Redis 7.4.11 and
ONLYOFFICE Docs Community 9.4.0.129 (image tag 9.4.0.1), on a dedicated network.
- Images are pinned by digest; Calendar 6.6.2, Contacts 8.9.1, ONLYOFFICE connector
10.2.1 and Team Folders 21.0.9 archives are pinned by version and SHA-256.
- Dedicated ZFS namespace: `zpool/services/data/nextcloud`, with separate `app`,
`files`, `database`, `cache` and `office` datasets. No writable SMB/Syncthing
access to the Nextcloud-managed file namespace is provided.
- The `admin` Nextcloud account is an application administrator, distinct from
the host account. `fabio` and `chiara` are standard users in `famiglia`, each
with no initial quota. Team folder `Famiglia` has unlimited quota and group
permission mask 15 (read/create/update/delete, not additional re-sharing).
- Optional TOTP is available; 2FA is not enforced. SMTP is not configured.
- The five-minute user cron timer is active; a manual service run succeeded.
Its `Type=oneshot` means a recurring short-lived job, not a one-time migration.
- Component memory ceilings are Nextcloud 2 GiB, ONLYOFFICE 4 GiB, PostgreSQL
1 GiB and Redis 256 MiB; these are ceilings, not reserved memory or load-test results.
Nextcloud reported installed, no maintenance mode and no pending DB upgrade.
PostgreSQL was healthy; ONLYOFFICE `/healthcheck` returned `true`. The connector's
`onlyoffice:documentserver --check` succeeded using internal routing. JWT is
enabled and matches the dedicated secret; neither privileged containers nor
container-engine socket mounts are used.
NPM on Prometheus reached both upstreams through the Aegis gateway. Direct LAN
connections from Ikaros to 8080/8081 were blocked, and PostgreSQL/Redis had no
published host ports. Existing Git, Music and Syncthing HTTPS returned 200 with
valid TLS. NPM and its backup export timer stayed active; the pool remained healthy.
After operator DNS/NPM configuration, both public hostnames resolved to the VPS.
HTTPS and HTTP-to-HTTPS redirects passed with valid certificates. Both Proxy Hosts
were enabled with Force SSL and WebSocket support. Public Office health and its
browser API asset returned 200; the connector check also passed. Actual browser
editing/saving and native mobile client use remain operator acceptance tests.
Public session-based web login and authenticated WebDAV succeeded for admin,
fabio and chiara. CalDAV/CardDAV
discovery redirected to the DAV endpoint; Fabio's calendar/address-book collections
answered PROPFIND. A uniquely named private test file was inaccessible to Chiara.
Fabio created a test file in Famiglia; Chiara read, edited and deleted it, and Fabio
read the updated contents. All temporary test files were removed. These are HTTP
protocol checks, not device synchronization or large-upload acceptance evidence.
## Operator DNS and NPM configuration
Namecheap: add CNAMEs `cloud` and `office` to `fscotto.co`. Do not change the blog,
mail records or apex IP.
| NPM hostname | Scheme | Upstream | Port |
| --- | --- | --- | --- |
| cloud.fscotto.co | http | 192.168.178.55 | 8080 |
| office.fscotto.co | http | 192.168.178.55 | 8081 |
For each host, obtain a certificate for its hostname, enable Force SSL and
WebSocket support. Keep NPM administration loopback-only; do not expose port 81.
Nextcloud's declared upload ceiling is 2 GiB; align the proxy request-size and
timeout settings rather than claiming large uploads work before testing them.
Verify CalDAV/CardDAV `.well-known` redirects to `/remote.php/dav/` through NPM.
Never disable certificate verification to make Office work.
The browser-facing Office URL is `https://office.fscotto.co/`; server-side routes
use `http://atlas-onlyoffice/` and `http://atlas-nextcloud/` on the private network.
These internal routes require explicit local-address permission in the connector
and ONLYOFFICE. Metadata-address access remains disabled. Nextcloud trusts only
the declared Aegis address and rootless network gateway, not arbitrary proxies.
## Secrets and administration
Six unique secrets were generated into the existing encrypted `secrets/vault.yml`:
database, Redis, Office JWT and initial passwords for `admin`, `fabio`, `chiara`.
Use the local Vault editor to retrieve them; do not paste them in chat.
Account provisioning never resets an existing user's password. After a user
changes it, the initial Vault password is not necessarily their current password.
Database secret rotation needs a coordinated role-password update, not just an
edited initialization file. Image/app upgrades likewise require a deliberate window.
Host configuration lives below `/home/admin/.config/atlas-nextcloud` with a 0700
parent. Mounted individual secret files are readable by their container consumers,
but a different host user was verified unable to read them through the parent.
Nextcloud's managed PHP include inherits the live container SELinux category;
neither global relabeling nor disabling SELinux is used.
```bash
ansible-playbook ansible/site.yml --limit atlas --tags nextcloud --check --diff
ansible-playbook ansible/site.yml --limit atlas --tags nextcloud
```
Dry-run skips initial downloads, image pulls and runtime account/app commands;
it is not proof of an installed or healthy stack. The deployed repeat run is
the current idempotence evidence.
## Gates before family data and full client acceptance
- Verify the first actual scrub and the outstanding protection checks.
- Public TLS, redirects, web login and WebDAV passed. Complete calendar/contact
synchronization and Office editing/saving from a desktop.
- Test opening, editing and saving from the iPhone/iPad ONLYOFFICE app; mobile
browser editing is not a requirement. No such client test is claimed yet.
- Private-space isolation and cross-user shared writes/deletes passed the public
smoke test above; complete normal client acceptance as well.
- Integrate and test application-consistent database/files backups before import.
The new datasets fall beneath existing recursive snapshot/backup scope, but
that alone does not verify a new Borg/USB version or a consistent Nextcloud restore.
- For a consistent backup, coordinate pending Office saves, pause cron and writes,
take a verified PostgreSQL dump and matching application/files snapshot, and
resume services promptly even on failure. Extend recurring backup procedures,
not the steady-state playbook with one-time migration tasks. Restore into an
isolated environment using matching image/app versions, config, files and DB.
- Confirm encrypted Vault/recovery material is available offline. Without SMTP,
recovery for standard accounts is administrator-assisted; a forgotten admin
password can be reset through the private host-side `occ` CLI.
- Select versions deliberately for upgrades. Do not downgrade the application
against an upgraded database; use matching tested backups for recovery.
- Future Uranus migration and iCloud import are separate, explicitly authorized
operations. No source data deletion or automatic cross-system cutover is provided.

82
docs/domain-fscotto-co.md Normal file
View File

@@ -0,0 +1,82 @@
# fscotto.co domain transition
## Observed state (2026-10-03)
Namecheap remains the DNS provider. The operator moved GitHub Pages to
`blog.fscotto.co` in `fscotto/fscotto.github.io`, aligned Hugo and Pages
settings, and changed the apex A record to `179.237.102.172`. The blog
remains a CNAME to `fscotto.github.io`; mail records were left unchanged.
A new Hugo deployment and cache clearing resolved the initial stale DNS
and generated URLs. Blog HTTPS returned 200 with valid TLS.
The `git`, `music` and `syncthing` subdomains are CNAMEs to `fscotto.co`.
The operator added NPM Proxy Hosts with certificates, WebSocket support
and Force SSL:
| Hostname | HTTP upstream |
| --- | --- |
| git.fscotto.co | 192.168.178.55:3000 |
| music.fscotto.co | 192.168.178.55:4533 |
| syncthing.fscotto.co | 192.168.178.55:8384 |
All three redirected HTTP to HTTPS and returned final HTTPS 200 with valid
TLS. Only the Syncthing GUI uses NPM; native synchronization is unchanged.
NPM administration remains loopback-only on port 81 via SSH tunnel.
## Gitea canonical hostname
Atlas declares `atlas_gitea_public_domain: git.fscotto.co`. Ansible manages
only `[server] DOMAIN`, `ROOT_URL` and `SSH_DOMAIN` in the existing private
app.ini, preserving unrelated settings and mode 0600. Private configuration
backups are created; diffs and secret-bearing results are suppressed.
Only Gitea restarts when these fields change; a repeat run changed nothing.
HTTPS uses `https://git.fscotto.co/`; public SSH remains TCP/2222.
Agent read-only checks returned the same HEAD from `fscotto/infra.git`
over HTTPS and authenticated SSH. SSH host identity was checked against
the already-trusted old endpoint key. No test push or user-authenticated
web login was performed by the agent.
```bash
ansible-playbook ansible/site.yml --limit atlas --tags gitea_public_domain --check --diff
```
Client remotes do not update automatically. Update them deliberately after
checking repository paths; integrations and webhooks are separate operations.
For the verified infrastructure repository only:
```bash
git remote set-url origin ssh://git@git.fscotto.co:2222/fscotto/infra.git
```
Do not copy this path into unrelated clones. Verify Gitea's known SSH key
before accepting the new hostname's identity.
## Local DuckDNS retirement
DuckDNS support has been removed entirely from the server profile. On 2026-10-03 the explicit
Ansible cleanup removed the five-minute rocky cron entry and the private
`~/duckdns` directory containing only `duck.sh` and `duck.log`. The temporary
cleanup tasks and flag were subsequently removed from the playbook at the
operator's request. The updater provisioning tasks, template, variables and
enablement flag were also removed; there is no retained opt-in support.
The external DuckDNS name, Vault token, disabled NPM hosts and certificates
remain untouched for a separate future decision.
Before removing the temporary cleanup tasks, the repeat cleanup changed nothing.
The cron table had no remaining entries, NPM and the export timer were active,
and NPM administration still listened only on `127.0.0.1:81`.
## Operator-confirmed transition completion
On 2026-10-03 the operator confirmed completion of:
- Web login on the new Gitea hostname.
- Updates to remaining Git remotes, webhooks and integrations.
- Removal of obsolete DuckDNS NPM Proxy Hosts, unused certificates and the old upstream override.
- Review and removal of completed one-time procedures from the playbook.
These are operator confirmations, not new agent runtime checks or a test push.
At the earlier inspection the three old DuckDNS Proxy Hosts were disabled,
not deleted; that observation predates the confirmed cleanup. Existing backup
archives remain preserved. DNS/Pages/NPM changes were operator actions;
the Gitea application configuration change was deployed through Ansible.

View File

@@ -2,19 +2,23 @@
The playbook and both hosts have the dedicated identity, restricted SSH The playbook and both hosts have the dedicated identity, restricted SSH
access, helpers, and systemd units. A manual export, pull, and temporary access, helpers, and systemd units. A manual export, pull, and temporary
restore passed on 2026-09-30. Both timers are enabled; their first scheduled restore passed on 2026-09-30. The first scheduled export and pull passed on
runs are pending, so daily operation is not yet verified. 2026-10-01. After NPM moved to its Quadlet, another manual export, pull, and
isolated restore passed on 2026-10-03. The first scheduled cycle after that
cutover is still pending. See `docs/prometheus-npm-quadlet.md`.
## Declared design ## Declared design
- Prometheus prepares a tar archive of Nginx Proxy Manager and Gitea data, - Prometheus prepares a tar archive of Nginx Proxy Manager data and certificates,
their managed Compose configuration, SSH/firewalld/WireGuard configuration, its active Quadlet and network definitions,
and the Gitea SSH path. NPM access logs and regenerable Gitea logs, sessions, and SSH/firewalld/WireGuard configuration. Gitea now runs on Atlas and is no
temporary files, and indexers are excluded. The archive contains credentials, longer included in new Prometheus exports. NPM access logs are excluded.
certificates, and the WireGuard private key: protect both copies accordingly. The archive contains credentials, certificates, and the WireGuard private
- The approved consistency mode stops the managed Compose stack for the local key: protect both copies accordingly.
tar creation at 02:00 Europe/Rome, then restarts it even if archiving fails. - The approved consistency mode stops the NPM Quadlet for local tar creation
A manual test outside that window requires separate approval. at 02:00 Europe/Rome, then restarts it even if archiving fails. After the
approved legacy cleanup, the helper requires the Quadlet active and has
no Compose dependency. A manual test outside that window requires separate approval.
- Prometheus publishes the archive with its checksum as a versioned, read-only - Prometheus publishes the archive with its checksum as a versioned, read-only
source under `/var/lib/prometheus-backup-export`. A locked service account source under `/var/lib/prometheus-backup-export`. A locked service account
has no sudo or supplementary groups. Its only authorized SSH key is forced has no sudo or supplementary groups. Its only authorized SSH key is forced
@@ -51,18 +55,20 @@ runs are pending, so daily operation is not yet verified.
account's key, and verify `sshd -T -C user=prometheus-backup,...` plus account's key, and verify `sshd -T -C user=prometheus-backup,...` plus
read-only SSH denial tests after any SSH configuration change. read-only SSH denial tests after any SSH configuration change.
3. During an agreed window, start the Prometheus export service manually. 3. During an agreed window, start the Prometheus export service manually.
Confirm Compose is healthy afterward, inspect the archive without exposing Confirm the active NPM service is healthy afterward, inspect the archive
file contents, and verify the checksum/metadata. without exposing file contents, and verify the checksum/metadata.
4. Start the Atlas pull service manually. Confirm the SSH host pin, source 4. Start the Atlas pull service manually. Confirm the SSH host pin, source
freshness, checksum, tar listing, published `latest`, retention behavior, freshness, checksum, tar listing, published `latest`, retention behavior,
clean temporary directories, and healthy pool. clean temporary directories, and healthy pool.
5. Independently restore the selected archive to an empty staging directory 5. Independently restore the selected archive to an empty staging directory
(never `/`) and compare the SQLite databases, Git repositories, NPM data, (never `/`) and compare NPM SQLite, data, active Quadlet files, certificates,
Compose file, permissions, and representative files. Test application permissions, and representative files. Historical pre-Gitea-cutover
startup only in an isolated environment or an approved restore window. versions also include Gitea repositories; current versions do not. Test
6. The two timers were enabled after the manual test. Verify their calendars application startup only in an isolated environment or an approved restore
and the next actual run. A successful manual test is not proof of scheduled window.
operation. 6. Both timers are enabled. Verify their calendars and the next actual run
after any service-ownership change. A successful manual test is not proof
of a later scheduled cycle.
Narrow static validation: Narrow static validation:
@@ -100,7 +106,26 @@ repository passed `git fsck`. The temporary restore directory was removed.
This did not test application startup on an isolated host. This did not test application startup on an isolated host.
After these checks, Ansible enabled the Prometheus 02:00 Europe/Rome export After these checks, Ansible enabled the Prometheus 02:00 Europe/Rome export
timer and Atlas 03:00 Europe/Rome pull timer. The next scheduled occurrences timer and Atlas 03:00 Europe/Rome pull timer. Their first scheduled run passed
were displayed for 2026-10-01. Atlas' health monitor now includes the pull on 2026-10-01; Atlas verified and published `20261001T000001Z` as `latest`.
timer. Check both actual service results after the first scheduled run before Atlas' health monitor includes the pull timer.
claiming unattended operation.
On 2026-10-03 the stopped-source version `20261003T091009Z` was verified and
pulled before the NPM cutover. The post-cutover version `20261003T091633Z`
was exported by the Quadlet-aware helper, checksum-verified, pulled to Atlas,
and restored to an isolated temporary directory. NPM SQLite `quick_check`
passed with ten proxy hosts and six certificate records. The archive contains
both Quadlet definitions. A manifest of all 70 regular Let's Encrypt files
and 12 symlinks, including content hashes and link targets, matched the live
Prometheus tree. No private key or secret content was printed. The next
scheduled export/pull is still pending observation.
## Post-cleanup validation (2026-10-03)
The operator-approved removal of legacy data and Compose fallback also
removed those backup input paths and the obsolete Gitea mount dependency.
A separately approved export and Atlas pull published `20261003T112906Z`.
Both SHA-256 checks passed; an isolated SQLite restore passed `quick_check`
and contained ten proxy hosts. Both active Quadlet definitions were present;
retired paths were absent. Existing backup archives were not deleted by cleanup.
The first scheduled cycle after these changes remains unverified.

View File

@@ -0,0 +1,145 @@
# Prometheus NPM Quadlet cutover
## Current state (2026-10-03)
Nginx Proxy Manager runs as the **rootful** generated
`prometheus-npm.service` on Prometheus. The Quadlet files are
`/etc/containers/systemd/prometheus-npm.container` and
`/etc/containers/systemd/server-web.network`; the image is pinned by digest
in `ansible/inventory/host_vars/prometheus.yml`. The generated service is
wanted by `multi-user.target` and requires the generated network service.
The old Compose unit, Compose file and Gitea final-export helper were
removed by the operator-approved cleanup on 2026-10-03. The retired
application data and empty legacy directories were also removed.
Prometheus host vars set `server_legacy_stack_retired: true` so normal runs
do not recreate those files. Destructive deletion still requires a separate
cleanup tag and explicit extra-var.
There was **no data copy** in this cutover. The Quadlet reuses the existing
`/opt/npm/data:/data` and `/opt/npm/letsencrypt:/etc/letsencrypt` bind mounts
with the same container name and `server_web` bridge (`10.89.0.0/24`). Ports
80 and 443 remain public; administration port 81 remains bound to
`127.0.0.1`. Gitea stays on Atlas, and NPM remains on Prometheus. The
Compose fallback is no longer installed. The Quadlet uses `Pull=missing`,
not an automatic floating-tag update.
## Cutover and recovery boundaries
The separate `scripts/cutover_prometheus_npm_quadlet.sh` was run **once** in
the approved outage window, after source backup version
`20261003T091009Z` was checksum-verified and pulled to Atlas. Its preflight
required exactly the Compose owner, an inactive generated Quadlet, the
expected image, and the current backup version. The execution held the
backup-export lock, stopped the export timer, stopped and disabled Compose,
started the Quadlet, checked the exact image ID, SQLite database counts,
certificate content, Nginx configuration, and local Gitea/Syncthing HTTPS,
then restarted the timer. Its failure trap would have restarted Compose.
**Do not rerun that forward-cutover script after success**: its preconditions
intentionally reject an active Quadlet.
Recovery is now a Quadlet rebuild and restoration from a verified Atlas
backup, with an explicit outage decision before replacing live NPM state.
The old Compose owner is no longer installed; reintroducing it would require
a separately reviewed configuration and outage plan. The historical
in-window rollback trap is not a supported post-cleanup rollback procedure.
Do not restore an old database over a live instance or remove NPM bind mounts.
## Verified evidence
- Immediately after cutover, `prometheus-npm.service` was active with zero
recorded restarts; Compose was inactive/disabled. The generated
`multi-user.target.wants` link and network dependency were present. An
actual reboot has not been performed solely for this test.
- The running image ID matched the prior Compose image. Podman showed the
original two bind mounts, `server_web`, public 80/443, and loopback-only 81.
External HTTPS to Gitea and Syncthing returned 200 with TLS verification
result 0. External access to TCP/81 timed out.
- The first **manual post-cutover** export `20261003T091633Z` succeeded with
the Quadlet as its active owner. The Atlas pull published that version;
its SHA-256 payload check passed. An isolated restore passed NPM SQLite
`quick_check` with ten proxy hosts and six certificate records. Both
Quadlet definitions were present in the tar archive.
- A path/content manifest of all 70 regular Let's Encrypt files and the
path/target manifest of all 12 symlinks in the Atlas archive exactly
matched the live Prometheus tree (aggregate SHA-256
`ce0965fbd3ff44bb8502ed9f314e0131edd86d822039de115b39f6a2273c2da8`).
The earlier apparent 70-vs-82 count was only a regular-file-versus-symlink
counting difference, not missing certificate data. No certificate key
contents were exposed during comparison.
- The targeted `--tags npm_quadlet` normal Ansible run completed with
`changed=0`, and the backup export timer remained active/enabled.
The first unattended 02:00 Europe/Rome export and 03:00 Atlas pull **after**
this cutover have not yet occurred. Check their service results and the
published version after the next cycle; the successful manual cycle proves
the new path works but not its next scheduled execution.
```bash
ANSIBLE_LOCAL_TEMP=/tmp/ansible-local \
ansible-playbook ansible/site.yml --limit prometheus --tags npm_quadlet --check --diff
sudo systemctl status prometheus-npm.service prometheus-backup-export.timer
sudo systemctl show podman-compose-server.service -p LoadState # expected: not-found
```
The backup archive includes credentials, certificates, and WireGuard
configuration. Do not publish it or print its contents in diagnostics; see
`docs/prometheus-backup.md` for the restricted pull and restore procedure.
## Selective legacy image cleanup
On 2026-10-03 opt-in Ansible tasks removed only the unused Gitea 1.25.2,
Navidrome latest and PostgreSQL 13 rootful images, without force or global
prune. Podman refuses images referenced by existing containers. The second
run changed nothing. NPM remained active with zero restarts; local admin
and public Gitea HTTPS returned 200. Backup timer and SSH proxy stayed active.
Validation:
```bash
ansible-playbook ansible/site.yml --limit prometheus --tags server_image_cleanup --check --diff -e server_legacy_image_cleanup=true
```
The image cleanup defaults to disabled and carries the `never` tag.
Check mode probes image presence but skips removal; it does not prove
Podman would accept deletion. It never removes NPM resources.
## Approved legacy data and fallback cleanup
The operator explicitly approved deletion on 2026-10-03. The separate
`server_legacy_cleanup` tasks removed `/opt/gitea`, `/home/git/.ssh`,
`/opt/navidrome`, `/opt/postgres`, `/opt/music`, `/opt/containerd`,
`/opt/docker`, the old Compose unit and the final Gitea export helper.
The empty `/home/git` parent is removed only with `rmdir`, after confirming
the Git account is absent. Guards reject symlinked paths, nested mounts,
unexpected containers, container users of these paths, unexpected content
in the empty legacy trees, and an active Compose or export service.
The second cleanup run changed nothing.
Before deletion, Ansible removed obsolete backup input paths and the
Gitea mount dependency. Normal Compose/template/final-export task checks
changed nothing and did not recreate the retired files. Deletion is opt-in:
```bash
ansible-playbook ansible/site.yml --limit prometheus --tags server_legacy_cleanup --check --diff -e server_legacy_cleanup=true
```
Remove check mode only for approved deletion. No active NPM data, certificate,
image, network, volume, SSH proxy, WireGuard configuration or backup archive
is removed. No services were restarted by the cleanup.
After separate approval for the brief managed NPM pause, the new export
`20261003T112906Z` completed successfully and was pulled to Atlas. SHA-256
passed on both hosts; an isolated SQLite restore passed `quick_check` and
contained ten proxy hosts. Both Quadlet definitions were present, and
retired paths were absent. Temporary restore files were removed.
NPM was active with zero automatic restarts; primary public Gitea HTTPS
returned 200 with valid TLS. Backup timer, SSH proxy and WireGuard stayed active.
The first scheduled post-cleanup cycle remains unverified.
After separate operator approval on 2026-10-03, the unused secondary hostname
`git.ov-ad3410.infomaniak.ch` was removed from the declared domains and
the managed NPM runtime override. Its Proxy Host (id 10) was already
soft-deleted, with no generated config or associated certificate. Historical
deleted records and backup archives are preserved; no DNS changes were made.
Only `git.fscotto.duckdns.org` remains declared for the Gitea override.
Nginx validation and reload passed without restarting NPM; the primary
public HTTPS endpoint returned 200 with valid TLS.

View File

@@ -0,0 +1,106 @@
#!/usr/bin/env bash
# Run on Prometheus as root with the exact verified source-export version.
set -Eeuo pipefail
expected_export=${1:?Pass the verified Prometheus backup export version}
mode=${2:---preflight}
[[ $expected_export =~ ^[0-9]{8}T[0-9]{6}Z$ ]] || exit 2
[[ $mode == --preflight || $mode == --execute ]] || exit 2
[[ $EUID -eq 0 ]] || { echo 'Run as root on Prometheus' >&2; exit 2; }
compose_unit=podman-compose-server.service
quadlet_unit=prometheus-npm.service
backup_timer=prometheus-backup-export.timer
versions=/var/lib/prometheus-backup-export/versions
quadlet_file=/etc/containers/systemd/prometheus-npm.container
exec 9>/run/lock/prometheus-backup-export.lock
flock -n 9 || { echo 'Backup/export lock is busy' >&2; exit 1; }
systemctl is-active --quiet "$compose_unit"
if systemctl is-active --quiet "$quadlet_unit"; then
echo 'NPM Quadlet is already active; refusing overlapping cutover' >&2
exit 1
fi
[[ $(systemctl show "$quadlet_unit" -p LoadState --value) == loaded ]]
[[ $(systemctl is-enabled "$compose_unit") == enabled ]]
[[ $(readlink "$versions/current") == "$expected_export" ]]
image=$(sed -n 's/^Image=//p' "$quadlet_file")
[[ $image =~ ^docker\.io/jc21/nginx-proxy-manager@sha256:[a-f0-9]{64}$ ]]
podman image exists "$image"
(cd "$versions/current" && sha256sum -c payload.sha256 && tar -tf payload.tar >/dev/null)
curl -fsS --connect-timeout 2 --max-time 5 -o /dev/null http://127.0.0.1:81/
old_image=$(podman inspect nginx-proxy-manager --format '{{.Image}}')
data_signature() {
python3 - <<'PY'
import hashlib, os, sqlite3
db = sqlite3.connect('file:/opt/npm/data/database.sqlite?mode=ro', uri=True)
assert db.execute('pragma quick_check').fetchone()[0] == 'ok'
counts = [db.execute('select count(*) from ' + table).fetchone()[0]
for table in ('proxy_host', 'certificate', 'user')]
db.close()
digest = hashlib.sha256()
for root, dirs, files in os.walk('/opt/npm/letsencrypt'):
dirs.sort()
for name in sorted(files):
path = os.path.join(root, name)
with open(path, 'rb') as stream:
digest.update(path.encode() + b'\0' + stream.read())
print(*counts, digest.hexdigest())
PY
}
before=$(data_signature)
if [[ $mode == --preflight ]]; then
echo 'NPM Quadlet cutover preflight passed; no service was changed'
exit 0
fi
stopped_old=false
rollback() {
rc=$?
trap - EXIT
if (( rc != 0 )) && "$stopped_old"; then
echo 'NPM Quadlet cutover failed; restoring Compose' >&2
systemctl stop "$quadlet_unit" || true
systemctl enable "$compose_unit" || true
systemctl start "$compose_unit" || true
systemctl start "$backup_timer" || true
curl -fsS --connect-timeout 2 --max-time 10 -o /dev/null http://127.0.0.1:81/ || true
fi
exit "$rc"
}
trap rollback EXIT
stopped_old=true
systemctl stop "$backup_timer"
systemctl stop "$compose_unit"
if podman container exists nginx-proxy-manager; then
echo 'Compose left the NPM container behind; refusing duplicate ownership' >&2
exit 1
fi
systemctl disable "$compose_unit"
systemctl start "$quadlet_unit"
ready=false
for _ in {1..60}; do
if curl -fsS --connect-timeout 2 --max-time 3 -o /dev/null http://127.0.0.1:81/; then
ready=true
break
fi
sleep 2
done
"$ready"
systemctl is-active --quiet "$quadlet_unit"
[[ $(podman inspect nginx-proxy-manager --format '{{.Image}}') == "$old_image" ]]
podman exec nginx-proxy-manager nginx -t
[[ $(data_signature) == "$before" ]]
for hostname in git.fscotto.duckdns.org syncthing.fscotto.duckdns.org; do
status=$(curl -ksS --connect-timeout 3 --max-time 10 \
--resolve "$hostname:443:127.0.0.1" -o /dev/null -w '%{http_code}' \
"https://$hostname/")
[[ $status == 200 ]]
done
systemctl start "$backup_timer"
stopped_old=false
echo 'NPM Quadlet cutover passed local application and data checks'

View File

@@ -1,161 +0,0 @@
#!/usr/bin/env sh
# Copy the persistent NPM and Gitea data from the retired Ubuntu server to the Rocky
# replacement. Run this script on the Ubuntu source as root. It is a dry run
# unless --execute and --quiesce-source are both supplied. Extended attributes
# are deliberately not copied: Rocky must assign its own SELinux labels.
set -eu
SOURCE_COMPOSE_FILE=/opt/docker/server/docker-compose.yml
DESTINATION=
IDENTITY_FILE=
EXECUTE=false
QUIESCE_SOURCE=false
DATA_PATHS='
/opt/npm/data
/opt/npm/letsencrypt
/opt/gitea/data
'
usage() {
cat <<'EOF'
Usage: sudo ./scripts/migrate_prometheus_data.sh --destination USER@HOST [options]
Copies persistent Nginx Proxy Manager and Gitea data to the Rocky server with
rsync. The destination Docker containers must be stopped.
Options:
--destination USER@HOST Rocky SSH destination (required).
--identity PATH SSH private key readable by root on the source host.
--source-compose PATH Source Compose file (default: /opt/docker/server/docker-compose.yml).
--quiesce-source Stop the source Compose stack before copying.
--execute Perform the transfer; otherwise only show changes.
-h, --help Show this help.
The script never deletes source data, destination-only files, containers, or
volumes. It intentionally excludes Syncthing and /home/git/.ssh.
EOF
}
fail() {
printf 'Error: %s\n' "$1" >&2
exit 1
}
require_command() {
command -v "$1" >/dev/null 2>&1 || fail "required command not found: $1"
}
while [ "$#" -gt 0 ]; do
case "$1" in
--destination)
[ "$#" -ge 2 ] || fail '--destination requires USER@HOST'
DESTINATION=$2
shift 2
;;
--identity)
[ "$#" -ge 2 ] || fail '--identity requires a path'
IDENTITY_FILE=$2
shift 2
;;
--source-compose)
[ "$#" -ge 2 ] || fail '--source-compose requires a path'
SOURCE_COMPOSE_FILE=$2
shift 2
;;
--quiesce-source)
QUIESCE_SOURCE=true
shift
;;
--execute)
EXECUTE=true
shift
;;
-h|--help)
usage
exit 0
;;
*)
fail "unknown option: $1"
;;
esac
done
[ "$(id -u)" -eq 0 ] || fail 'run this script with sudo on the Ubuntu source host'
[ -n "$DESTINATION" ] || fail '--destination is required'
if [ -n "$IDENTITY_FILE" ]; then
[ -r "$IDENTITY_FILE" ] || fail "SSH identity is not readable: $IDENTITY_FILE"
case "$IDENTITY_FILE" in
*' '*|*"$(printf '\t')"*) fail 'SSH identity paths must not contain whitespace' ;;
esac
fi
if [ "$EXECUTE" = true ] && [ "$QUIESCE_SOURCE" != true ]; then
fail '--execute requires --quiesce-source to keep application data consistent'
fi
require_command rsync
require_command ssh
SSH_COMMAND='ssh -o BatchMode=yes'
if [ -n "$IDENTITY_FILE" ]; then
SSH_COMMAND="$SSH_COMMAND -i $IDENTITY_FILE"
fi
run_ssh() {
# shellcheck disable=SC2086
$SSH_COMMAND "$DESTINATION" "$@"
}
printf 'Destination: %s\n' "$DESTINATION"
printf 'Mode: %s\n' "$( [ "$EXECUTE" = true ] && printf execute || printf dry-run )"
printf 'Data paths:\n%s\n' "$DATA_PATHS"
run_ssh 'sudo -n true' || fail 'destination sudo must be passwordless for this transfer'
run_ssh 'sudo -n docker info >/dev/null' \
|| fail 'destination Docker daemon is unavailable'
if run_ssh 'sudo -n docker ps -q | grep -q .'; then
fail 'destination Docker containers must be stopped before migration'
fi
for path in $DATA_PATHS; do
[ -d "$path" ] || fail "source directory is missing: $path"
run_ssh "sudo -n test -d $path" || fail "destination directory is missing: $path"
done
if [ "$QUIESCE_SOURCE" = true ]; then
require_command docker
[ -f "$SOURCE_COMPOSE_FILE" ] || fail "source Compose file is missing: $SOURCE_COMPOSE_FILE"
if [ "$EXECUTE" = true ]; then
printf 'Stopping source Compose stack...\n'
docker compose -f "$SOURCE_COMPOSE_FILE" stop
else
printf 'Dry-run: source Compose stack would be stopped.\n'
fi
fi
for path in $DATA_PATHS; do
printf '\nSyncing %s\n' "$path"
if [ "$EXECUTE" = true ]; then
rsync -aHA --numeric-ids --itemize-changes --human-readable --partial \
--rsync-path='sudo -n rsync' -e "$SSH_COMMAND" "$path/" "$DESTINATION:$path/"
else
rsync -aHA --numeric-ids --itemize-changes --human-readable --partial --dry-run \
--rsync-path='sudo -n rsync' -e "$SSH_COMMAND" "$path/" "$DESTINATION:$path/"
fi
done
if [ "$EXECUTE" = true ]; then
printf '\nVerifying source-to-destination parity...\n'
for path in $DATA_PATHS; do
rsync -aHA --numeric-ids --itemize-changes --dry-run \
--rsync-path='sudo -n rsync' -e "$SSH_COMMAND" "$path/" "$DESTINATION:$path/"
done
printf '\nTransfer completed. Keep the source stack stopped until application validation on Rocky succeeds.\n'
else
printf '\nDry-run completed. Re-run with --quiesce-source --execute after reviewing the changes.\n'
fi

View File

@@ -1,83 +1,101 @@
$ANSIBLE_VAULT;1.1;AES256 $ANSIBLE_VAULT;1.1;AES256
61353065386233646137323235306631353635663530363237636231316265643562353465323430 36616436366637373963326235323736623235633666353235383933663230616532613131636466
6165646466623962313835313537633137633766373930380a316335323962616265643136346666 3132633562663861353835633633653764376634636638620a636662316234316164626635646539
63336133336131346336383534356637623831363138323165633262386333363535393365383233 63343233373531373833626437656630363330363932353136653834313830646431343961386237
6234393835653439370a313963313365373633323464343263383661383336363662633133643232 3166323135376665620a383334616634356361326134313930613266333136393238366566343233
34366634383862363635653034313531623330396639616462343630326162316535643465653532 33366335353639316164346239336539636335393130663261333065363733323163613437396332
36326534333637376462353561343964633636366331363833313263353133383636623537303663 35663339623338363737383332396238346430353730356632623964323134663434336363613564
35393032316439336666343161653439643638376134363535656262343963393365623432336433 63306464623331643738666234343162643630353061363231313933633733626165333763653461
35383934313762313037326430316666363731666231336534326661353034333063643364343230 39663066376637623939383964333663306137663433313334313132323465623534666133393533
65333739303566366263333565333465613136646237623937393733623438613832393634663463 36633036376538623764363165663861383135663437343230366165636530663165643538376161
39376131313234333039633735613233373931613232653036663665316636303961653834366339 62653335666463653538356635333339353165336333306462373233316438386539613361383039
36353730316132316233303964303839363161346564396163336137663134353062363733656430 34663336636565343035626238633139356638636535373239386463663738633036383861633062
37643339326661653031376265646132623162373562393437373437313732396537383939333666 35313735333530306666313966333061326338393533333936633634633136353237643464376563
62353036316633306666313461663033303830393765396131643035353730383931646239663935 35626266363237613037663934666538356639366637643037386336316131343965616137336330
32626461316364386135303761383837613063336466363162323332663764616464373565383231 64623432663033653066353661613366313065366264663138643965346363626562366433326461
61346463336566346533326535376439643133613762383633396131323632356533636139336365 65316661346631343330633033326630306536633831366231363066323861366662363861666364
62393838316634623932643034376631333539343965383436613364643962363834346337353334 38623438633235646430613935363932386237303132343236303439633939373862366565313864
32656439366439313734353963343133333533653839613632323338336131373566613835393536 35306332636562636466333739343663343762343163343738646234353638303134643763636639
31663433616334373432376531346435336530303936356461303163646463613661643161313661 36613662633135303733376333613235333637646661326235373732306139363363623632666262
66663866343565616631616338353737356164353562366164383736346131666662623132333466 38366436613733316465623438343334333861313161363131376132613232376663623230623533
39383865653631373232393433663430643961646265386166333137643966303834363262373636 38303061386639616631383636303966666338353865626464363434353661393665613862303130
62396434373363353636376133666133663162653265313139313732353639336232333862643036 62303664653362336433356239626661353864363537346234613331376331313038633138363565
64386231336561396537326139346566306434633934343038663165396665363032383466633662 31616232653265343430646537373835643163396530353832366337663363386635306665643432
62336163633964363435386630343966333162333730336138333239646631633132663931376462 66393838363266383230363633313235376130356436633137636637666562383165643862313931
33663139356261313065376636613930353735396131306538306664646135636336643032623131 63346633343334333662363334373865653232623938363162363362646361383961376532626339
38346264333331353633326535326431626563323036313665643337353563333339646430386564 30643537356436346161353161626232303962396463323037653235343633643261396134373061
31613435383036313430316366323636663735326336393338353835323861333564363832656462 61323463653962363639373531366130326431353635346463396434393336313730373431316334
35336435623261326363633033316130393062616339353263643062633331646137376135656365 37313032666231383536363535326239383363346137363037653930373261326338303936663234
35636139336564346164616235616431326531333433646330386134323932373339646536356464 36326635346465316233363266383337343335653239393830356262346530363734383532303936
66343533326534326165323564663533653666633035343163633832393361336462343937623165 38633065633135666438333832333336636365326430656534313332356662356165616563333035
62383931326630363036396333313931393836366439653433623165666166356338653364336534 35363833363636346430356461306337396561366536326139623131303638333733616663653336
35333936653833386163633738326164386166613561333530633937343230363366333662666539 65623062626366386364343036386633626236363638393565323163623936663930363864656264
39333361633933663735303438663239303536363433313962643137386533633539326365383765 61323566376464356532316366633663623031613439653635323339363730366231326531303163
37636538386339333935386132353265353031643662616330316463623661663738353433313830 62313638393962653064303934663436376335663763333965366230323466646463653665656466
36373963633166333464653338343830373063323536383364393033393235326639613662343737 62643164616331636464613934376335353437653662363433363533613633633536346662656339
38663362636331343061646465313237313431373433353361353265333766633463353632646536 62353565303464373438383234353237636239313062643036383161303735386539613533383334
31323231306138323031396630656538363930373439336234343963616334363632653738316465 63383065613236316633623936383130353466383865376336393733663434663636333463336334
63653938373830336362313238656266613362636634616537653863336132343931616262396130 37356233306333366463303839643363393463636630306632326339646661643162323334633331
66393239303866656232653832343132366537333537343635666563343639323433383163613335 66613731313733646362396534356236363361363330383230303731356261333336653930303161
39613533376634316133633430303535306266656333626264343733666335393661666561396633 35353336326438376563616534616361353233373232303034623465656261326664393962326632
39346265316137326465326635396362333565393133623637633132616232326263663662343137 36373936393261616338396630383034323462646664623566663064316438363065646330353362
33363733306135363361643031306265363733656362386666306334333035393839636533343363 32633566666263333863383264363762323964356430336539623633643537336538353037396566
35396638616636633639343930373136376339346162393061393765363837646365383866636131 63396537626465363531393161653939633461366231326234663161646364616338636236313332
33653465666239393133616232636231333332396138376332393664343364643835306530393238 35646664333763623532306637383961623538643164633939303561316262316463646665353633
34663237303530303837663535646263393931373531393039356336316561653130356262636562 66313164646134646132653338356531303435623130343864326236353939356433396164336236
38336362326639653237626634376334666565653036353236313634376364626338646538386536 36623066396435323532356663326163636637346463626235616132353932326438303233393830
38626636386466373566646166393963643164343536373236396138303532393161363335386638 64326563303365646664376337303539643032363537633139623665346130636631386662373762
32633032393737626363613463323366366637616361313537356136626661626633613739323338 66336565303334303561386134343730306566303036313933613134366238303636316238363165
35383963666431343566356562333234663936376562616638636261303466633539376334303331 66373531633430363730376236353939626137323862623538356233616363376330366433633032
39303834663234663063356233313962326664383839393832303462643636393034383434303465 37363538393331376665623230623233343065653139313431323966326238636663633030393734
64333635376135326333356435373734643430623736373234643335343130383066326436356664 32643635326266646636663539353537623062633130386532373638396366353038663861333033
63346663326364343634303930343338336139313864316165366232643537366635653764353763 63343031383238306139653932366433346564643233323937316134306666623030623137313736
31363863633261643263303433373330366161323166366462336332313135366338393334653764 39623537346564623236353131656465326632303038366261626661333931323265396262636661
66353733653137663835663731373364613030373334663061313433373861613665363236633130 35313739306432323034643832623831373831666231643862613736393135383561386365323835
65613965366636343465336533613438373466383737373366653965633437323562643966396431 35623863396339653038316262303263313262616361666666343331393666663530363764643639
39303033643438633762633263326132663466643438656366363431616237633031333936313831 31353564633835323031303835636261613839353031373334366335323465326536323762626633
30323930383233313032323638356333626230333764363662313662646536643839353032353462 37343633376666323963336436623533346261396438343336663630366434383961393738383263
30326166653937353130623133303533343934633565393831623033303234316330353432313266 65383036333031643336393835303835363733663634653463313639313939636539386634663464
30636536633933376365623665616262663236383731633633346232613366333137396139306363 32643034383835333533343434656234343134313934323462643631653337383536363165613835
35633336643266326335303261666666653536666630613639376336373237646134306462616537 63393437653261653966313237633939626330316631633335386235346465663332336337613865
33343561373162666332613634643837343566646161373065366637653135613632353334636363 30626332353130326430316266363062353636356663663439346662313461393835663864656561
63363232303963646530333366663862323264326536643337323266396566316233613630303637 62633131323937656239383531373863393865386265663038346535616463326630646565663463
66646366376466373931613734363931316230323063373666653062373364396433633762633762 64393861366635656130386434633431393661656438333832633366333730643639333036613935
38613933323733653238383935623230383562646563363833653838636165626365646537383639 31363764396466333964343136363630386530343662656362316137306634383032363962616530
33666535656363393562316336633439636138373365623431393965653765306138646234663938 38393731346236353530626263336366376466343430316235653363396565656435323531393438
65653133663663393731646337386535333261643932336132396237323930306136643534353930 61356464333539636637363632626661663634333331643734316230663736333134383664376231
65636438396432623034626561613137336138623265393064383034623863303166356138393564 37613339613266663831613030633466326439323635626638343430383230333639333561646431
37373164626634653662326234333539663735323464613334616130643937373730363263633366 66313634373365373137613134373635333535333164353134623937633066613330393430633438
31393937326432386165343338313031376565313866363731643534313233303064373935303538 35366261633739623963623331373262313865326264386334633630653263343637343633313366
31343832336230393636653432653162336361383963633766343461653466316337353931333363 33303036623333633365373830333465333931633761636366323939363463303239363461333139
63313137303564336630343937356564643763383764613362366634373362666465626334336539 33396663376335646137393436323463383461336233646236306331636361653964346536666637
64366533376165306532343461613265366266383862323032333465336161663161376630316465 36376133326431333234613435613535316263313364396362386537343565393533356564633338
30306562666163646235656664653635366461366435663961623635383437663564356563346462 64363261323838343531326234343138303133626636653732313234383662326131313431323332
31636234663765623838333237393239373564366262613637363938653463396530613963643837 32636539323033376434323939666437373936383763323762636439323836656432303833363362
38636634376637366332623035313465393762653865623130336263343663303066366135616639 35646235653839303838363936613166643662393131373438633265316136663264316332663638
63333964356466613038303263366462346261353030646532366361393965306435613131316463 32613535643565303566376530373065646333356136613462666465396566313933323261663736
65366266376637323764643239323730366565633335666638666334663635373961303637383861 66396565396261306139396364393962393361326363666439333566376561366466626335363461
35313431646434656562333937663837393038386361616630626532636339306432353434656165 34356232353664346234316330363962656332396236383136353461333234313662633234346565
33663261383166386432383465666136376237346565303164363461666663346130346162316338 34376365356133396239383333643163386133316461633032373035323131663139336339346633
62373061353034316234303835663439396434343738303764376665336239626238386436386234 32373633663231373361393762396632383738616330333038646439336532303461663430613133
61306166383637366266393730323732386163366261393630336431633862353761343763363665 37633833393762303035353566633736353130663136626666613061383732326233303831386466
61323039396234393835303633363339373633653334343766653032313230343464326664356566 38313162623836373533326361313031303636393564656634393263306262376336303663363933
3462623830666664626633373966363866333337383730313066 30643266626632366333323434383063356363646264363133306566316533356438633135336130
62663335386335636364313234633965373961353135373339316337626665323761336133653364
33393733383330656432356231313236646163666565373666633637373765346636336235316534
64613536333433626261646333373539383862366334376137373862323232653362346431386164
36643536613133396162653132616134663538393566323363353038383464663638303865376336
30333833313764643130366533646234343339356562663036373137356565643762306261316632
33323134633562303263383931623565383766653536353565303266353862643234346637653132
63646130646339663035333963323366373331616462613236623133646239363134333165646133
64643433373134656161653130323537613361643731653938623036383331633861666332376361
39373962653630326561323662303664636161386461383833363865663935303132353637386633
37346566626439323863393064643765636337616231363066636539306439356632633032663065
66363839616430666233373033376362623862383066396565633632306534623036626335393039
32343132616465383961373432336233376339393863663136663435303266333038333566313665
61303561376366306331633730616265343662333833633533643465373663663634636632666234
31373332323234376430306538386138316431623133626636633034333735303337663335646461
61653439653663653930666332313334623264323539613037323534666137616165373865306531
36306137663164316534373738383865363333316363323538356139646139363064383666626536
66653363393433316335623063633436353761313065636631623366646633353735326362366162
64613736343363333834

View File

@@ -1,5 +1,4 @@
--- ---
vault_duckdns_token: "CHANGEME"
vault_personal_full_name: "REPLACE_ME" vault_personal_full_name: "REPLACE_ME"
vault_git_email: "REPLACE_ME" vault_git_email: "REPLACE_ME"
vault_git_signing_key: "REPLACE_ME" vault_git_signing_key: "REPLACE_ME"
@@ -8,8 +7,14 @@ vault_git_work_email: "REPLACE_ME"
vault_git_work_gpg: "REPLACE_ME" vault_git_work_gpg: "REPLACE_ME"
vault_ikaros_authorized_ssh_keys: vault_ikaros_authorized_ssh_keys:
- "ssh-ed25519 REPLACE_ME" - "ssh-ed25519 REPLACE_ME"
vault_aegis_icloudpd_apple_id: "REPLACE_ME" vault_atlas_icloudpd_apple_id: "REPLACE_ME"
vault_atlas_admin_password_hash: "REPLACE_WITH_A_SHADOW_COMPATIBLE_HASH" vault_atlas_admin_password_hash: "REPLACE_WITH_A_SHADOW_COMPATIBLE_HASH"
vault_atlas_samba_password: "REPLACE_ME" vault_atlas_samba_password: "REPLACE_ME"
vault_atlas_immich_db_password: "REPLACE_ME" vault_atlas_immich_db_password: "REPLACE_ME"
vault_nextcloud_database_password: "REPLACE_WITH_A_UNIQUE_RANDOM_ALPHANUMERIC_SECRET"
vault_nextcloud_redis_password: "REPLACE_WITH_A_UNIQUE_RANDOM_ALPHANUMERIC_SECRET"
vault_nextcloud_onlyoffice_jwt: "REPLACE_WITH_A_UNIQUE_RANDOM_ALPHANUMERIC_SECRET"
vault_nextcloud_admin_password: "REPLACE_WITH_A_UNIQUE_RANDOM_ALPHANUMERIC_SECRET"
vault_nextcloud_fabio_password: "REPLACE_WITH_A_UNIQUE_RANDOM_ALPHANUMERIC_SECRET"
vault_nextcloud_chiara_password: "REPLACE_WITH_A_UNIQUE_RANDOM_ALPHANUMERIC_SECRET"
vault_atlas_borg_passphrase: "REPLACE_WITH_A_STRONG_UNIQUE_PASSPHRASE" vault_atlas_borg_passphrase: "REPLACE_WITH_A_STRONG_UNIQUE_PASSPHRASE"