Compare commits

..

2 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
38 changed files with 1117 additions and 1174 deletions

View File

@@ -66,17 +66,11 @@ Ansible-driven personal infrastructure repo for Fedora and Void desktops, Fedora
`ansible-playbook ansible/site.yml --limit atlas --tags gitea --check --diff` `ansible-playbook ansible/site.yml --limit atlas --tags gitea --check --diff`
- Atlas canonical Gitea domain (restarts only Gitea on a real configuration change): - 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` `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: - Atlas iCloudPD storage and boot-started Quadlet:
`ansible-playbook ansible/site.yml --limit atlas --tags icloudpd --check --diff` `ansible-playbook ansible/site.yml --limit atlas --tags icloudpd --check --diff`
- Atlas explicit Gitea host-owner migration (live outage; never a normal run): - Ongoing Gitea proxy configuration:
`ansible-playbook ansible/site.yml --limit atlas --tags gitea_owner_migration -e atlas_gitea_owner_migration=true`
- Atlas explicit isolated Gitea restore rehearsal (not part of normal runs):
`ansible-playbook ansible/site.yml --limit atlas --tags gitea_restore -e atlas_gitea_restore_test=true`
- Atlas final Gitea replacement gate (dry-run only until a stopped-source export is pulled):
`ansible-playbook ansible/site.yml --limit atlas --tags gitea_final_restore --check --diff -e atlas_gitea_final_restore=true`
- Prometheus final Gitea export helper (dry-run installs only; outage action remains opt-in):
`ansible-playbook ansible/site.yml --limit prometheus --tags gitea_final_export --check --diff`
- Gitea cutover network configuration before activation:
`ansible-playbook ansible/site.yml --limit prometheus --tags gitea_cutover,prometheus_backup --check --diff -e server_gitea_on_atlas=true` `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` and `ansible-playbook ansible/site.yml --limit atlas --tags gitea --check --diff`
- Atlas daily Navidrome music copy: - Atlas daily Navidrome music copy:
@@ -99,7 +93,6 @@ Ansible-driven personal infrastructure repo for Fedora and Void desktops, Fedora
`ansible-playbook ansible/site.yml --limit prometheus,aegis --tags wireguard --check --diff` `ansible-playbook ansible/site.yml --limit prometheus,aegis --tags wireguard --check --diff`
- Prometheus NPM Quadlet steady state (does not perform a cutover): - Prometheus NPM Quadlet steady state (does not perform a cutover):
`ansible-playbook ansible/site.yml --limit prometheus --tags npm_quadlet --check --diff` `ansible-playbook ansible/site.yml --limit prometheus --tags npm_quadlet --check --diff`
- DuckDNS config only (skipped on Prometheus): `ansible-playbook ansible/site.yml --limit prometheus --tags duckdns --check --diff`
## Conventions ## Conventions
- Use FQCN Ansible modules. - Use FQCN Ansible modules.
@@ -143,13 +136,10 @@ 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
- Prometheus disables DuckDNS provisioning with `server_duckdns_enabled: false`. Its updater, - DuckDNS support is removed from the server profile, not feature-gated. No updater tasks,
log and five-minute cron entry were explicitly retired; the external DuckDNS name and Vault templates or enablement variables remain. Prometheus uses its static IP and `fscotto.co`;
token remain untouched. The completed one-time cleanup has no remaining playbook tasks. the local updater, log and cron job were already retired. External DuckDNS account/name
- When enabled, DuckDNS is rendered by `profile_server` from host-local `server_duckdns_domain` and and existing encrypted token are outside this removal and remain untouched.
`vault_duckdns_token`. Keep the rotated token in encrypted Vault or untracked local vars, never in
dotfiles. The private `~/duckdns/duck.sh` keeps the existing entrypoint; rendering uses `no_log`
and disables diffs. Provisioning does not execute the updater or change its external schedule.
- `rocky_server` is a child of both `platform_rocky` and `server`; `prometheus` is its active target. - `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. Prometheus explicitly retires the legacy - The Rocky profile installs Podman and podman-compose. Prometheus explicitly retires the legacy
@@ -164,8 +154,11 @@ The dotfile vars follow the same split: `desktop_common_dotfiles` carries mode-i
- 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.
@@ -372,10 +365,21 @@ successfully. The first monthly scrub remains a runtime check.
- [x] Validate Gitea login and write via HTTPS. On 2026-10-03 the operator confirmed - [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 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. restart the stale source Gitea after Atlas has accepted writes.
- [ ] Design and deploy Nextcloud as another explicitly temporary Atlas service before Uranus. Give it - [x] Design and deploy the empty temporary Atlas Nextcloud/ONLYOFFICE stack on 2026-10-03.
separate persistent application, database, and cache storage; keep credentials in Vault; publish it only The operator explicitly authorized empty internal service startup before the first scrub;
through NPM over the Prometheus--Aegis gateway; and define backup, upgrade, and eventual Uranus-migration this does not close the scrub or protection checks. Four rootless Quadlets, separate
procedures before exposing user data. Do not deploy Nextcloud before the data-protection checklist is complete. 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 - [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. 2026-10-03 through Ansible. Only Gitea restarted; second run changed nothing.
HTTPS and authenticated SSH reads returned the same repository HEAD. HTTPS and authenticated SSH reads returned the same repository HEAD.
@@ -389,7 +393,7 @@ successfully. The first monthly scrub remains a runtime check.
The operator confirmed completion on 2026-10-03. The operator confirmed completion on 2026-10-03.
- [x] Retire Prometheus' local DuckDNS updater on 2026-10-03 through Ansible: - [x] Retire Prometheus' local DuckDNS updater on 2026-10-03 through Ansible:
the five-minute cron entry and private updater/log directory were removed. the five-minute cron entry and private updater/log directory were removed.
Provisioning is disabled; repeat cleanup changed nothing. HTTPS services, private NPM 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 administration and the export timer stayed healthy. The external name and Vault token
remain untouched for possible future use on a local host. 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`,

View File

@@ -229,36 +229,12 @@ Prometheus li raggiunge attraverso Aegis come gateway WireGuard. Solo la GUI web
NPM; il traffico di sincronizzazione resta sulle porte native esposte sulla LAN dichiarata. NPM; il traffico di sincronizzazione resta sulle porte native esposte sulla LAN dichiarata.
Mantenere l'autenticazione Syncthing e una policy di accesso NPM adeguata. Mantenere l'autenticazione Syncthing e una policy di accesso NPM adeguata.
### DuckDNS ### Rimozione DuckDNS
`server_duckdns_enabled: false` disabilita il provisioning su Prometheus, che usa IP statico Il supporto DuckDNS è stato rimosso dal profilo server: non restano task, template,
e `fscotto.co`. Updater, log e cron ogni cinque minuti sono stati rimossi una sola volta; variabili o flag di abilitazione. Prometheus usa IP statico e `fscotto.co`.
non restano task o flag di pulizia. Il nome DuckDNS esterno e il token Vault restano invariati. Updater locale, log e cron erano già stati rimossi. Nome/account DuckDNS esterni
ed eventuale token cifrato esistente restano invariati per un possibile uso futuro.
Sui server con `server_duckdns_enabled: true`, `profile_server` genera `~/duckdns/duck.sh` con permessi `0700`, mantenendo il percorso dello
script e `duck.log`. Definire `server_duckdns_domain` negli host vars del server e salvare il
**nuovo token rigenerato** in `vault_duckdns_token`, nel Vault cifrato `secrets/vault.yml`
(`ansible-vault edit secrets/vault.yml`) oppure negli override non versionati `secrets/vault.local.yml`.
Non committare lo script generato e non passare il token sulla riga di comando. Il rendering
nasconde output e diff sensibili; lo script verifica TLS e passa il token a curl tramite stdin.
Il playbook non esegue lo script e non modifica la sua schedulazione esterna.
```bash
ansible-playbook ansible/site.yml --limit prometheus --tags duckdns --check --diff
ansible-playbook ansible/site.yml --limit prometheus --tags duckdns
```
La cancellazione dalla cronologia non revoca il token: rigenerarlo sul pannello DuckDNS.
Dopo la bonifica, riclonare gli altri checkout senza unire nuovamente la vecchia storia;
salvare separatamente eventuali modifiche non committate senza copiare segreti.
### Migrazione dati
Dopo il provisioning Rocky, eseguire `scripts/migrate_prometheus_data.sh` **sul server Ubuntu
sorgente**. Lo script usa rsync, e in dry-run di default; richiede `--quiesce-source --execute` per
fermare lo stack sorgente e copiare in modo consistente soltanto i dati di Nginx Proxy Manager e
Gitea. Non sposta Navidrome o Syncthing, non avvia container, non cancella dati e non esegue il
cutover.
Utente del profilo server: Utente del profilo server:

View File

@@ -169,49 +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
`server_duckdns_enabled: false` disables provisioning on Prometheus, which uses its static IP DuckDNS support has been removed from the server profile: no tasks, templates,
and `fscotto.co`. The local updater, log and five-minute cron job were removed once; variables or enablement flags remain. Prometheus uses its static IP and `fscotto.co`.
no cleanup tasks or flags remain. The external DuckDNS name and Vault token remain untouched. The local updater, log and cron job were already removed. The external DuckDNS
name/account and existing encrypted token remain untouched for possible future use.
For servers with `server_duckdns_enabled: true`, `profile_server` renders `~/duckdns/duck.sh` with mode `0700`, keeping the existing updater path
and `duck.log`. Set `server_duckdns_domain` in the server's host vars and store the **rotated**
`vault_duckdns_token` in encrypted `secrets/vault.yml` (using `ansible-vault edit secrets/vault.yml`)
or untracked `secrets/vault.local.yml`. Never commit the rendered script or put the token on a
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

View File

@@ -10,7 +10,6 @@ server_npm_quadlet_stage: false
server_npm_quadlet_cutover: false server_npm_quadlet_cutover: false
server_legacy_stack_retired: false server_legacy_stack_retired: false
server_legacy_cleanup: false server_legacy_cleanup: false
server_duckdns_enabled: true
ai_agents: {} ai_agents: {}
vim_plugins_enabled: false vim_plugins_enabled: false
@@ -92,9 +91,8 @@ 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
# Explicit Gitea cutover helper: installed separately from any outage action. # Ongoing public Gitea proxy configuration.
server_gitea_cutover_tools_enabled: false server_gitea_proxy_enabled: false
server_gitea_final_export: false
server_gitea_on_atlas: false server_gitea_on_atlas: false
server_gitea_atlas_address: "{{ hostvars['atlas'].ansible_host }}" server_gitea_atlas_address: "{{ hostvars['atlas'].ansible_host }}"
server_gitea_npm_domains: [] server_gitea_npm_domains: []

View File

@@ -49,6 +49,38 @@ 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. # Rootless Gitea was restored from the stopped-source export before production activation.
atlas_manage_gitea: true atlas_manage_gitea: true
atlas_gitea_production_enabled: true atlas_gitea_production_enabled: true

View File

@@ -22,12 +22,10 @@ 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
# Install the final-copy helper only; it is never run by a normal playbook invocation. # Install the final-copy helper only; it is never run by a normal playbook invocation.
server_gitea_cutover_tools_enabled: true server_gitea_proxy_enabled: true
server_gitea_on_atlas: true server_gitea_on_atlas: true
server_gitea_npm_domains: server_gitea_npm_domains:
- git.fscotto.duckdns.org - git.fscotto.duckdns.org
server_duckdns_domain: fscotto
server_duckdns_enabled: false
server_ssh_authorized_keys: server_ssh_authorized_keys:
- name: ikaros - name: ikaros
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINrIxXjA3ffPwziKGR5gzc4gAoBehQPlnEMcXF4Wl0ZS ikaros" key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINrIxXjA3ffPwziKGR5gzc4gAoBehQPlnEMcXF4Wl0ZS ikaros"

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
@@ -175,9 +199,6 @@ atlas_gitea_home: "{{ atlas_admin_home }}"
atlas_gitea_container_uid: 1000 atlas_gitea_container_uid: 1000
atlas_gitea_container_gid: 1000 atlas_gitea_container_gid: 1000
atlas_gitea_legacy_username: gitea atlas_gitea_legacy_username: gitea
atlas_gitea_legacy_uid: 1101
atlas_gitea_legacy_home: /var/lib/atlas-gitea
atlas_gitea_owner_migration: false
atlas_gitea_dataset: "{{ atlas_zfs_pool }}/services/data/gitea" atlas_gitea_dataset: "{{ atlas_zfs_pool }}/services/data/gitea"
atlas_gitea_mountpoint: "{{ atlas_app_data_mountpoint }}/gitea" atlas_gitea_mountpoint: "{{ atlas_app_data_mountpoint }}/gitea"
atlas_gitea_quadlet_dir: "{{ atlas_gitea_home }}/.config/containers/systemd" atlas_gitea_quadlet_dir: "{{ atlas_gitea_home }}/.config/containers/systemd"
@@ -191,9 +212,6 @@ atlas_gitea_ssh_port: 2222
atlas_gitea_staging_bind_address: 127.0.0.1 atlas_gitea_staging_bind_address: 127.0.0.1
atlas_gitea_staging_http_port: 3001 atlas_gitea_staging_http_port: 3001
atlas_gitea_staging_ssh_port: 2223 atlas_gitea_staging_ssh_port: 2223
atlas_gitea_restore_test: false
atlas_gitea_final_restore: false
atlas_gitea_restore_helper: /usr/local/libexec/atlas-gitea-restore-test
# Declare storage and an inactive Quadlet only. The operator supplies the # Declare storage and an inactive Quadlet only. The operator supplies the
# private configuration, handles MFA, and starts the user service manually. # private configuration, handles MFA, and starts the user service manually.

View File

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

View File

@@ -47,8 +47,8 @@
- atlas_gitea_dataset_owner.stat.uid | int == atlas_admin_uid | int - atlas_gitea_dataset_owner.stat.uid | int == atlas_admin_uid | int
- atlas_gitea_dataset_owner.stat.gid | int == atlas_admin_gid | int - atlas_gitea_dataset_owner.stat.gid | int == atlas_admin_gid | int
fail_msg: >- fail_msg: >-
Run the explicit Gitea owner migration before enabling the admin The production dataset must already belong to admin before enabling
Quadlet; never chown an active legacy service in a normal run. the Quadlet; normal provisioning must not chown an active legacy service.
when: atlas_gitea_production_enabled | bool when: atlas_gitea_production_enabled | bool
- name: Remove the retired account's parent-dataset traverse ACL - name: Remove the retired account's parent-dataset traverse ACL

View File

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

View File

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

View File

@@ -14,21 +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 explicit Atlas Gitea owner migration
ansible.builtin.import_tasks: gitea_owner_migration.yml
- name: Import staged Atlas rootless Gitea tasks - name: Import staged Atlas rootless Gitea tasks
ansible.builtin.import_tasks: gitea.yml ansible.builtin.import_tasks: gitea.yml
- name: Import the declared Atlas Gitea public domain - name: Import the declared Atlas Gitea public domain
ansible.builtin.import_tasks: gitea_public_domain.yml 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 - name: Import Atlas iCloudPD storage and boot-started Quadlet tasks
ansible.builtin.import_tasks: icloudpd.yml ansible.builtin.import_tasks: icloudpd.yml
- name: Import explicit Atlas Gitea restore rehearsal tasks
ansible.builtin.import_tasks: gitea_restore.yml
- name: Import Atlas ZFS maintenance tasks - 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,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

@@ -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

@@ -1,38 +0,0 @@
---
- name: Install the explicit Gitea final-export helper
tags: [services, gitea_final_export]
ansible.builtin.template:
src: prometheus-gitea-final-export.sh.j2
dest: /usr/local/sbin/prometheus-gitea-final-export
owner: root
group: root
mode: "0750"
when:
- server_gitea_cutover_tools_enabled | bool
- not server_legacy_stack_retired | bool
- name: Require the prepared source and explicit final-export approval
tags: [services, gitea_final_export]
ansible.builtin.assert:
that:
- server_gitea_cutover_tools_enabled | bool
- server_backup_export_enabled | bool
- not server_legacy_stack_retired | bool
- not ansible_check_mode
fail_msg: >-
Install the cutover helper and perform an explicit non-check-mode run
only after the Gitea outage gate has been approved.
when: server_gitea_final_export | bool
- name: Stop source Gitea and publish the final consistent export
tags: [services, gitea_final_export]
ansible.builtin.command:
argv:
- /usr/local/sbin/prometheus-gitea-final-export
register: server_gitea_final_export_result
changed_when: server_gitea_final_export_result.rc == 0
no_log: true
when:
- server_gitea_final_export | bool
- not server_legacy_stack_retired | bool
- not ansible_check_mode

View File

@@ -3,7 +3,7 @@
tags: [services, gitea_cutover] tags: [services, gitea_cutover]
ansible.builtin.assert: ansible.builtin.assert:
that: that:
- server_gitea_cutover_tools_enabled | bool - server_gitea_proxy_enabled | bool
- server_gitea_npm_domains | length > 0 - server_gitea_npm_domains | length > 0
- server_gitea_npm_domains | select('match', '^[a-zA-Z0-9.-]+$') | list | length == server_gitea_npm_domains | length - 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. fail_msg: Declare the exact NPM Gitea hostnames before enabling the Atlas upstream.
@@ -17,7 +17,7 @@
owner: root owner: root
group: root group: root
mode: "0755" mode: "0755"
when: server_gitea_cutover_tools_enabled | bool when: server_gitea_proxy_enabled | bool
- name: Render the Gitea-only NPM runtime upstream override - name: Render the Gitea-only NPM runtime upstream override
tags: [services, gitea_cutover] tags: [services, gitea_cutover]
@@ -36,7 +36,7 @@
path: /opt/npm/data/nginx/custom/server_proxy.conf path: /opt/npm/data/nginx/custom/server_proxy.conf
state: absent state: absent
when: when:
- server_gitea_cutover_tools_enabled | bool - server_gitea_proxy_enabled | bool
- not server_gitea_on_atlas | bool - not server_gitea_on_atlas | bool
- name: Validate NPM configuration after a Gitea upstream change - name: Validate NPM configuration after a Gitea upstream change

View File

@@ -3,7 +3,7 @@
tags: [services, gitea_cutover] tags: [services, gitea_cutover]
ansible.builtin.assert: ansible.builtin.assert:
that: that:
- server_gitea_cutover_tools_enabled | bool - server_gitea_proxy_enabled | bool
- server_gitea_atlas_address is match('^[0-9]{1,3}(\.[0-9]{1,3}){3}$') - 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 > 1024
- server_gitea_ssh_public_port | int < 65536 - server_gitea_ssh_public_port | int < 65536
@@ -27,14 +27,14 @@
loop_control: loop_control:
label: "{{ item }}" label: "{{ item }}"
register: server_gitea_ssh_proxy_units register: server_gitea_ssh_proxy_units
when: server_gitea_cutover_tools_enabled | bool when: server_gitea_proxy_enabled | bool
- name: Reload systemd after Gitea SSH proxy unit changes - name: Reload systemd after Gitea SSH proxy unit changes
tags: [services, gitea_cutover] tags: [services, gitea_cutover]
ansible.builtin.systemd: ansible.builtin.systemd:
daemon_reload: true daemon_reload: true
when: when:
- server_gitea_cutover_tools_enabled | bool - server_gitea_proxy_enabled | bool
- server_gitea_ssh_proxy_units is changed - server_gitea_ssh_proxy_units is changed
- not ansible_check_mode - not ansible_check_mode
@@ -45,7 +45,7 @@
state: "{{ 'started' if server_gitea_on_atlas | bool else 'stopped' }}" state: "{{ 'started' if server_gitea_on_atlas | bool else 'stopped' }}"
enabled: "{{ server_gitea_on_atlas | bool }}" enabled: "{{ server_gitea_on_atlas | bool }}"
when: when:
- server_gitea_cutover_tools_enabled | bool - server_gitea_proxy_enabled | bool
- not ansible_check_mode - not ansible_check_mode
- name: Open only the public Gitea SSH port after cutover - name: Open only the public Gitea SSH port after cutover
@@ -57,5 +57,5 @@
permanent: true permanent: true
immediate: true immediate: true
when: when:
- server_gitea_cutover_tools_enabled | bool - server_gitea_proxy_enabled | bool
- server_firewall_backend == 'firewalld' - server_firewall_backend == 'firewalld'

View File

@@ -8,11 +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
when: server_duckdns_enabled | bool
- name: Ensure server directories exist - name: Ensure server directories exist
tags: [dotfiles, services] tags: [dotfiles, services]
ansible.builtin.file: ansible.builtin.file:
@@ -79,9 +74,6 @@
tags: [never, server_legacy_cleanup] tags: [never, server_legacy_cleanup]
when: server_legacy_cleanup | bool when: server_legacy_cleanup | bool
- name: Import explicit Prometheus Gitea final-export tasks
ansible.builtin.import_tasks: gitea_final_export.yml
- name: Import Prometheus Gitea SSH proxy tasks - name: Import Prometheus Gitea SSH proxy tasks
ansible.builtin.import_tasks: gitea_ssh_proxy.yml ansible.builtin.import_tasks: gitea_ssh_proxy.yml

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,80 +0,0 @@
#!/usr/bin/env bash
set -Eeuo pipefail
umask 077
export_root={{ server_backup_export_root | quote }}
versions="$export_root/versions"
stamp=$(date -u +%Y%m%dT%H%M%SZ)
stage=''
gitea_stopped=false
exec 9>/run/lock/prometheus-backup-export.lock
flock -n 9 || { echo 'A Prometheus backup export is already running' >&2; exit 1; }
cleanup() {
local rc=$?
trap - EXIT
if (( rc != 0 )) && "$gitea_stopped"; then
podman start gitea >/dev/null || rc=1
fi
if (( rc != 0 )) && [[ -n "$stage" && -d "$stage" ]]; then
rm -rf -- "$stage"
fi
exit "$rc"
}
trap cleanup EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
systemctl is-active --quiet podman-compose-server.service || {
echo 'Prometheus Compose stack is not active' >&2; exit 1;
}
if systemctl is-active --quiet prometheus-backup-export.timer; then
echo 'Stop the scheduled export timer for the cutover first' >&2
exit 1
fi
[[ $(podman inspect --format '{{ '{{.State.Running}}' }}' gitea) == true ]] || {
echo 'Source Gitea must be running before the final export' >&2; exit 1;
}
[[ -d /opt/gitea/data && -d /home/git/.ssh ]] || {
echo 'Required source Gitea paths are missing' >&2; exit 1;
}
[[ ! -e "$versions/$stamp" ]] || {
echo 'Final export timestamp already exists' >&2; exit 1;
}
gitea_stopped=true
podman stop --time 30 gitea >/dev/null
[[ $(podman inspect --format '{{ '{{.State.Running}}' }}' gitea) == false ]] || {
echo 'Source Gitea did not stop' >&2; exit 1;
}
python3 - <<'PY'
import sqlite3
path = '/opt/gitea/data/gitea/gitea.db'
with sqlite3.connect(f'file:{path}?mode=ro', uri=True) as database:
if database.execute('PRAGMA quick_check').fetchone()[0] != 'ok':
raise SystemExit('Source Gitea SQLite quick_check failed')
PY
stage=$(mktemp -d "$export_root/.staging.XXXXXXXX")
tar --acls --xattrs --selinux -C / -cf "$stage/payload.tar" \
opt/gitea/data home/git/.ssh
tar -tf "$stage/payload.tar" >/dev/null
(cd "$stage" && sha256sum payload.tar >payload.sha256)
printf '{"schema":1,"host":"prometheus","purpose":"gitea-cutover","created_utc":"%s"}\n' \
"$stamp" >"$stage/metadata.json"
[[ $(podman inspect --format '{{ '{{.State.Running}}' }}' gitea) == false ]] || {
echo 'Source Gitea restarted during final export' >&2; exit 1;
}
chown root:{{ server_backup_username }} "$stage" "$stage/payload.tar" \
"$stage/payload.sha256" "$stage/metadata.json"
chmod 0750 "$stage"
chmod 0640 "$stage/payload.tar" "$stage/payload.sha256" "$stage/metadata.json"
mv -- "$stage" "$versions/$stamp"
stage=''
ln -s "$stamp" "$versions/.current.new"
mv -Tf -- "$versions/.current.new" "$versions/current"
echo "Prepared final Gitea export $stamp; source Gitea remains stopped"

View File

@@ -1,5 +1,10 @@
# Gitea migration from Prometheus to Atlas # 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 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 in `docs/domain-fscotto-co.md`. Public SSH remains on TCP/2222; the old
DuckDNS Proxy Host was observed disabled. Earlier domain references below DuckDNS Proxy Host was observed disabled. Earlier domain references below

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.

View File

@@ -54,15 +54,15 @@ before accepting the new hostname's identity.
## Local DuckDNS retirement ## Local DuckDNS retirement
Prometheus declares `server_duckdns_enabled: false`. On 2026-10-03 the explicit 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 Ansible cleanup removed the five-minute rocky cron entry and the private
`~/duckdns` directory containing only `duck.sh` and `duck.log`. The temporary `~/duckdns` directory containing only `duck.sh` and `duck.log`. The temporary
cleanup tasks and flag were subsequently removed from the playbook at the cleanup tasks and flag were subsequently removed from the playbook at the
operator's request. Only the disabled provisioning state remains; ordinary operator's request. The updater provisioning tasks, template, variables and
provisioning cannot recreate the updater. enablement flag were also removed; there is no retained opt-in support.
The external DuckDNS name, Vault token, disabled NPM hosts and certificates The external DuckDNS name, Vault token, disabled NPM hosts and certificates
remain untouched for a separate future decision. remain untouched for a separate future decision.
The repeat cleanup changed nothing; ordinary DuckDNS provisioning was skipped. 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, 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`. and NPM administration still listened only on `127.0.0.1:81`.

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,71 +1,101 @@
$ANSIBLE_VAULT;1.1;AES256 $ANSIBLE_VAULT;1.1;AES256
37646664613266633436346262613633613830623366383138613432366365373765353230333134 36616436366637373963326235323736623235633666353235383933663230616532613131636466
3332333764313337396637323133623937343738373133370a333930356365653034323235643230 3132633562663861353835633633653764376634636638620a636662316234316164626635646539
36633864343161653833356636373931383761663864663334336236373733326266386639366335 63343233373531373833626437656630363330363932353136653834313830646431343961386237
3131313661313637320a313937633361646333333962303335333233346166343831373039663964 3166323135376665620a383334616634356361326134313930613266333136393238366566343233
33366532386135663463643965363766643063616436316463666232666138323236346231303537 33366335353639316164346239336539636335393130663261333065363733323163613437396332
34303535333866376430363063623934623761373865656231656661383935393866353566346430 35663339623338363737383332396238346430353730356632623964323134663434336363613564
64333434613432376436343438343561383235366631623730653533633535326237666265653439 63306464623331643738666234343162643630353061363231313933633733626165333763653461
34366264653665643063663361313339663034323932326233366636326336323432303434373765 39663066376637623939383964333663306137663433313334313132323465623534666133393533
36316532316265343434383438623239666232373633626330333464303361643630303635643834 36633036376538623764363165663861383135663437343230366165636530663165643538376161
39313136623830303762313462343637633763626333393033346637663931663238653734626131 62653335666463653538356635333339353165336333306462373233316438386539613361383039
38393963646563333732353531653239643330326539643538323164343934356166343034316565 34663336636565343035626238633139356638636535373239386463663738633036383861633062
33346431333735636537613930383331393265313962626234363237373562313231393061326439 35313735333530306666313966333061326338393533333936633634633136353237643464376563
64363765323935316661353531366165343139633963336139313737306332613364643031666161 35626266363237613037663934666538356639366637643037386336316131343965616137336330
30386362643930316265616564306336633133303166363665333462316265313364393939306162 64623432663033653066353661613366313065366264663138643965346363626562366433326461
31303639313933356337386134623934663461643161306666633261653538633232343036653833 65316661346631343330633033326630306536633831366231363066323861366662363861666364
66316466636233343136393765636333353230353738313833333265663238303730313936326664 38623438633235646430613935363932386237303132343236303439633939373862366565313864
38373239353162363438323964333030666563346161643437326335666162356264396135393532 35306332636562636466333739343663343762343163343738646234353638303134643763636639
63363862373136346532653734336335616132386237303031363433663132343861633937386130 36613662633135303733376333613235333637646661326235373732306139363363623632666262
30633938616364303462303030303966303939633066393264303462393730363233373937356439 38366436613733316465623438343334333861313161363131376132613232376663623230623533
36663533376232663737613734653532313136343939663539373866333638396266666163383864 38303061386639616631383636303966666338353865626464363434353661393665613862303130
63613532393334373539346338616163383637633237666234613437663966653733616361353830 62303664653362336433356239626661353864363537346234613331376331313038633138363565
61656666376133363330633863346637376266343134633037313132313361366638616261363839 31616232653265343430646537373835643163396530353832366337663363386635306665643432
39393062396237666333303937363536346561343763663133323236393037383532396465336138 66393838363266383230363633313235376130356436633137636637666562383165643862313931
35613463356532376534386433626337613030343266353332306462306463336336343830666138 63346633343334333662363334373865653232623938363162363362646361383961376532626339
33656138363837633337393865643633623261613335366263643162663637623636666162653632 30643537356436346161353161626232303962396463323037653235343633643261396134373061
30626238616266323332616234393838343330663662393433366630393566316336636530303165 61323463653962363639373531366130326431353635346463396434393336313730373431316334
38343665623437356636643236393734396264356632326133623264633862633333626330336663 37313032666231383536363535326239383363346137363037653930373261326338303936663234
61376263656665653731636133316161653635323138303866623862303065366232633736623336 36326635346465316233363266383337343335653239393830356262346530363734383532303936
39656666386435343062656138313061616661313966326432663236626631316162623961616636 38633065633135666438333832333336636365326430656534313332356662356165616563333035
35343939613262303066626537396164616666316265643065373638663436643961336138313862 35363833363636346430356461306337396561366536326139623131303638333733616663653336
39666163646538356338356631346534633139643636393866646462646533363265663234633761 65623062626366386364343036386633626236363638393565323163623936663930363864656264
65363661336138353239656165393836386134666331663036653132306433343764643666306333 61323566376464356532316366633663623031613439653635323339363730366231326531303163
33623661626565633333306337303263633335386632386330353730316436313931326164363862 62313638393962653064303934663436376335663763333965366230323466646463653665656466
37616265653161633632353865346639653961653836353962303762336535666266386535363165 62643164616331636464613934376335353437653662363433363533613633633536346662656339
39653138646663376634323131613463333035326639313266613830616431316131383464353533 62353565303464373438383234353237636239313062643036383161303735386539613533383334
62656634346637636164626461613137303461633761336232373133653532323566303136663030 63383065613236316633623936383130353466383865376336393733663434663636333463336334
30633337346534636566343934306662356238396365306563336666623435353731613136333036 37356233306333366463303839643363393463636630306632326339646661643162323334633331
36343436373932323265306639363761353364383635333136366231373166613861633032343233 66613731313733646362396534356236363361363330383230303731356261333336653930303161
61376338616630343639333964356162613332323835333730333135356665383431626138643534 35353336326438376563616534616361353233373232303034623465656261326664393962326632
66393966666465303763316230386538393863303063386564303165303962346139373338303436 36373936393261616338396630383034323462646664623566663064316438363065646330353362
39373032663538323532323766353864643338326561313564373562616430326264386362666532 32633566666263333863383264363762323964356430336539623633643537336538353037396566
36613132306462336631363035343732636465343562643430343035373961366566383130656165 63396537626465363531393161653939633461366231326234663161646364616338636236313332
64613938393265343037633161653937323933646637653036306532366237313838346361333932 35646664333763623532306637383961623538643164633939303561316262316463646665353633
34663565653264626137323239336532643262356166633665313761336162303635346666383863 66313164646134646132653338356531303435623130343864326236353939356433396164336236
61383930383033626337383366353766393536653135383062656639323361353539356232613736 36623066396435323532356663326163636637346463626235616132353932326438303233393830
63646235663363333333623463313961326533653236363938383765663439613832653039386436 64326563303365646664376337303539643032363537633139623665346130636631386662373762
38393734633536313731323437336332353564363564333736663037386530333639326338656561 66336565303334303561386134343730306566303036313933613134366238303636316238363165
66336637353238383231613666313261383234336531666132396230373931623363323832633064 66373531633430363730376236353939626137323862623538356233616363376330366433633032
39613036346166393936613939363865616135653830366435643538336365353333613831353962 37363538393331376665623230623233343065653139313431323966326238636663633030393734
36623537363434373137633063373934383439333462646361613737303239643834303535366138 32643635326266646636663539353537623062633130386532373638396366353038663861333033
34656465316431656461373737643537303936636539383934373831616438343965373765373535 63343031383238306139653932366433346564643233323937316134306666623030623137313736
63653164363731653030303466646539636361383664343763646163663238383435653035653666 39623537346564623236353131656465326632303038366261626661333931323265396262636661
63383165626365653261303834333234626534396333353231303261396361616233363334383336 35313739306432323034643832623831373831666231643862613736393135383561386365323835
33316462636133336132656364613439396131613565646565396365316238323962353462653736 35623863396339653038316262303263313262616361666666343331393666663530363764643639
64386139313266663963643962363133386133393166306163626632646463333363323830306164 31353564633835323031303835636261613839353031373334366335323465326536323762626633
35353936653137383761326132373739306163613764386531613032313235373331303530383633 37343633376666323963336436623533346261396438343336663630366434383961393738383263
64646533313434653734366233633535323564386431306538633666383661303038613330653832 65383036333031643336393835303835363733663634653463313639313939636539386634663464
34643463396137643034353439653334653836333161396130363637326339383363303037306330 32643034383835333533343434656234343134313934323462643631653337383536363165613835
61353635633334343432646461396439393439383639336139316161373737333961653731393333 63393437653261653966313237633939626330316631633335386235346465663332336337613865
61636164343838346365373736356161386430356533303331333838333732363233613931613863 30626332353130326430316266363062353636356663663439346662313461393835663864656561
66633662383466306332366563373865323861323833353238356563363635313463366333653432 62633131323937656239383531373863393865386265663038346535616463326630646565663463
65303839653963376566383737346231343663363363313332383365646363373737323839613564 64393861366635656130386434633431393661656438333832633366333730643639333036613935
34613362303335316363363661653639386538326337386537333765643161613961316531613563 31363764396466333964343136363630386530343662656362316137306634383032363962616530
38386564636637643762643830666138383361396233303339643665343261356462393830376662 38393731346236353530626263336366376466343430316235653363396565656435323531393438
32656334346536636536343263336565333234353831616565366538393661353561376538346334 61356464333539636637363632626661663634333331643734316230663736333134383664376231
61396135623230366433303932396130636331333263316333643861626564343330386636613063 37613339613266663831613030633466326439323635626638343430383230333639333561646431
32383061616435643736653264313839363232346332343565336464353138396339623533393237 66313634373365373137613134373635333535333164353134623937633066613330393430633438
38353632646565323735643462626239663736643033643231613464663866663262366632353434 35366261633739623963623331373262313865326264386334633630653263343637343633313366
37363866343239363131633464316133396462353336613962306332343563333962333934616330 33303036623333633365373830333465333931633761636366323939363463303239363461333139
3536356634376131633039373834376533633065303533653333 33396663376335646137393436323463383461336233646236306331636361653964346536666637
36376133326431333234613435613535316263313364396362386537343565393533356564633338
64363261323838343531326234343138303133626636653732313234383662326131313431323332
32636539323033376434323939666437373936383763323762636439323836656432303833363362
35646235653839303838363936613166643662393131373438633265316136663264316332663638
32613535643565303566376530373065646333356136613462666465396566313933323261663736
66396565396261306139396364393962393361326363666439333566376561366466626335363461
34356232353664346234316330363962656332396236383136353461333234313662633234346565
34376365356133396239383333643163386133316461633032373035323131663139336339346633
32373633663231373361393762396632383738616330333038646439336532303461663430613133
37633833393762303035353566633736353130663136626666613061383732326233303831386466
38313162623836373533326361313031303636393564656634393263306262376336303663363933
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"
@@ -12,4 +11,10 @@ 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"