Prepare gated Gitea HTTPS and SSH cutover

This commit is contained in:
Fabio Scotto di Santolo
2026-10-01 22:09:57 +02:00
parent 9b5ee77905
commit 31fedb8d44
13 changed files with 221 additions and 42 deletions

View File

@@ -65,6 +65,9 @@ Ansible-driven personal infrastructure repo for Fedora and Void desktops, Fedora
`ansible-playbook ansible/site.yml --limit atlas --tags gitea_final_restore --check --diff -e atlas_gitea_final_restore=true` `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): - 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` `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`
and `ansible-playbook ansible/site.yml --limit atlas --tags gitea --check --diff`
- Atlas network/share hardening: - Atlas network/share hardening:
`ansible-playbook ansible/site.yml --limit atlas --tags hardening,sharing --check --diff` `ansible-playbook ansible/site.yml --limit atlas --tags hardening,sharing --check --diff`
- Atlas ZFS snapshot retention and scrub timers: - Atlas ZFS snapshot retention and scrub timers:
@@ -289,7 +292,8 @@ successfully. The first monthly scrub remains a runtime check.
and snapshot mounts were removed, the Borg service ended successfully, and the pool was healthy. and snapshot mounts were removed, the Borg service ended successfully, and the pool was healthy.
- [ ] Include the new Gitea dataset in the next UUID-bound offline USB version and test a file restore - [ ] Include the new Gitea dataset in the next UUID-bound offline USB version and test a file restore
from that version before accepting production writes; the UUID-bound disk is connected but its from that version before accepting production writes; the UUID-bound disk is connected but its
LUKS mapper is closed, so the manual backup still requires interactive unlock. LUKS mapper is closed, so the manual backup still requires interactive unlock. On 2026-10-01 the
operator could not unlock it and chose to defer traffic cutover until this check passes.
- [x] Install a separate opt-in final Gitea export helper on Prometheus. Its 2026-10-01 targeted - [x] Install a separate opt-in final Gitea export helper on Prometheus. Its 2026-10-01 targeted
deployment and `bash -n` passed while Gitea and NPM stayed running. It refuses an active export deployment and `bash -n` passed while Gitea and NPM stayed running. It refuses an active export
timer, stops only Gitea, verifies SQLite, publishes a checksum-verified Gitea-only version for timer, stops only Gitea, verifies SQLite, publishes a checksum-verified Gitea-only version for
@@ -299,6 +303,12 @@ successfully. The first monthly scrub remains a runtime check.
layout before replacing the marked rehearsal, and rolls back a failed swap. Synthetic success layout before replacing the marked rehearsal, and rolls back a failed swap. Synthetic success
and rollback tests and a second idempotent rehearsal run passed on 2026-10-01; the final gate and rollback tests and a second idempotent rehearsal run passed on 2026-10-01; the final gate
has **not** been invoked. has **not** been invoked.
- [x] Prepare, but do not activate, the Atlas LAN rootless Quadlet and Prometheus TCP/2222 socket
proxy. NPM's two existing `gitea:3000` Proxy Hosts will resolve that name to Atlas through a
managed Compose `extra_hosts` entry after the source container is removed; no NPM database edit
is needed. The future-mode Prometheus check-run passed, both current-mode runs were idempotent,
the new systemd units passed verification, and source HTTP remained 200 on 2026-10-01. Public
2222 is closed and the target remains inactive until the explicit cutover flags are enabled.
- [ ] After an explicit outage approval, perform the final consistent copy and HTTPS/SSH cutover, - [ ] After an explicit outage approval, perform the final consistent copy and HTTPS/SSH cutover,
then remove Gitea from Prometheus' desired stack and backup export without deleting source data. then remove Gitea from Prometheus' desired stack and backup export without deleting source data.
- [ ] Design and deploy Nextcloud as another explicitly temporary Atlas service before Uranus. Give it - [ ] Design and deploy Nextcloud as another explicitly temporary Atlas service before Uranus. Give it

View File

@@ -90,23 +90,22 @@ server_backup_export_start_timer: false
# Explicit Gitea cutover helper: installed separately from any outage action. # Explicit Gitea cutover helper: installed separately from any outage action.
server_gitea_cutover_tools_enabled: false server_gitea_cutover_tools_enabled: false
server_gitea_final_export: false server_gitea_final_export: false
server_gitea_on_atlas: false
server_gitea_atlas_address: "{{ hostvars['atlas'].ansible_host }}"
server_gitea_ssh_public_port: 2222
server_gitea_ssh_target_port: 2222
server_backup_export_source_keep: 3 server_backup_export_source_keep: 3
server_backup_export_paths: server_backup_export_paths: >-
- opt/npm/data {{ ['opt/npm/data', 'opt/npm/letsencrypt']
- opt/npm/letsencrypt + ([] if server_gitea_on_atlas | bool else ['opt/gitea/data', 'home/git/.ssh'])
- opt/gitea/data + ['opt/docker/server/docker-compose.yml',
- home/git/.ssh 'etc/systemd/system/podman-compose-server.service',
- opt/docker/server/docker-compose.yml 'etc/ssh/sshd_config', 'etc/ssh/sshd_config.d',
- etc/systemd/system/podman-compose-server.service 'etc/firewalld', 'etc/wireguard/wg0.conf'] }}
- etc/ssh/sshd_config server_backup_export_excludes: >-
- etc/ssh/sshd_config.d {{ ['opt/npm/data/logs']
- etc/firewalld + ([] if server_gitea_on_atlas | bool else
- etc/wireguard/wg0.conf ['opt/gitea/data/gitea/log', 'opt/gitea/data/gitea/tmp',
server_backup_export_excludes: 'opt/gitea/data/gitea/sessions', 'opt/gitea/data/gitea/indexers']) }}
- opt/npm/data/logs
- opt/gitea/data/gitea/log
- opt/gitea/data/gitea/tmp
- opt/gitea/data/gitea/sessions
- opt/gitea/data/gitea/indexers
server_ssh_authorized_keys: [] server_ssh_authorized_keys: []
server_ssh_authorized_key_directory: "{{ server_user_home }}/.ssh/authorized_keys.d" server_ssh_authorized_key_directory: "{{ server_user_home }}/.ssh/authorized_keys.d"

View File

@@ -178,6 +178,10 @@ 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"
atlas_gitea_image: docker.gitea.com/gitea:1.25.2-rootless atlas_gitea_image: docker.gitea.com/gitea:1.25.2-rootless
atlas_gitea_production_enabled: false
atlas_gitea_bind_address: "{{ ansible_host }}"
atlas_gitea_http_port: 3000
atlas_gitea_ssh_port: 2222
atlas_gitea_staging_bind_address: 127.0.0.1 atlas_gitea_staging_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

View File

@@ -14,10 +14,25 @@
- atlas_gitea_gid | int != atlas_admin_gid | int - atlas_gitea_gid | int != atlas_admin_gid | int
- atlas_gitea_gid | int != atlas_immich_gid | int - atlas_gitea_gid | int != atlas_immich_gid | int
- atlas_gitea_staging_bind_address == '127.0.0.1' - atlas_gitea_staging_bind_address == '127.0.0.1'
- not (atlas_gitea_production_enabled | bool) or atlas_manage_firewall | bool
- not (atlas_gitea_production_enabled | bool) or atlas_gitea_bind_address == ansible_host
fail_msg: >- fail_msg: >-
Rootless Gitea preparation requires Atlas storage, an isolated service Rootless Gitea preparation requires Atlas storage, an isolated service
identity and dataset, and loopback-only staging ports. identity and dataset, and loopback-only staging ports.
- name: Inspect the final-restore marker before production activation
ansible.builtin.stat:
path: "{{ atlas_gitea_mountpoint }}/.final-sha256"
register: atlas_gitea_final_marker
when: atlas_gitea_production_enabled | bool
- name: Refuse production activation without the final consistent restore
ansible.builtin.assert:
that:
- atlas_gitea_final_marker.stat.isreg | default(false)
fail_msg: Restore the final stopped-source Gitea export before enabling production.
when: atlas_gitea_production_enabled | bool
- name: Create the dedicated Gitea group - name: Create the dedicated Gitea group
ansible.builtin.group: ansible.builtin.group:
name: "{{ atlas_gitea_group }}" name: "{{ atlas_gitea_group }}"
@@ -114,7 +129,7 @@
state: started state: started
when: not ansible_check_mode when: not ansible_check_mode
- name: Render the disabled rootless Gitea Quadlet - name: Render the rootless Gitea Quadlet
ansible.builtin.template: ansible.builtin.template:
src: atlas-gitea.container.j2 src: atlas-gitea.container.j2
dest: "{{ atlas_gitea_quadlet_dir }}/atlas-gitea.container" dest: "{{ atlas_gitea_quadlet_dir }}/atlas-gitea.container"
@@ -122,6 +137,20 @@
group: "{{ atlas_gitea_group }}" group: "{{ atlas_gitea_group }}"
mode: "0644" mode: "0644"
- name: Permit only Aegis to reach production Gitea HTTP and SSH
ansible.posix.firewalld:
rich_rule: >-
rule family="ipv4" source address="{{ atlas_aegis_ip }}"
port port="{{ item }}" protocol="tcp" accept
zone: "{{ atlas_firewalld_zone }}"
state: "{{ 'enabled' if atlas_gitea_production_enabled | bool else 'disabled' }}"
permanent: true
immediate: true
loop:
- "{{ atlas_gitea_http_port }}"
- "{{ atlas_gitea_ssh_port }}"
when: atlas_manage_firewall | bool
- name: Reload the rootless Gitea user manager without starting Gitea - name: Reload the rootless Gitea user manager without starting Gitea
become_user: "{{ atlas_gitea_username }}" become_user: "{{ atlas_gitea_username }}"
ansible.builtin.systemd: ansible.builtin.systemd:
@@ -131,3 +160,17 @@
XDG_RUNTIME_DIR: "/run/user/{{ atlas_gitea_uid }}" XDG_RUNTIME_DIR: "/run/user/{{ atlas_gitea_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ atlas_gitea_uid }}/bus" DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ atlas_gitea_uid }}/bus"
when: not ansible_check_mode when: not ansible_check_mode
- name: Start and enable the rootless Gitea user Quadlet after final restore
become_user: "{{ atlas_gitea_username }}"
ansible.builtin.systemd:
name: atlas-gitea.service
scope: user
state: started
enabled: true
environment:
XDG_RUNTIME_DIR: "/run/user/{{ atlas_gitea_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ atlas_gitea_uid }}/bus"
when:
- atlas_gitea_production_enabled | bool
- not ansible_check_mode

View File

@@ -1,14 +1,19 @@
# Managed by Ansible. Staging only: no [Install], no automatic start. # Managed by Ansible. Staging does not start automatically.
[Unit] [Unit]
Description=Atlas rootless Gitea staging target Description=Atlas rootless Gitea
RequiresMountsFor={{ atlas_gitea_mountpoint }} RequiresMountsFor={{ atlas_gitea_mountpoint }}
[Container] [Container]
ContainerName=atlas-gitea ContainerName=atlas-gitea
Image={{ atlas_gitea_image }} Image={{ atlas_gitea_image }}
UserNS=keep-id:uid=1000,gid=1000 UserNS=keep-id:uid=1000,gid=1000
{% if atlas_gitea_production_enabled | bool %}
PublishPort={{ atlas_gitea_bind_address }}:{{ atlas_gitea_http_port }}:3000
PublishPort={{ atlas_gitea_bind_address }}:{{ atlas_gitea_ssh_port }}:2222
{% else %}
PublishPort={{ atlas_gitea_staging_bind_address }}:{{ atlas_gitea_staging_http_port }}:3000 PublishPort={{ atlas_gitea_staging_bind_address }}:{{ atlas_gitea_staging_http_port }}:3000
PublishPort={{ atlas_gitea_staging_bind_address }}:{{ atlas_gitea_staging_ssh_port }}:2222 PublishPort={{ atlas_gitea_staging_bind_address }}:{{ atlas_gitea_staging_ssh_port }}:2222
{% endif %}
Volume={{ atlas_gitea_mountpoint }}/data:/var/lib/gitea:Z Volume={{ atlas_gitea_mountpoint }}/data:/var/lib/gitea:Z
Volume={{ atlas_gitea_mountpoint }}/config:/etc/gitea:Z Volume={{ atlas_gitea_mountpoint }}/config:/etc/gitea:Z
NoNewPrivileges=true NoNewPrivileges=true
@@ -18,3 +23,8 @@ DropCapability=all
Restart=on-failure Restart=on-failure
RestartSec=10 RestartSec=10
TimeoutStartSec=900 TimeoutStartSec=900
{% if atlas_gitea_production_enabled | bool %}
[Install]
WantedBy=default.target
{% endif %}

View File

@@ -44,7 +44,7 @@
when: server_backup_export_enabled | bool when: server_backup_export_enabled | bool
- name: Install Prometheus backup export helper - name: Install Prometheus backup export helper
tags: [services, backup, prometheus_backup] tags: [services, backup, prometheus_backup, gitea_cutover]
ansible.builtin.template: ansible.builtin.template:
src: prometheus-backup-export.sh.j2 src: prometheus-backup-export.sh.j2
dest: /usr/local/sbin/prometheus-backup-export dest: /usr/local/sbin/prometheus-backup-export

View File

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

View File

@@ -37,7 +37,7 @@
label: "{{ item.dest }}" label: "{{ item.dest }}"
- name: Render server templates - name: Render server templates
tags: [dotfiles, dotfiles:server] tags: [dotfiles, dotfiles:server, gitea_cutover]
ansible.builtin.template: ansible.builtin.template:
src: "{{ item.src }}" src: "{{ item.src }}"
dest: "{{ item.dest if item.dest.startswith('/') else server_user_home ~ '/' ~ item.dest }}" dest: "{{ item.dest if item.dest.startswith('/') else server_user_home ~ '/' ~ item.dest }}"
@@ -62,6 +62,9 @@
- name: Import explicit Prometheus Gitea final-export tasks - name: Import explicit Prometheus Gitea final-export tasks
ansible.builtin.import_tasks: gitea_final_export.yml ansible.builtin.import_tasks: gitea_final_export.yml
- name: Import Prometheus Gitea SSH proxy tasks
ansible.builtin.import_tasks: gitea_ssh_proxy.yml
- name: Ensure server SSH authorized key fragments directory exists - name: Ensure server SSH authorized key fragments directory exists
tags: [services, ssh] tags: [services, ssh]
ansible.builtin.file: ansible.builtin.file:

View File

@@ -59,7 +59,7 @@ stack_stopped=true
systemctl stop "$stack_unit" systemctl stop "$stack_unit"
tar --acls --xattrs --selinux "${excludes[@]}" -C / -cf "$stage/payload.tar" "${paths[@]}" tar --acls --xattrs --selinux "${excludes[@]}" -C / -cf "$stage/payload.tar" "${paths[@]}"
systemctl start "$stack_unit" systemctl start "$stack_unit"
for container in nginx-proxy-manager gitea; do for container in nginx-proxy-manager{% if not server_gitea_on_atlas | bool %} gitea{% endif %}; do
running=false running=false
for _ in {1..30}; do for _ in {1..30}; do
if [[ $(podman inspect --format '{{ '{{.State.Running}}' }}' "$container" 2>/dev/null) == true ]]; then if [[ $(podman inspect --format '{{ '{{.State.Running}}' }}' "$container" 2>/dev/null) == true ]]; then

View File

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

View File

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

View File

@@ -13,6 +13,10 @@ services:
- "127.0.0.1:81:81" - "127.0.0.1:81:81"
extra_hosts: extra_hosts:
- "host.containers.internal:host-gateway" - "host.containers.internal:host-gateway"
{% if server_gitea_on_atlas | bool %}
# Keep both existing NPM Proxy Hosts unchanged; their gitea name now resolves to Atlas.
- "gitea:{{ server_gitea_atlas_address }}"
{% endif %}
volumes: volumes:
- "/opt/npm/data:/data{{ ':' ~ selinux_volume_option if selinux_volume_option else '' }}" - "/opt/npm/data:/data{{ ':' ~ selinux_volume_option if selinux_volume_option else '' }}"
- "/opt/npm/letsencrypt:/etc/letsencrypt{{ ':' ~ selinux_volume_option if selinux_volume_option else '' }}" - "/opt/npm/letsencrypt:/etc/letsencrypt{{ ':' ~ selinux_volume_option if selinux_volume_option else '' }}"
@@ -38,6 +42,7 @@ services:
# networks: # networks:
# - web # - web
{% if not server_gitea_on_atlas | bool %}
gitea: gitea:
image: docker.gitea.com/gitea:1.25.2 image: docker.gitea.com/gitea:1.25.2
container_name: gitea container_name: gitea
@@ -55,6 +60,7 @@ services:
ports: ports:
- "3000:3000" - "3000:3000"
- "127.0.0.1:222:22" - "127.0.0.1:222:22"
{% endif %}
networks: networks:

View File

@@ -22,7 +22,7 @@ Uranus; NPM remains on Prometheus.
and pull succeeded. The intended target is a separate and pull succeeded. The intended target is a separate
`/zpool/services/data/gitea` dataset, not `Archive` or the backup dataset. `/zpool/services/data/gitea` dataset, not `Archive` or the backup dataset.
- The approved cutover keeps NPM on Prometheus, changes the two HTTP Proxy - The approved cutover keeps NPM on Prometheus, changes the two HTTP Proxy
Hosts to Atlas over the Prometheus--Aegis gateway, and offers public Gitea Hosts' effective upstream to Atlas over the Prometheus--Aegis gateway, and offers public Gitea
SSH on port 2222 via the same gateway. Prometheus port 22 is unchanged. SSH on port 2222 via the same gateway. Prometheus port 22 is unchanged.
HTTPS and SSH must be validated together before declaring cutover. HTTPS and SSH must be validated together before declaring cutover.
- Run Gitea as a **rootless user Quadlet** under a dedicated, non-login Atlas - Run Gitea as a **rootless user Quadlet** under a dedicated, non-login Atlas
@@ -118,36 +118,58 @@ only metadata marked `gitea-cutover`, validates a private staged replacement,
and swaps it for the marked rehearsal. The swap and its rollback path passed and swaps it for the marked rehearsal. The swap and its rollback path passed
synthetic tests on 2026-10-01; the gate has not been used on live Gitea data. synthetic tests on 2026-10-01; the gate has not been used on live Gitea data.
The network change is also prepared but inactive. `server_gitea_on_atlas=true`
removes the rootful Gitea service from the desired Prometheus Compose stack,
adds `gitea:192.168.178.55` to NPM's container hosts file, and removes Gitea
from future Prometheus backup exports. Both existing NPM Proxy Host records
remain at `gitea:3000`, but that name then resolves to Atlas; no direct SQLite
edit or NPM login is required. A separate systemd socket on public TCP/2222
proxies SSH to Atlas TCP/2222 over the gateway, leaving administrative TCP/22
unchanged. Atlas' production flag changes the user Quadlet from loopback
staging ports to LAN ports 3000/2222, grants only Aegis access in firewalld,
and starts it **only** after the `.final-sha256` marker exists. Neither flag
is enabled yet. The future Prometheus configuration passed a check-run; the
installed socket units passed `systemd-analyze verify` while remaining
inactive. Source Gitea still answered HTTP 200 after preparation.
The operator cannot unlock the UUID-bound USB disk now and chose to defer
traffic activation until a new USB version covers Gitea and its file restore
passes. This blocks the final export, production flags, and public cutover;
the prepared configuration alone does not constitute a migration.
1. Agree on an outage and record source/target versions, pool health, the 1. Agree on an outage and record source/target versions, pool health, the
latest backups, SSH host-key fingerprints, and both current NPM routes. latest backups, SSH host-key fingerprints, and both current NPM routes.
Stop the Prometheus export timer for the change window so it cannot Stop the Prometheus export timer for the change window so it cannot
restart the old Compose stack unexpectedly. restart the old Compose stack unexpectedly.
2. Quiesce source writes. Run one final consistent Prometheus export, pull it 2. Quiesce source writes with the final-export helper: it stops Gitea before
to Atlas, verify checksum and timestamp, then stop the source Gitea. Keep the consistent export and leaves it stopped after success. Pull that export
to Atlas and verify checksum and timestamp. Keep
`/opt/gitea/data` and `/home/git/.ssh` intact for rollback. Do not allow `/opt/gitea/data` and `/home/git/.ssh` intact for rollback. Do not allow
source Gitea to restart after accepting writes on Atlas. source Gitea to restart after accepting writes on Atlas.
3. Restore the final Gitea-only payload to the target and repeat integrity 3. Restore the final Gitea-only payload to the target and repeat integrity
checks. Set Gitea's advertised SSH port to 2222 while retaining its checks. Verify its advertised SSH port is 2222, its existing HTTPS
existing HTTPS `ROOT_URL` and verified host keys. Start the pinned rootless `ROOT_URL`, repositories, LFS/attachments, and SSH host-key identity. Enable
Atlas user Quadlet, the production Atlas Quadlet only after the final-restore marker exists;
initially without public ingress; validate local HTTP, SQLite, repositories, its firewall permits only Aegis to reach HTTP and SSH. Validate local HTTP
LFS/attachments, and SSH host-key identity. and the target service before switching NPM.
4. Permit only Aegis' source-NAT address to reach Atlas' Gitea HTTP and SSH 4. Enable the public TCP/2222 socket proxy on Prometheus to Atlas over Aegis
ports. Enable the public TCP/2222 forward on Prometheus to Atlas over Aegis without changing administrative TCP/22. Switch Prometheus to the desired
without changing administrative TCP/22. Update **both** NPM Proxy Hosts NPM-only Compose stack and recreate NPM with the managed `gitea` host alias
from `gitea:3000` to Atlas' HTTP endpoint. Do not change public DNS. so **both** existing Proxy Hosts reach Atlas without changing their database
records. The old Gitea data stays intact. Do not change public DNS.
5. Test HTTPS login, representative clone/push, LFS, and public SSH clone/push 5. Test HTTPS login, representative clone/push, LFS, and public SSH clone/push
on port 2222 from outside the Atlas LAN. Record the last source write and on port 2222 from outside the Atlas LAN. Record the last source write and
first healthy target service times; do not claim RPO/RTO without measuring. first healthy target service times; do not claim RPO/RTO without measuring.
6. Only after successful traffic validation, remove Gitea from Prometheus' 6. After successful traffic validation, resume the Prometheus NPM-only backup
desired Compose stack and its backup-export path/container checks, leaving export timer and verify its next result. Verify the next Atlas snapshot/Borg
NPM and its backups operational. Do not delete the old data. Verify the run covers Gitea and test a restored target copy. Do not delete old source
next Atlas snapshot/Borg run covers Gitea and test a restored target copy. data.
## Rollback gate ## Rollback gate
Before Atlas accepts writes, revert the two NPM routes, disable the public Before Atlas accepts writes, restore the old Compose definition (removing the
2222 forward, and restart the unchanged source Gitea if target validation NPM `gitea` host alias), disable the public 2222 proxy, and restart the
unchanged source Gitea if target validation
fails. **After Atlas accepts writes, do not blindly restart the source:** its fails. **After Atlas accepts writes, do not blindly restart the source:** its
SQLite database and repositories are stale. Quiesce Atlas, capture its new SQLite database and repositories are stale. Quiesce Atlas, capture its new
data, and decide a reverse migration or an extended outage explicitly. data, and decide a reverse migration or an extended outage explicitly.