From 256d758b1afa21fde4c2a2e0d5b08d72eed7c44f Mon Sep 17 00:00:00 2001 From: Fabio Scotto di Santolo Date: Thu, 1 Oct 2026 21:23:40 +0200 Subject: [PATCH] Prepare isolated rootless Gitea Quadlet on Atlas --- AGENTS.md | 12 +- README.it.md | 8 +- README.md | 8 +- ansible/inventory/host_vars/atlas.yml | 5 + ansible/roles/profile_atlas/defaults/main.yml | 17 +++ ansible/roles/profile_atlas/tasks/gitea.yml | 133 ++++++++++++++++++ ansible/roles/profile_atlas/tasks/main.yml | 3 + .../templates/atlas-gitea.container.j2 | 20 +++ docs/atlas-gitea-migration.md | 9 ++ 9 files changed, 206 insertions(+), 9 deletions(-) create mode 100644 ansible/roles/profile_atlas/tasks/gitea.yml create mode 100644 ansible/roles/profile_atlas/templates/atlas-gitea.container.j2 diff --git a/AGENTS.md b/AGENTS.md index b61942e..2cfcf91 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,6 +57,8 @@ Ansible-driven personal infrastructure repo for Fedora and Void desktops, Fedora - Server compose render: `podman-compose -f /opt/docker/server/docker-compose.yml config` and `systemctl status podman-compose-server` - Atlas media stack: `ansible-playbook ansible/site.yml --limit atlas --tags storage,sharing,containers --check --diff` + - Atlas rootless Gitea staging (does not start Gitea): + `ansible-playbook ansible/site.yml --limit atlas --tags gitea --check --diff` - Atlas network/share hardening: `ansible-playbook ansible/site.yml --limit atlas --tags hardening,sharing --check --diff` - Atlas ZFS snapshot retention and scrub timers: @@ -263,9 +265,13 @@ successfully. The first monthly scrub remains a runtime check. - [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; Gitea must run as a dedicated rootless user Quadlet on Atlas. The rootful-to-rootless data-layout - conversion requires an isolated restore test. No data has been moved or traffic changed. -- [ ] Prepare a separate Atlas Gitea dataset, disabled rootless user Quadlet, and isolated restore test from the - verified Prometheus backup; validate SQLite, repositories, SSH host keys, and target backups. + conversion requires an isolated restore test. No Gitea data has been moved or traffic changed. +- [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 + run passed; the generated unit was inactive, with no staging HTTP/SSH listener. POSIX ACLs on only the + service-namespace parents grant this account traversal without access to sibling datasets. +- [ ] Perform an isolated rootless restore test from the verified Prometheus backup; validate SQLite, + repositories, SSH host keys, and target backups before any traffic 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. - [ ] Design and deploy Nextcloud as another explicitly temporary Atlas service before Uranus. Give it diff --git a/README.it.md b/README.it.md index 21753da..e987874 100644 --- a/README.it.md +++ b/README.it.md @@ -322,9 +322,11 @@ 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 ricarica le reti Podman rootful di Prometheus per conservare DNS e connettività del proxy. -La migrazione Gitea da Prometheus ad Atlas è pianificata, ma non ancora eseguita, in -[`docs/atlas-gitea-migration.md`](docs/atlas-gitea-migration.md). NPM resta su Prometheus; -stack sorgente e instradamento pubblico rimangono invariati fino a un cutover separato e validato. +La migrazione Gitea da Prometheus ad Atlas è predisposta in +[`docs/atlas-gitea-migration.md`](docs/atlas-gitea-migration.md). Atlas ha un dataset e un account +dedicati con Quadlet utente rootless inattivo. I dati Gitea non sono ancora stati ripristinati o spostati. +NPM resta su Prometheus; stack sorgente e instradamento pubblico rimangono invariati fino a un cutover +HTTPS e SSH separato e validato. Validare il gateway con: diff --git a/README.md b/README.md index 4298f7f..ba6228b 100644 --- a/README.md +++ b/README.md @@ -298,9 +298,11 @@ and Aegis (`10.0.0.2`). Their state is initialized ex novo in `/zpool/services/d `/zpool/services/data/syncthing`; no source application state is migrated. The music library at `/zpool/media/music` is populated separately. -The staged, not-yet-executed Gitea move from Prometheus to Atlas is described in -[`docs/atlas-gitea-migration.md`](docs/atlas-gitea-migration.md). NPM remains on Prometheus; -the source stack and public routes stay unchanged until a separately validated cutover. +The Gitea move from Prometheus to Atlas is staged in +[`docs/atlas-gitea-migration.md`](docs/atlas-gitea-migration.md). Atlas has a dedicated dataset and +non-login account with an inactive rootless user Quadlet. No Gitea data has been restored or moved yet. +NPM remains on Prometheus; the source stack and public routes stay unchanged until a separately +validated HTTPS and SSH cutover. The separate `wireguard_overlay` role manages `wg0` between Prometheus (`10.0.0.1`) and Aegis (`10.0.0.2`), generating private keys once on their respective hosts and exchanging only public keys diff --git a/ansible/inventory/host_vars/atlas.yml b/ansible/inventory/host_vars/atlas.yml index 1e06140..58f6805 100644 --- a/ansible/inventory/host_vars/atlas.yml +++ b/ansible/inventory/host_vars/atlas.yml @@ -49,6 +49,11 @@ atlas_zfs_backup_reservation: 500G atlas_zfs_dataset_photobook: media/photobook atlas_mount_root: /zpool atlas_manage_storage: true +# Prepare only the isolated rootless Gitea target; no restore, start, or cutover. +atlas_manage_gitea: 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_manage_zfs_snapshots: true atlas_zfs_snapshot_prefix: atlas-auto diff --git a/ansible/roles/profile_atlas/defaults/main.yml b/ansible/roles/profile_atlas/defaults/main.yml index 15f0208..96397fd 100644 --- a/ansible/roles/profile_atlas/defaults/main.yml +++ b/ansible/roles/profile_atlas/defaults/main.yml @@ -165,6 +165,23 @@ atlas_prometheus_pull_keep_monthly: 12 atlas_prometheus_pull_max_age_hours: 24 atlas_photobook_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_photobook }}" +# Staged rootless Gitea target. Preparation never starts the user Quadlet or opens ingress. +atlas_manage_gitea: false +atlas_gitea_username: gitea +atlas_gitea_group: gitea +atlas_gitea_uid: 1101 +atlas_gitea_gid: 1101 +atlas_gitea_subid_start: 165536 +atlas_gitea_subid_count: 65536 +atlas_gitea_home: /var/lib/atlas-gitea +atlas_gitea_dataset: "{{ atlas_zfs_pool }}/services/data/gitea" +atlas_gitea_mountpoint: "{{ atlas_app_data_mountpoint }}/gitea" +atlas_gitea_quadlet_dir: "{{ atlas_gitea_home }}/.config/containers/systemd" +atlas_gitea_image: docker.gitea.com/gitea:1.25.2-rootless +atlas_gitea_staging_bind_address: 127.0.0.1 +atlas_gitea_staging_http_port: 3001 +atlas_gitea_staging_ssh_port: 2223 + atlas_45drives_repo_url: https://repo.45drives.com/repofiles/rocky/45drives-enterprise.repo atlas_45drives_repo_file: /etc/yum.repos.d/45drives-enterprise.repo atlas_45drives_packages: diff --git a/ansible/roles/profile_atlas/tasks/gitea.yml b/ansible/roles/profile_atlas/tasks/gitea.yml new file mode 100644 index 0000000..5df4e88 --- /dev/null +++ b/ansible/roles/profile_atlas/tasks/gitea.yml @@ -0,0 +1,133 @@ +--- +- name: Prepare the isolated rootless Atlas Gitea target + tags: [atlas, gitea] + when: atlas_manage_gitea | bool + block: + - name: Require the existing Atlas application-data dataset + ansible.builtin.assert: + that: + - atlas_manage_storage | bool + - atlas_gitea_dataset == atlas_zfs_pool ~ '/services/data/gitea' + - atlas_gitea_mountpoint == atlas_app_data_mountpoint ~ '/gitea' + - atlas_gitea_uid | int != atlas_admin_uid | int + - atlas_gitea_uid | int != atlas_immich_uid | int + - atlas_gitea_gid | int != atlas_admin_gid | int + - atlas_gitea_gid | int != atlas_immich_gid | int + - atlas_gitea_staging_bind_address == '127.0.0.1' + fail_msg: >- + Rootless Gitea preparation requires Atlas storage, an isolated service + identity and dataset, and loopback-only staging ports. + + - name: Create the dedicated Gitea group + ansible.builtin.group: + name: "{{ atlas_gitea_group }}" + gid: "{{ atlas_gitea_gid }}" + system: true + state: present + + - name: Create the non-login Gitea service account + ansible.builtin.user: + name: "{{ atlas_gitea_username }}" + uid: "{{ atlas_gitea_uid }}" + group: "{{ atlas_gitea_group }}" + home: "{{ atlas_gitea_home }}" + shell: /sbin/nologin + create_home: true + system: true + state: present + + - name: Restrict the Gitea service home + ansible.builtin.file: + 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 }}" + regexp: '^{{ atlas_gitea_username }}:' + line: >- + {{ atlas_gitea_username }}:{{ atlas_gitea_subid_start }}:{{ atlas_gitea_subid_count }} + create: false + mode: "0644" + loop: + - /etc/subuid + - /etc/subgid + + - name: Enable POSIX ACLs only on the service-namespace parents + community.general.zfs: + name: "{{ item }}" + state: present + extra_zfs_properties: + acltype: posix + loop: + - "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_services }}" + - "{{ 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 + community.general.zfs: + name: "{{ atlas_gitea_dataset }}" + state: present + extra_zfs_properties: + compression: zstd + mountpoint: "{{ atlas_gitea_mountpoint }}" + + - name: Restrict the Gitea dataset and create rootless volume paths + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: "{{ atlas_gitea_username }}" + group: "{{ atlas_gitea_group }}" + mode: "0700" + loop: + - "{{ atlas_gitea_mountpoint }}" + - "{{ atlas_gitea_mountpoint }}/data" + - "{{ atlas_gitea_mountpoint }}/config" + - "{{ atlas_gitea_home }}/.config" + - "{{ atlas_gitea_home }}/.config/containers" + - "{{ atlas_gitea_quadlet_dir }}" + + - name: Enable lingering for the dedicated rootless account + ansible.builtin.command: + argv: + - loginctl + - enable-linger + - "{{ atlas_gitea_username }}" + creates: "/var/lib/systemd/linger/{{ atlas_gitea_username }}" + + - name: Start the dedicated rootless user manager + ansible.builtin.systemd: + name: "user@{{ atlas_gitea_uid }}.service" + state: started + when: not ansible_check_mode + + - name: Render the disabled rootless Gitea Quadlet + ansible.builtin.template: + src: atlas-gitea.container.j2 + dest: "{{ atlas_gitea_quadlet_dir }}/atlas-gitea.container" + owner: "{{ atlas_gitea_username }}" + group: "{{ atlas_gitea_group }}" + mode: "0644" + + - name: Reload the rootless Gitea user manager without starting Gitea + become_user: "{{ atlas_gitea_username }}" + ansible.builtin.systemd: + scope: user + daemon_reload: true + environment: + XDG_RUNTIME_DIR: "/run/user/{{ atlas_gitea_uid }}" + DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ atlas_gitea_uid }}/bus" + when: not ansible_check_mode diff --git a/ansible/roles/profile_atlas/tasks/main.yml b/ansible/roles/profile_atlas/tasks/main.yml index 2403c66..51dae83 100644 --- a/ansible/roles/profile_atlas/tasks/main.yml +++ b/ansible/roles/profile_atlas/tasks/main.yml @@ -14,6 +14,9 @@ - name: Import Atlas storage tasks ansible.builtin.import_tasks: storage.yml +- name: Import staged Atlas rootless Gitea tasks + ansible.builtin.import_tasks: gitea.yml + - name: Import Atlas ZFS maintenance tasks ansible.builtin.import_tasks: zfs_maintenance.yml diff --git a/ansible/roles/profile_atlas/templates/atlas-gitea.container.j2 b/ansible/roles/profile_atlas/templates/atlas-gitea.container.j2 new file mode 100644 index 0000000..582b542 --- /dev/null +++ b/ansible/roles/profile_atlas/templates/atlas-gitea.container.j2 @@ -0,0 +1,20 @@ +# Managed by Ansible. Staging only: no [Install], no automatic start. +[Unit] +Description=Atlas rootless Gitea staging target +RequiresMountsFor={{ atlas_gitea_mountpoint }} + +[Container] +ContainerName=atlas-gitea +Image={{ atlas_gitea_image }} +UserNS=keep-id:uid=1000,gid=1000 +PublishPort={{ atlas_gitea_staging_bind_address }}:{{ atlas_gitea_staging_http_port }}:3000 +PublishPort={{ atlas_gitea_staging_bind_address }}:{{ atlas_gitea_staging_ssh_port }}:2222 +Volume={{ atlas_gitea_mountpoint }}/data:/var/lib/gitea:Z +Volume={{ atlas_gitea_mountpoint }}/config:/etc/gitea:Z +NoNewPrivileges=true +DropCapability=all + +[Service] +Restart=on-failure +RestartSec=10 +TimeoutStartSec=900 diff --git a/docs/atlas-gitea-migration.md b/docs/atlas-gitea-migration.md index 7196201..8fc5061 100644 --- a/docs/atlas-gitea-migration.md +++ b/docs/atlas-gitea-migration.md @@ -35,6 +35,15 @@ Uranus; NPM remains on Prometheus. ## Phase 1: prepare without traffic changes +Preparation completed on 2026-10-01: Ansible created +`zpool/services/data/gitea`, a dedicated non-login `gitea` account (UID/GID +1101), separate subordinate IDs, parent-dataset traverse ACLs, and an inactive +user Quadlet under `/var/lib/atlas-gitea/.config/containers/systemd/`. The +Quadlet has no `[Install]` section and, until the final cutover, binds only +loopback staging ports 3001/2223 if started manually. A second targeted +Ansible run changed nothing; the generated service was inactive and neither +staging port listened. **No Gitea payload has been restored to the target.** + 1. Provision a dedicated target dataset and non-login service identity via Ansible, keeping UID/GID distinct from Atlas' reserved Immich `1100`. Install the user Quadlet in that identity's