diff --git a/AGENTS.md b/AGENTS.md index 3e12db0..8ae3dd8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,15 +68,7 @@ Ansible-driven personal infrastructure repo for Fedora and Void desktops, Fedora `ansible-playbook ansible/site.yml --limit atlas --tags gitea_public_domain --check --diff` - Atlas iCloudPD storage and boot-started Quadlet: `ansible-playbook ansible/site.yml --limit atlas --tags icloudpd --check --diff` - - Atlas explicit Gitea host-owner migration (live outage; never a normal run): - `ansible-playbook ansible/site.yml --limit atlas --tags gitea_owner_migration -e atlas_gitea_owner_migration=true` - - Atlas explicit isolated Gitea restore rehearsal (not part of normal runs): - `ansible-playbook ansible/site.yml --limit atlas --tags gitea_restore -e atlas_gitea_restore_test=true` - - Atlas final Gitea replacement gate (dry-run only until a stopped-source export is pulled): - `ansible-playbook ansible/site.yml --limit atlas --tags gitea_final_restore --check --diff -e atlas_gitea_final_restore=true` - - Prometheus final Gitea export helper (dry-run installs only; outage action remains opt-in): - `ansible-playbook ansible/site.yml --limit prometheus --tags gitea_final_export --check --diff` - - Gitea cutover network configuration before activation: + - Ongoing Gitea proxy configuration: `ansible-playbook ansible/site.yml --limit prometheus --tags gitea_cutover,prometheus_backup --check --diff -e server_gitea_on_atlas=true` and `ansible-playbook ansible/site.yml --limit atlas --tags gitea --check --diff` - Atlas daily Navidrome music copy: @@ -99,7 +91,6 @@ Ansible-driven personal infrastructure repo for Fedora and Void desktops, Fedora `ansible-playbook ansible/site.yml --limit prometheus,aegis --tags wireguard --check --diff` - Prometheus NPM Quadlet steady state (does not perform a cutover): `ansible-playbook ansible/site.yml --limit prometheus --tags npm_quadlet --check --diff` - - DuckDNS config only (skipped on Prometheus): `ansible-playbook ansible/site.yml --limit prometheus --tags duckdns --check --diff` ## Conventions - Use FQCN Ansible modules. @@ -143,13 +134,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. ## Rocky Server Notes -- Prometheus disables DuckDNS provisioning with `server_duckdns_enabled: false`. Its updater, - log and five-minute cron entry were explicitly retired; the external DuckDNS name and Vault - token remain untouched. The completed one-time cleanup has no remaining playbook tasks. -- When enabled, DuckDNS is rendered by `profile_server` from host-local `server_duckdns_domain` and - `vault_duckdns_token`. Keep the rotated token in encrypted Vault or untracked local vars, never in - dotfiles. The private `~/duckdns/duck.sh` keeps the existing entrypoint; rendering uses `no_log` - and disables diffs. Provisioning does not execute the updater or change its external schedule. +- DuckDNS support is removed from the server profile, not feature-gated. No updater tasks, + templates or enablement variables remain. Prometheus uses its static IP and `fscotto.co`; + the local updater, log and cron job were already retired. External DuckDNS account/name + and existing encrypted token are outside this removal and remain untouched. - `rocky_server` is a child of both `platform_rocky` and `server`; `prometheus` is its active target. - 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 @@ -164,8 +152,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 `443/tcp`; bind its administration interface only to `127.0.0.1:81` and use `npm-tunnel` from Ikaros or Nymph. Nextcloud remains disabled; do not provision `/srv/nextcloud` directories. -- `scripts/migrate_prometheus_data.sh` is the separate, source-host-run NPM/Gitea migration path. It dry-runs by - default and requires explicit source-stack quiescing before copying persistent Docker data with rsync. +- The completed Ubuntu-to-Rocky data migration script and its operational instructions + 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 leak into `rocky_server`. Cockpit plus its Navigator and Podman extensions are selected explicitly for Prometheus through its host variables. @@ -389,7 +380,7 @@ successfully. The first monthly scrub remains a runtime check. The operator confirmed completion on 2026-10-03. - [x] Retire Prometheus' local DuckDNS updater on 2026-10-03 through Ansible: the five-minute cron entry and private updater/log directory were removed. - Provisioning is disabled; repeat cleanup changed nothing. HTTPS services, private NPM + Provisioning support was subsequently removed entirely; repeat cleanup changed nothing. HTTPS services, private NPM administration and the export timer stayed healthy. The external name and Vault token remain untouched for possible future use on a local host. - [ ] Keep `atlas_manage_media_stack` disabled until the future Immich deployment has validated `/dev/dri`, diff --git a/README.it.md b/README.it.md index 4195833..46516d7 100644 --- a/README.it.md +++ b/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. 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 -e `fscotto.co`. Updater, log e cron ogni cinque minuti sono stati rimossi una sola volta; -non restano task o flag di pulizia. Il nome DuckDNS esterno e il token Vault restano invariati. - -Sui server con `server_duckdns_enabled: true`, `profile_server` genera `~/duckdns/duck.sh` con permessi `0700`, mantenendo il percorso dello -script e `duck.log`. Definire `server_duckdns_domain` negli host vars del server e salvare il -**nuovo token rigenerato** in `vault_duckdns_token`, nel Vault cifrato `secrets/vault.yml` -(`ansible-vault edit secrets/vault.yml`) oppure negli override non versionati `secrets/vault.local.yml`. -Non committare lo script generato e non passare il token sulla riga di comando. Il rendering -nasconde output e diff sensibili; lo script verifica TLS e passa il token a curl tramite stdin. -Il playbook non esegue lo script e non modifica la sua schedulazione esterna. - -```bash -ansible-playbook ansible/site.yml --limit prometheus --tags duckdns --check --diff -ansible-playbook ansible/site.yml --limit prometheus --tags duckdns -``` - -La cancellazione dalla cronologia non revoca il token: rigenerarlo sul pannello DuckDNS. -Dopo la bonifica, riclonare gli altri checkout senza unire nuovamente la vecchia storia; -salvare separatamente eventuali modifiche non committate senza copiare segreti. - -### Migrazione dati - -Dopo il provisioning Rocky, eseguire `scripts/migrate_prometheus_data.sh` **sul server Ubuntu -sorgente**. Lo script usa rsync, e in dry-run di default; richiede `--quiesce-source --execute` per -fermare lo stack sorgente e copiare in modo consistente soltanto i dati di Nginx Proxy Manager e -Gitea. Non sposta Navidrome o Syncthing, non avvia container, non cancella dati e non esegue il -cutover. +Il supporto DuckDNS è stato rimosso dal profilo server: non restano task, template, +variabili o flag di abilitazione. Prometheus usa IP statico e `fscotto.co`. +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. Utente del profilo server: diff --git a/README.md b/README.md index b48853d..479634f 100644 --- a/README.md +++ b/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 `~/.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 -and `fscotto.co`. The local updater, log and five-minute cron job were removed once; -no cleanup tasks or flags remain. The external DuckDNS name and Vault token remain untouched. - -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. +DuckDNS support has been removed from the server profile: no tasks, templates, +variables or enablement flags remain. Prometheus uses its static IP and `fscotto.co`. +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. ## DNS Filter diff --git a/ansible/inventory/group_vars/server.yml b/ansible/inventory/group_vars/server.yml index fa2e1d3..b7ba58a 100644 --- a/ansible/inventory/group_vars/server.yml +++ b/ansible/inventory/group_vars/server.yml @@ -10,7 +10,6 @@ server_npm_quadlet_stage: false server_npm_quadlet_cutover: false server_legacy_stack_retired: false server_legacy_cleanup: false -server_duckdns_enabled: true ai_agents: {} vim_plugins_enabled: false @@ -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_export_calendar: "*-*-* 02:00:00 Europe/Rome" server_backup_export_start_timer: false -# Explicit Gitea cutover helper: installed separately from any outage action. -server_gitea_cutover_tools_enabled: false -server_gitea_final_export: false +# Ongoing public Gitea proxy configuration. +server_gitea_proxy_enabled: false server_gitea_on_atlas: false server_gitea_atlas_address: "{{ hostvars['atlas'].ansible_host }}" server_gitea_npm_domains: [] diff --git a/ansible/inventory/host_vars/prometheus.yml b/ansible/inventory/host_vars/prometheus.yml index 7014f39..92e4cee 100644 --- a/ansible/inventory/host_vars/prometheus.yml +++ b/ansible/inventory/host_vars/prometheus.yml @@ -22,12 +22,10 @@ server_npm_quadlet_cutover: true server_backup_export_enabled: true server_backup_export_start_timer: true # Install the final-copy helper only; it is never run by a normal playbook invocation. -server_gitea_cutover_tools_enabled: true +server_gitea_proxy_enabled: true server_gitea_on_atlas: true server_gitea_npm_domains: - git.fscotto.duckdns.org -server_duckdns_domain: fscotto -server_duckdns_enabled: false server_ssh_authorized_keys: - name: ikaros key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINrIxXjA3ffPwziKGR5gzc4gAoBehQPlnEMcXF4Wl0ZS ikaros" diff --git a/ansible/roles/profile_atlas/defaults/main.yml b/ansible/roles/profile_atlas/defaults/main.yml index 0248fc1..d786a58 100644 --- a/ansible/roles/profile_atlas/defaults/main.yml +++ b/ansible/roles/profile_atlas/defaults/main.yml @@ -175,9 +175,6 @@ atlas_gitea_home: "{{ atlas_admin_home }}" atlas_gitea_container_uid: 1000 atlas_gitea_container_gid: 1000 atlas_gitea_legacy_username: gitea -atlas_gitea_legacy_uid: 1101 -atlas_gitea_legacy_home: /var/lib/atlas-gitea -atlas_gitea_owner_migration: false atlas_gitea_dataset: "{{ atlas_zfs_pool }}/services/data/gitea" atlas_gitea_mountpoint: "{{ atlas_app_data_mountpoint }}/gitea" atlas_gitea_quadlet_dir: "{{ atlas_gitea_home }}/.config/containers/systemd" @@ -191,9 +188,6 @@ atlas_gitea_ssh_port: 2222 atlas_gitea_staging_bind_address: 127.0.0.1 atlas_gitea_staging_http_port: 3001 atlas_gitea_staging_ssh_port: 2223 -atlas_gitea_restore_test: false -atlas_gitea_final_restore: false -atlas_gitea_restore_helper: /usr/local/libexec/atlas-gitea-restore-test # Declare storage and an inactive Quadlet only. The operator supplies the # private configuration, handles MFA, and starts the user service manually. diff --git a/ansible/roles/profile_atlas/files/atlas-gitea-restore-test.py b/ansible/roles/profile_atlas/files/atlas-gitea-restore-test.py deleted file mode 100644 index e478c06..0000000 --- a/ansible/roles/profile_atlas/files/atlas-gitea-restore-test.py +++ /dev/null @@ -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() diff --git a/ansible/roles/profile_atlas/tasks/gitea.yml b/ansible/roles/profile_atlas/tasks/gitea.yml index 74a9bf7..6c0daf0 100644 --- a/ansible/roles/profile_atlas/tasks/gitea.yml +++ b/ansible/roles/profile_atlas/tasks/gitea.yml @@ -47,8 +47,8 @@ - atlas_gitea_dataset_owner.stat.uid | int == atlas_admin_uid | int - atlas_gitea_dataset_owner.stat.gid | int == atlas_admin_gid | int fail_msg: >- - Run the explicit Gitea owner migration before enabling the admin - Quadlet; never chown an active legacy service in a normal run. + The production dataset must already belong to admin before enabling + the Quadlet; normal provisioning must not chown an active legacy service. when: atlas_gitea_production_enabled | bool - name: Remove the retired account's parent-dataset traverse ACL diff --git a/ansible/roles/profile_atlas/tasks/gitea_owner_migration.yml b/ansible/roles/profile_atlas/tasks/gitea_owner_migration.yml deleted file mode 100644 index 2d5352a..0000000 --- a/ansible/roles/profile_atlas/tasks/gitea_owner_migration.yml +++ /dev/null @@ -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" diff --git a/ansible/roles/profile_atlas/tasks/gitea_restore.yml b/ansible/roles/profile_atlas/tasks/gitea_restore.yml deleted file mode 100644 index 3687044..0000000 --- a/ansible/roles/profile_atlas/tasks/gitea_restore.yml +++ /dev/null @@ -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 diff --git a/ansible/roles/profile_atlas/tasks/main.yml b/ansible/roles/profile_atlas/tasks/main.yml index 77f550a..97bb3a9 100644 --- a/ansible/roles/profile_atlas/tasks/main.yml +++ b/ansible/roles/profile_atlas/tasks/main.yml @@ -14,9 +14,6 @@ - name: Import Atlas storage tasks ansible.builtin.import_tasks: storage.yml -- name: Import explicit Atlas Gitea owner migration - ansible.builtin.import_tasks: gitea_owner_migration.yml - - name: Import staged Atlas rootless Gitea tasks ansible.builtin.import_tasks: gitea.yml @@ -26,9 +23,6 @@ - name: Import Atlas iCloudPD storage and boot-started Quadlet tasks ansible.builtin.import_tasks: icloudpd.yml -- name: Import explicit Atlas Gitea restore rehearsal tasks - ansible.builtin.import_tasks: gitea_restore.yml - - name: Import Atlas ZFS maintenance tasks ansible.builtin.import_tasks: zfs_maintenance.yml diff --git a/ansible/roles/profile_server/tasks/duckdns.yml b/ansible/roles/profile_server/tasks/duckdns.yml deleted file mode 100644 index 3ae073d..0000000 --- a/ansible/roles/profile_server/tasks/duckdns.yml +++ /dev/null @@ -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 diff --git a/ansible/roles/profile_server/tasks/gitea_final_export.yml b/ansible/roles/profile_server/tasks/gitea_final_export.yml deleted file mode 100644 index fb02d79..0000000 --- a/ansible/roles/profile_server/tasks/gitea_final_export.yml +++ /dev/null @@ -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 diff --git a/ansible/roles/profile_server/tasks/gitea_npm_proxy.yml b/ansible/roles/profile_server/tasks/gitea_npm_proxy.yml index 3466d60..29c198a 100644 --- a/ansible/roles/profile_server/tasks/gitea_npm_proxy.yml +++ b/ansible/roles/profile_server/tasks/gitea_npm_proxy.yml @@ -3,7 +3,7 @@ tags: [services, gitea_cutover] ansible.builtin.assert: that: - - server_gitea_cutover_tools_enabled | bool + - server_gitea_proxy_enabled | bool - server_gitea_npm_domains | length > 0 - server_gitea_npm_domains | select('match', '^[a-zA-Z0-9.-]+$') | list | length == server_gitea_npm_domains | length fail_msg: Declare the exact NPM Gitea hostnames before enabling the Atlas upstream. @@ -17,7 +17,7 @@ owner: root group: root mode: "0755" - when: server_gitea_cutover_tools_enabled | bool + when: server_gitea_proxy_enabled | bool - name: Render the Gitea-only NPM runtime upstream override tags: [services, gitea_cutover] @@ -36,7 +36,7 @@ path: /opt/npm/data/nginx/custom/server_proxy.conf state: absent when: - - server_gitea_cutover_tools_enabled | bool + - server_gitea_proxy_enabled | bool - not server_gitea_on_atlas | bool - name: Validate NPM configuration after a Gitea upstream change diff --git a/ansible/roles/profile_server/tasks/gitea_ssh_proxy.yml b/ansible/roles/profile_server/tasks/gitea_ssh_proxy.yml index ceb53eb..d3ebe14 100644 --- a/ansible/roles/profile_server/tasks/gitea_ssh_proxy.yml +++ b/ansible/roles/profile_server/tasks/gitea_ssh_proxy.yml @@ -3,7 +3,7 @@ tags: [services, gitea_cutover] ansible.builtin.assert: 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_ssh_public_port | int > 1024 - server_gitea_ssh_public_port | int < 65536 @@ -27,14 +27,14 @@ loop_control: label: "{{ item }}" 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 tags: [services, gitea_cutover] ansible.builtin.systemd: daemon_reload: true when: - - server_gitea_cutover_tools_enabled | bool + - server_gitea_proxy_enabled | bool - server_gitea_ssh_proxy_units is changed - not ansible_check_mode @@ -45,7 +45,7 @@ state: "{{ 'started' if server_gitea_on_atlas | bool else 'stopped' }}" enabled: "{{ server_gitea_on_atlas | bool }}" when: - - server_gitea_cutover_tools_enabled | bool + - server_gitea_proxy_enabled | bool - not ansible_check_mode - name: Open only the public Gitea SSH port after cutover @@ -57,5 +57,5 @@ permanent: true immediate: true when: - - server_gitea_cutover_tools_enabled | bool + - server_gitea_proxy_enabled | bool - server_firewall_backend == 'firewalld' diff --git a/ansible/roles/profile_server/tasks/main.yml b/ansible/roles/profile_server/tasks/main.yml index e53f3da..1c0a8a6 100644 --- a/ansible/roles/profile_server/tasks/main.yml +++ b/ansible/roles/profile_server/tasks/main.yml @@ -8,11 +8,6 @@ fail_msg: >- 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 tags: [dotfiles, services] ansible.builtin.file: @@ -79,9 +74,6 @@ tags: [never, server_legacy_cleanup] 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 ansible.builtin.import_tasks: gitea_ssh_proxy.yml diff --git a/ansible/roles/profile_server/templates/duck.sh.j2 b/ansible/roles/profile_server/templates/duck.sh.j2 deleted file mode 100644 index 061abf4..0000000 --- a/ansible/roles/profile_server/templates/duck.sh.j2 +++ /dev/null @@ -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 diff --git a/ansible/roles/profile_server/templates/prometheus-gitea-final-export.sh.j2 b/ansible/roles/profile_server/templates/prometheus-gitea-final-export.sh.j2 deleted file mode 100644 index 567bcdc..0000000 --- a/ansible/roles/profile_server/templates/prometheus-gitea-final-export.sh.j2 +++ /dev/null @@ -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" diff --git a/docs/atlas-gitea-migration.md b/docs/atlas-gitea-migration.md index 987fe9a..24bfe6d 100644 --- a/docs/atlas-gitea-migration.md +++ b/docs/atlas-gitea-migration.md @@ -1,5 +1,10 @@ # Gitea migration from Prometheus to Atlas +Historical record: the completed owner-migration, migration-restore and final-export +tasks, helpers and flags have been removed from the repository. Commands below +record past execution, not currently supported migration entry points. Current +service safety checks, recurring backups and proxy configuration remain managed. + The later 2026-10-03 canonical-domain change to `git.fscotto.co` is recorded in `docs/domain-fscotto-co.md`. Public SSH remains on TCP/2222; the old DuckDNS Proxy Host was observed disabled. Earlier domain references below diff --git a/docs/domain-fscotto-co.md b/docs/domain-fscotto-co.md index d58d4b8..90fa3c9 100644 --- a/docs/domain-fscotto-co.md +++ b/docs/domain-fscotto-co.md @@ -54,15 +54,15 @@ before accepting the new hostname's identity. ## 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 `~/duckdns` directory containing only `duck.sh` and `duck.log`. The temporary cleanup tasks and flag were subsequently removed from the playbook at the -operator's request. Only the disabled provisioning state remains; ordinary -provisioning cannot recreate the updater. +operator's request. The updater provisioning tasks, template, variables and +enablement flag were also removed; there is no retained opt-in support. The external DuckDNS name, Vault token, disabled NPM hosts and certificates remain untouched for a separate future decision. -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, and NPM administration still listened only on `127.0.0.1:81`. diff --git a/scripts/migrate_prometheus_data.sh b/scripts/migrate_prometheus_data.sh deleted file mode 100644 index 11dca6f..0000000 --- a/scripts/migrate_prometheus_data.sh +++ /dev/null @@ -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 diff --git a/secrets/vault.yml.example b/secrets/vault.yml.example index 3e3e0b6..9a8e43e 100644 --- a/secrets/vault.yml.example +++ b/secrets/vault.yml.example @@ -1,5 +1,4 @@ --- -vault_duckdns_token: "CHANGEME" vault_personal_full_name: "REPLACE_ME" vault_git_email: "REPLACE_ME" vault_git_signing_key: "REPLACE_ME"