Move Atlas Gitea Quadlet to admin with internal gitea user

This commit is contained in:
Fabio Scotto di Santolo
2026-10-02 10:06:51 +02:00
parent 0028fe8c4d
commit dd33a4f55d
13 changed files with 453 additions and 78 deletions

View File

@@ -59,6 +59,8 @@ Ansible-driven personal infrastructure repo for Fedora and Void desktops, Fedora
`ansible-playbook ansible/site.yml --limit atlas --tags storage,sharing,containers --check --diff` `ansible-playbook ansible/site.yml --limit atlas --tags storage,sharing,containers --check --diff`
- Atlas rootless Gitea staging (does not start Gitea): - Atlas rootless Gitea staging (does not start Gitea):
`ansible-playbook ansible/site.yml --limit atlas --tags gitea --check --diff` `ansible-playbook ansible/site.yml --limit atlas --tags gitea --check --diff`
- Atlas explicit Gitea host-owner migration (live outage; never a normal run):
`ansible-playbook ansible/site.yml --limit atlas --tags gitea_owner_migration -e atlas_gitea_owner_migration=true`
- Atlas explicit isolated Gitea restore rehearsal (not part of normal runs): - 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` `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): - Atlas final Gitea replacement gate (dry-run only until a stopped-source export is pulled):
@@ -273,7 +275,8 @@ successfully. The first monthly scrub remains a runtime check.
- [ ] After data protection and recovery are validated, populate `/zpool/media/music` and validate Navidrome. - [ ] After data protection and recovery are validated, populate `/zpool/media/music` and validate Navidrome.
- [x] Design the staged Prometheus-to-Atlas Gitea migration in `docs/atlas-gitea-migration.md`. - [x] Design the staged Prometheus-to-Atlas Gitea migration in `docs/atlas-gitea-migration.md`.
The approved topology keeps NPM on Prometheus and moves HTTPS and public SSH (TCP/2222) together; The approved topology keeps NPM on Prometheus and moves HTTPS and public SSH (TCP/2222) together;
Gitea must run as a dedicated rootless user Quadlet on Atlas. The rootful-to-rootless data-layout Gitea runs as an `admin`-owned rootless user Quadlet on Atlas with an internal `gitea` user.
The rootful-to-rootless data-layout
conversion passed an isolated restore rehearsal. The later partial cutover is tracked below. conversion passed an isolated restore rehearsal. The later partial cutover is tracked below.
- [x] Prepare the dedicated Atlas Gitea dataset, non-login UID/GID 1101 with a separate rootless Podman - [x] Prepare the dedicated Atlas Gitea dataset, non-login UID/GID 1101 with a separate rootless Podman
sub-ID range, and disabled user Quadlet. On 2026-10-01 the targeted Ansible run and a second idempotent sub-ID range, and disabled user Quadlet. On 2026-10-01 the targeted Ansible run and a second idempotent
@@ -316,6 +319,15 @@ successfully. The first monthly scrub remains a runtime check.
Gitea container was removed from the desired Compose stack without deleting its data; the Gitea container was removed from the desired Compose stack without deleting its data; the
Prometheus backup export timer resumed for NPM only. A post-cutover recursive ZFS snapshot and Prometheus backup export timer resumed for NPM only. A post-cutover recursive ZFS snapshot and
encrypted Borg archive `atlas-20261002T073044Z` completed successfully. encrypted Borg archive `atlas-20261002T073044Z` completed successfully.
- [x] Move the live Gitea Quadlet and dataset from the legacy host `gitea` account to `admin`
after a disposable snapshot-copy test of the pinned derived image. On 2026-10-02 the explicit
outage run stopped only legacy Gitea, made safety snapshot
`zpool/services/data/gitea@gitea-owner-migration-20261002T100104`, changed dataset ownership,
and validated loopback staging (HTTP 200, internal `gitea` UID/GID 1000, SQLite `quick_check`)
before promoting the `admin` Quadlet. Production LAN and public HTTPS returned 200; Navidrome
and Syncthing remained active, the pool was healthy, and the normal Gitea run changed nothing.
The old host account and data on Prometheus remain preserved; the old Atlas Quadlet and its
parent-dataset traverse ACL were removed. A subsequent normal run changed nothing.
- [ ] Complete public SSH/2222 and representative authenticated HTTPS/SSH clone/push validation. - [ ] Complete public SSH/2222 and representative authenticated HTTPS/SSH clone/push validation.
Prometheus' TCP/2222 socket and firewalld rule are active and the local proxy presents the Prometheus' TCP/2222 socket and firewalld rule are active and the local proxy presents the
matching Atlas host key, but Ikaros' external TCP connection timed out and no SYN reached matching Atlas host key, but Ikaros' external TCP connection timed out and no SYN reached

View File

@@ -322,12 +322,13 @@ alla LAN. Dopo la verifica dei servizi, configurare manualmente i Proxy Host NPM
negli `AllowedIPs`; aggiungere la VIP Uranus quando esisterà. Dopo il reload di firewalld, Ansible negli `AllowedIPs`; aggiungere la VIP Uranus quando esisterà. Dopo il reload di firewalld, Ansible
ricarica le reti Podman rootful di Prometheus per conservare DNS e connettività del proxy. ricarica le reti Podman rootful di Prometheus per conservare DNS e connettività del proxy.
La migrazione Gitea da Prometheus ad Atlas è predisposta in La migrazione Gitea da Prometheus ad Atlas è descritta in
[`docs/atlas-gitea-migration.md`](docs/atlas-gitea-migration.md). Atlas ha un dataset e un account [`docs/atlas-gitea-migration.md`](docs/atlas-gitea-migration.md). Gitea usa un Quadlet rootless
dedicati con Quadlet utente rootless inattivo. Una copia isolata del backup Prometheus ha superato di `admin` su un dataset dedicato; l'immagine derivata mantiene UID/GID 1000 ma chiama l'utente
i controlli SQLite, Git e del container rootless senza rete; non è la copia finale per il cutover. interno `gitea`. NPM resta su Prometheus e l'HTTPS pubblico primario serve Atlas. L'SSH pubblico
NPM resta su Prometheus; stack sorgente e instradamento pubblico rimangono invariati fino a un cutover su TCP/2222 non era ancora raggiungibile dall'esterno il 2026-10-02; non considerare completo
HTTPS e SSH separato e validato. il cutover HTTPS+SSH finché non sono validati clone/push autenticati. I dati sorgente restano
conservati su Prometheus senza avviarne il vecchio container.
Validare il gateway con: Validare il gateway con:

View File

@@ -300,7 +300,8 @@ and Aegis (`10.0.0.2`). Their state is initialized ex novo in `/zpool/services/d
The Gitea move from Prometheus to Atlas is tracked in The Gitea move from Prometheus to Atlas is tracked in
[`docs/atlas-gitea-migration.md`](docs/atlas-gitea-migration.md). The final consistent copy runs in [`docs/atlas-gitea-migration.md`](docs/atlas-gitea-migration.md). The final consistent copy runs in
Atlas' dedicated dataset under a rootless user Quadlet. NPM remains on Prometheus and the primary Atlas' dedicated dataset under `admin`'s rootless user Quadlet. Its pinned derived image uses an
internal Unix user named `gitea` (UID/GID 1000), while clone URLs keep `git@`. NPM remains on Prometheus and the primary
public HTTPS route serves Atlas. The public SSH/2222 socket works locally on Prometheus, but an public HTTPS route serves Atlas. The public SSH/2222 socket works locally on Prometheus, but an
external connection did not reach its interface on 2026-10-02; check upstream filtering before external connection did not reach its interface on 2026-10-02; check upstream filtering before
declaring the HTTPS+SSH cutover complete. The old Gitea data remains on Prometheus, but its container declaring the HTTPS+SSH cutover complete. The old Gitea data remains on Prometheus, but its container

View File

@@ -52,9 +52,6 @@ atlas_manage_storage: true
# Rootless Gitea was restored from the stopped-source export before production activation. # Rootless Gitea was restored from the stopped-source export before production activation.
atlas_manage_gitea: true atlas_manage_gitea: true
atlas_gitea_production_enabled: true atlas_gitea_production_enabled: true
# Dedicated rootless Podman range; admin owns 100000-165535 on this host.
atlas_gitea_subid_start: 165536
atlas_gitea_subid_count: 65536
atlas_prometheus_pull_start_timer: true atlas_prometheus_pull_start_timer: true
atlas_manage_zfs_snapshots: true atlas_manage_zfs_snapshots: true
atlas_zfs_snapshot_prefix: atlas-auto atlas_zfs_snapshot_prefix: atlas-auto

View File

@@ -165,19 +165,24 @@ atlas_prometheus_pull_keep_monthly: 12
atlas_prometheus_pull_max_age_hours: 24 atlas_prometheus_pull_max_age_hours: 24
atlas_photobook_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_photobook }}" atlas_photobook_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_photobook }}"
# Staged rootless Gitea target. Preparation never starts the user Quadlet or opens ingress. # Rootless Gitea runs in admin's user manager; the image maps internal gitea to UID/GID 1000.
atlas_manage_gitea: false atlas_manage_gitea: false
atlas_gitea_username: gitea atlas_gitea_username: "{{ atlas_admin_username }}"
atlas_gitea_group: gitea atlas_gitea_group: "{{ atlas_admin_group }}"
atlas_gitea_uid: 1101 atlas_gitea_uid: "{{ atlas_admin_uid }}"
atlas_gitea_gid: 1101 atlas_gitea_gid: "{{ atlas_admin_gid }}"
atlas_gitea_subid_start: 165536 atlas_gitea_home: "{{ atlas_admin_home }}"
atlas_gitea_subid_count: 65536 atlas_gitea_container_uid: 1000
atlas_gitea_home: /var/lib/atlas-gitea atlas_gitea_container_gid: 1000
atlas_gitea_legacy_username: gitea
atlas_gitea_legacy_uid: 1101
atlas_gitea_legacy_home: /var/lib/atlas-gitea
atlas_gitea_owner_migration: false
atlas_gitea_dataset: "{{ atlas_zfs_pool }}/services/data/gitea" atlas_gitea_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: localhost/atlas-gitea:1.25.2-user-gitea-v1
atlas_gitea_image_build_dir: "{{ atlas_gitea_home }}/.local/share/atlas-gitea-image"
atlas_gitea_production_enabled: false atlas_gitea_production_enabled: false
atlas_gitea_bind_address: "{{ ansible_host }}" atlas_gitea_bind_address: "{{ ansible_host }}"
atlas_gitea_http_port: 3000 atlas_gitea_http_port: 3000

View File

@@ -0,0 +1,10 @@
FROM docker.gitea.com/gitea@sha256:f1943db2d2f1e447e857b3f0aee4ebb7b184500f86e5b80eae110fd435435906
# Preserve the official image's UID/GID, paths and entrypoint; change only the
# internal Unix identity. The host-side rootless owner is Atlas admin.
USER 0
RUN sed -i 's/^git:x:1000:1000:/gitea:x:1000:1000:/' /etc/passwd \
&& sed -i 's/^git:x:1000:/gitea:x:1000:/' /etc/group \
&& grep -q '^gitea:x:1000:1000:' /etc/passwd \
&& grep -q '^gitea:x:1000:' /etc/group
USER 1000:1000

View File

@@ -21,6 +21,8 @@ HOST_KEYS = (
) )
SERVER_SETTINGS = { SERVER_SETTINGS = {
"START_SSH_SERVER": "true", "START_SSH_SERVER": "true",
"BUILTIN_SSH_SERVER_USER": "git",
"SSH_USER": "git",
"SSH_PORT": "2222", "SSH_PORT": "2222",
"SSH_LISTEN_PORT": "2222", "SSH_LISTEN_PORT": "2222",
"SSH_SERVER_HOST_KEYS": ", ".join( "SSH_SERVER_HOST_KEYS": ", ".join(
@@ -52,6 +54,7 @@ def convert_config(config):
section = "" section = ""
server_seen = set() server_seen = set()
server_found = False server_found = False
run_user_seen = False
def append_missing_server_settings(): def append_missing_server_settings():
for key, value in SERVER_SETTINGS.items(): for key, value in SERVER_SETTINGS.items():
@@ -61,6 +64,9 @@ def convert_config(config):
for line in original.splitlines(keepends=True): for line in original.splitlines(keepends=True):
match = re.match(r"^\s*\[([^]]+)\]\s*$", line) match = re.match(r"^\s*\[([^]]+)\]\s*$", line)
if match: if match:
if not run_user_seen:
output.append("RUN_USER = gitea\n")
run_user_seen = True
if section == "server": if section == "server":
append_missing_server_settings() append_missing_server_settings()
section = match.group(1).lower() section = match.group(1).lower()
@@ -68,7 +74,10 @@ def convert_config(config):
output.append(line) output.append(line)
continue continue
setting = re.match(r"^(\s*)([A-Z_]+)(\s*=\s*)(.*?)(\r?\n?)$", line) setting = re.match(r"^(\s*)([A-Z_]+)(\s*=\s*)(.*?)(\r?\n?)$", line)
if setting and section == "server" and setting.group(2) in SERVER_SETTINGS: 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) key = setting.group(2)
server_seen.add(key) server_seen.add(key)
line = f"{setting.group(1)}{key}{setting.group(3)}{SERVER_SETTINGS[key]}{setting.group(5)}" line = f"{setting.group(1)}{key}{setting.group(3)}{SERVER_SETTINGS[key]}{setting.group(5)}"
@@ -180,8 +189,8 @@ def main():
raise ValueError("Refusing backup outside the Atlas Prometheus snapshots") raise ValueError("Refusing backup outside the Atlas Prometheus snapshots")
if str(target) != "/zpool/services/data/gitea": if str(target) != "/zpool/services/data/gitea":
raise ValueError("Refusing target outside the dedicated Gitea dataset") raise ValueError("Refusing target outside the dedicated Gitea dataset")
if args.uid != 1101 or args.gid != 1101: if args.uid != 1000 or args.gid != 1000:
raise ValueError("Unexpected dedicated Gitea account IDs") raise ValueError("Unexpected admin-owned Gitea account IDs")
expected = expected_digest(backup) expected = expected_digest(backup)
if sha256(backup / "payload.tar") != expected: if sha256(backup / "payload.tar") != expected:
raise ValueError("Prometheus backup SHA-256 mismatch") raise ValueError("Prometheus backup SHA-256 mismatch")

View File

@@ -9,16 +9,18 @@
- atlas_manage_storage | bool - atlas_manage_storage | bool
- 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_uid | int != atlas_admin_uid | int - atlas_gitea_username == atlas_admin_username
- atlas_gitea_uid | int != atlas_immich_uid | int - atlas_gitea_group == atlas_admin_group
- atlas_gitea_gid | int != atlas_admin_gid | int - atlas_gitea_uid | int == atlas_admin_uid | int
- atlas_gitea_gid | int != atlas_immich_gid | int - atlas_gitea_gid | int == atlas_admin_gid | int
- atlas_gitea_container_uid | int == 1000
- atlas_gitea_container_gid | int == 1000
- 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_manage_firewall | bool
- not (atlas_gitea_production_enabled | bool) or atlas_gitea_bind_address == ansible_host - 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 requires Atlas storage, the admin user manager, the
identity and dataset, and loopback-only staging ports. dedicated dataset, and loopback-only staging ports.
- name: Inspect the final-restore marker before production activation - name: Inspect the final-restore marker before production activation
ansible.builtin.stat: ansible.builtin.stat:
@@ -33,43 +35,32 @@
fail_msg: Restore the final stopped-source Gitea export before enabling production. fail_msg: Restore the final stopped-source Gitea export before enabling production.
when: atlas_gitea_production_enabled | bool when: atlas_gitea_production_enabled | bool
- name: Create the dedicated Gitea group - name: Verify the production Gitea dataset belongs to admin
ansible.builtin.group: ansible.builtin.stat:
name: "{{ atlas_gitea_group }}" path: "{{ atlas_gitea_mountpoint }}"
gid: "{{ atlas_gitea_gid }}" register: atlas_gitea_dataset_owner
system: true when: atlas_gitea_production_enabled | bool
state: present
- name: Create the non-login Gitea service account - name: Refuse to overlap the legacy host-account service
ansible.builtin.user: ansible.builtin.assert:
name: "{{ atlas_gitea_username }}" that:
uid: "{{ atlas_gitea_uid }}" - atlas_gitea_dataset_owner.stat.uid | int == atlas_admin_uid | int
group: "{{ atlas_gitea_group }}" - atlas_gitea_dataset_owner.stat.gid | int == atlas_admin_gid | int
home: "{{ atlas_gitea_home }}" fail_msg: >-
shell: /sbin/nologin Run the explicit Gitea owner migration before enabling the admin
create_home: true Quadlet; never chown an active legacy service in a normal run.
system: true when: atlas_gitea_production_enabled | bool
state: present
- name: Restrict the Gitea service home - name: Remove the retired account's parent-dataset traverse ACL
ansible.builtin.file: ansible.posix.acl:
path: "{{ atlas_gitea_home }}"
state: directory
owner: "{{ atlas_gitea_username }}"
group: "{{ atlas_gitea_group }}"
mode: "0700"
- name: Reserve dedicated rootless UID and GID ranges for Gitea
ansible.builtin.lineinfile:
path: "{{ item }}" path: "{{ item }}"
regexp: '^{{ atlas_gitea_username }}:' etype: user
line: >- entity: "{{ atlas_gitea_legacy_username }}"
{{ atlas_gitea_username }}:{{ atlas_gitea_subid_start }}:{{ atlas_gitea_subid_count }} state: absent
create: false
mode: "0644"
loop: loop:
- /etc/subuid - "{{ atlas_services_mountpoint }}"
- /etc/subgid - "{{ atlas_app_data_mountpoint }}"
when: atlas_gitea_production_enabled | bool
- name: Enable POSIX ACLs only on the service-namespace parents - name: Enable POSIX ACLs only on the service-namespace parents
community.general.zfs: community.general.zfs:
@@ -81,17 +72,6 @@
- "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_services }}" - "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_services }}"
- "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_app_data }}" - "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_app_data }}"
- name: Permit Gitea to traverse only the application-data parents
ansible.posix.acl:
path: "{{ item }}"
entity: "{{ atlas_gitea_username }}"
etype: user
permissions: x
state: present
loop:
- "{{ atlas_services_mountpoint }}"
- "{{ atlas_app_data_mountpoint }}"
- name: Create the dedicated Gitea ZFS dataset - name: Create the dedicated Gitea ZFS dataset
community.general.zfs: community.general.zfs:
name: "{{ atlas_gitea_dataset }}" name: "{{ atlas_gitea_dataset }}"
@@ -115,7 +95,7 @@
- "{{ atlas_gitea_home }}/.config/containers" - "{{ atlas_gitea_home }}/.config/containers"
- "{{ atlas_gitea_quadlet_dir }}" - "{{ atlas_gitea_quadlet_dir }}"
- name: Enable lingering for the dedicated rootless account - name: Ensure lingering for the admin rootless account
ansible.builtin.command: ansible.builtin.command:
argv: argv:
- loginctl - loginctl
@@ -123,12 +103,15 @@
- "{{ atlas_gitea_username }}" - "{{ atlas_gitea_username }}"
creates: "/var/lib/systemd/linger/{{ atlas_gitea_username }}" creates: "/var/lib/systemd/linger/{{ atlas_gitea_username }}"
- name: Start the dedicated rootless user manager - name: Start the admin rootless user manager
ansible.builtin.systemd: ansible.builtin.systemd:
name: "user@{{ atlas_gitea_uid }}.service" name: "user@{{ atlas_gitea_uid }}.service"
state: started state: started
when: not ansible_check_mode when: not ansible_check_mode
- name: Prepare the admin-owned Gitea image
ansible.builtin.import_tasks: gitea_image.yml
- name: Render the 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

View File

@@ -0,0 +1,51 @@
---
- name: Create the admin-owned Gitea image build directory
ansible.builtin.file:
path: "{{ atlas_gitea_image_build_dir }}"
state: directory
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "0700"
- name: Install the pinned rootless Gitea Containerfile
ansible.builtin.copy:
src: Containerfile.gitea-rootless
dest: "{{ atlas_gitea_image_build_dir }}/Containerfile"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "0644"
- name: Check the admin-owned Gitea image
become_user: "{{ atlas_admin_username }}"
ansible.builtin.command:
argv: [podman, image, exists, "{{ atlas_gitea_image }}"]
args:
chdir: "{{ atlas_gitea_image_build_dir }}"
environment:
HOME: "{{ atlas_admin_home }}"
XDG_RUNTIME_DIR: "/run/user/{{ atlas_admin_uid }}"
register: atlas_gitea_image_present
changed_when: false
failed_when: false
check_mode: false
- name: Build the pinned Gitea image with the internal gitea identity
become_user: "{{ atlas_admin_username }}"
ansible.builtin.command:
argv:
- podman
- build
- --pull=always
- --tag
- "{{ atlas_gitea_image }}"
- --file
- Containerfile
- .
args:
chdir: "{{ atlas_gitea_image_build_dir }}"
environment:
HOME: "{{ atlas_admin_home }}"
XDG_RUNTIME_DIR: "/run/user/{{ atlas_admin_uid }}"
when:
- atlas_gitea_image_present.rc != 0
- not ansible_check_mode

View File

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

View File

@@ -14,6 +14,9 @@
- 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

View File

@@ -6,7 +6,7 @@ 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={{ atlas_gitea_container_uid }},gid={{ atlas_gitea_container_gid }}
{% if atlas_gitea_production_enabled | bool %} {% if atlas_gitea_production_enabled | bool %}
PublishPort={{ atlas_gitea_bind_address }}:{{ atlas_gitea_http_port }}:3000 PublishPort={{ atlas_gitea_bind_address }}:{{ atlas_gitea_http_port }}:3000
PublishPort={{ atlas_gitea_bind_address }}:{{ atlas_gitea_ssh_port }}:2222 PublishPort={{ atlas_gitea_bind_address }}:{{ atlas_gitea_ssh_port }}:2222

View File

@@ -24,8 +24,8 @@ Prometheus data, but do not restart its stale Gitea after Atlas accepts writes.
Hosts' effective upstream 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 - The initial staging ran as a **rootless user Quadlet** under a dedicated,
account, using the pinned `1.25.2-rootless` image. This is an explicit non-login Atlas account, using the pinned `1.25.2-rootless` image. This was an explicit
rootful-to-rootless **data-layout conversion**, not a drop-in image swap: rootful-to-rootless **data-layout conversion**, not a drop-in image swap:
the target mounts `/var/lib/gitea` and `/etc/gitea`, and uses Gitea's the target mounts `/var/lib/gitea` and `/etc/gitea`, and uses Gitea's
built-in SSH server instead of the source image's OpenSSH daemon. Keep the built-in SSH server instead of the source image's OpenSSH daemon. Keep the
@@ -151,6 +151,34 @@ snapshot at `20261002T073032Z` and encrypted Borg archive
`atlas-20261002T073044Z` captured the Atlas target after cutover; Borg exited `atlas-20261002T073044Z` captured the Atlas target after cutover; Borg exited
successfully, cleaned its temporary snapshot, and the pool was healthy. successfully, cleaned its temporary snapshot, and the pool was healthy.
## Corrected Atlas service owner (2026-10-02)
The operator required the host Quadlet to belong to `admin`, while the Unix
user **inside** the container must be named `gitea`. The pinned derived
`Containerfile.gitea-rootless` changes only the base image's UID/GID 1000
passwd/group names from `git` to `gitea`; it retains the rootless image's
paths and entrypoint. Gitea's `RUN_USER` is `gitea`, while its built-in SSH
user and advertised clone user remain `git`, preserving `git@` URLs. The
selective restore helper now generates the same three settings for any future
explicit restore, instead of recreating a `RUN_USER = git` target.
A disposable, loopback-only container using a copy of a Gitea ZFS snapshot
passed HTTP, SQLite, internal-user and SSH host-key checks without touching
live data. After explicit outage approval, the opt-in
`--tags gitea_owner_migration -e atlas_gitea_owner_migration=true` run stopped
the old user service, took safety snapshot
`zpool/services/data/gitea@gitea-owner-migration-20261002T100104`, transferred
only the Gitea dataset to `admin`, tested an `admin` staging Quadlet on
loopback, then promoted it to the production LAN ports. The old Atlas Quadlet
was removed. The old host `gitea` account and its sub-ID range are retained
for a deliberate rollback; they must not restart stale Gitea. The parent
traverse ACL is removed by the normal Gitea role once the new owner is live.
The new service returned HTTP 200 locally and through public primary HTTPS;
Navidrome and Syncthing remained active under `admin`, the pool was healthy,
and a second normal Gitea Ansible run was idempotent. This does **not** close
the separate external TCP/2222 or authenticated clone/push validation gap.
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