mirror of
https://github.com/fscotto/infra.git
synced 2026-10-04 05:49:50 +00:00
Compare commits
2 Commits
18eb2d2eb2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
def3dbf313 | ||
|
|
a00602973c |
52
AGENTS.md
52
AGENTS.md
@@ -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`,
|
||||||
|
|||||||
34
README.it.md
34
README.it.md
@@ -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:
|
||||||
|
|
||||||
|
|||||||
47
README.md
47
README.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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: []
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
309
ansible/roles/profile_atlas/tasks/nextcloud.yml
Normal file
309
ansible/roles/profile_atlas/tasks/nextcloud.yml
Normal 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'
|
||||||
171
ansible/roles/profile_atlas/tasks/nextcloud_application.yml
Normal file
171
ansible/roles/profile_atlas/tasks/nextcloud_application.yml
Normal 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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -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
|
||||||
@@ -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 }}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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"
|
|
||||||
@@ -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
|
||||||
|
|||||||
105
docs/atlas-nextcloud-design.md
Normal file
105
docs/atlas-nextcloud-design.md
Normal 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
127
docs/atlas-nextcloud.md
Normal 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.
|
||||||
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user