mirror of
https://github.com/fscotto/infra.git
synced 2026-10-04 05:49:50 +00:00
Remove completed Atlas Gitea migration tooling
This commit is contained in:
31
AGENTS.md
31
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`
|
`ansible-playbook ansible/site.yml --limit atlas --tags gitea_public_domain --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 +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`
|
`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 +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.
|
- 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 +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
|
- 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.
|
||||||
@@ -389,7 +380,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: []
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -175,9 +175,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 +188,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,9 +14,6 @@
|
|||||||
- 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
|
||||||
|
|
||||||
@@ -26,9 +23,6 @@
|
|||||||
- 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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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"
|
||||||
|
|||||||
Reference in New Issue
Block a user