Cut the comment bloat from the Podman playbooks

Comment blocks now stay within three lines and explain only the non-obvious. Long histories, include examples, and commented-out code are gone. No task, key, or value changed; the only data change is comment text inside the generated unit files.
This commit is contained in:
tanshu committed 2026-10-18 22:10:57 +05:30
1 parent 2aab1aec1e
commit 476a6d7cd0
85 files changed
+372 -2433

No files matched your search

-15
View File
@@ -1,6 +1,3 @@
#################################################
# DO Community Playbooks: Docker
#################################################
---
- hosts: all
become: true
@@ -49,15 +46,3 @@
- /var/lib/gitea:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
# - name: Sets Nginx conf file for gitea
# template:
# src: "files/nginx.conf.j2"
# dest: "/etc/nginx/sites-available/{{ http_conf }}"
# - name: Enables new site
# file:
# src: "/etc/nginx/sites-available/{{ http_conf }}"
# dest: "/etc/nginx/sites-enabled/{{ http_conf }}"
# state: link
# notify: Reload Nginx
+2 -42
View File
@@ -1,33 +1,5 @@
---
# Gitea as a rootless Podman Quadlet unit on emily, behind the shared Caddy
# unit (caddy/playbook.yml), with git-over-SSH still arriving on the host's own
# port 22 so that no client has to change its remote.
#
# Connects as the container user and runs everything as that user: the Quadlet
# units belong to it, so the connection's PAM session is what makes
# `systemctl --user` work without any environment juggling.
#
# gather_facts is required: podman_user and the home directory come from the
# user_id / user_dir facts.
#
# The rootless-Podman prerequisites (installing podman, enabling lingering, the
# low-port sysctl, creating the Quadlet directory) are deliberately NOT applied
# here. They are the shared task file at caddy/roles/podman/tasks/main.yaml, and
# a host that has already run caddy/playbook.yml has them:
#
# - name: Rootless Podman prerequisites
# ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/podman/tasks/main.yaml"
#
# The variables this play needs are duplicated at the top of vars/default.yml, so
# it stands on its own. (Applying the commented include above would also need
# podman_unprivileged_port_start, which lives in caddy/vars/default.yml.)
#
# Unlike bitwarden/ and tandoor/ there is no postgres role here: this Gitea runs
# on SQLite, which monoco's live app.ini confirms (`DB_TYPE = sqlite3`,
# `PATH = /data/gitea/gitea.db`). The unused Docker-era Postgres variant that
# used to sit beside this file was removed for the same reason.
#
# Run with: ansible-playbook -u tanshu gitea/playbook.yml
# Requires caddy to have run first. Gitea uses SQLite, not Postgres.
- name: Deploy Gitea as a rootless Podman Quadlet
hosts: emily
gather_facts: true
@@ -35,23 +7,11 @@
- vars/default.yml
pre_tasks:
# This must happen before the app unit is started, not after: the generated
# unit references {{ network_name }}.network, and Quadlet fails *unit
# generation* (so `systemctl --user start` reports "unit not found") when a
# referenced .network file does not exist yet. The same include writes the
# Caddy-side drop-in that attaches Caddy to this network.
#
# network_name is a play var, so the include can see it directly.
# Quadlet needs the network unit before it generates the app unit.
- name: Register Gitea's network with Caddy
ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml"
roles:
# The host half of git-over-SSH: the `git` login user, the passthrough shim,
# the authorized_keys hook, the sshd drop-in and the one-command sudoers
# rule. Root-only steps elevate individually — see the role for why.
- ssh
# The app: the data directory tree the image's own root phase would leave
# subuid-owned, the Quadlet unit, start + verify.
- gitea
# Appends this site's block to the shared Caddyfile and reloads Caddy.
- caddy
+4 -17
View File
@@ -1,27 +1,14 @@
---
# Caddy is its own playbook (caddy/playbook.yml) and a rootless Quadlet unit
# there. These handlers run as the connection user, so `systemctl --user` and
# `podman exec` work without any XDG_RUNTIME_DIR juggling.
# Notified by this role when the Caddyfile changes. A reload keeps existing
# connections; a restart would briefly drop every hosted site.
#
# Note the config path: /etc/caddy/Caddyfile is the mount inside the container,
# not {{ caddyfile_path }}, which is the host side of it.
# A reload keeps existing connections. A restart would briefly drop every hosted site.
# The config path is inside the container, not caddyfile_path on the host.
- name: Reload Caddy configuration
ansible.builtin.command: >-
podman exec {{ caddy_name }} caddy reload --config /etc/caddy/Caddyfile
changed_when: true
listen: "Reload Caddy"
# Notified by caddy/roles/register when this service's network unit, or the
# drop-in that attaches Caddy to it, is written. Quadlet has no live equivalent
# of `docker network connect`, so the container has to be recreated — a brief
# restart of every hosted site, not just this one. See caddy/readme.md,
# "Trade-off worth knowing".
#
# Defined after the reload above so that when both are notified the cheap config
# reload happens first.
# Quadlet has no live equivalent of network connect, so Caddy is recreated and every hosted site restarts.
# Defined after the reload so the cheap reload runs first.
- name: Restart Caddy
ansible.builtin.systemd_service:
name: "{{ caddy_name }}.service"
-11
View File
@@ -1,15 +1,4 @@
---
# Append this site's block to the shared Caddyfile.
#
# The Caddyfile is a bind mount into the Caddy container, so Caddy sees the
# change as soon as the file is written; the handler only asks Caddy to re-read
# it. That is a reload, not a restart, so the other hosted sites are untouched.
#
# blockinfile is idempotent on its own via the marker, so there is no need to
# slurp the file and compare first — which also matters because slurp *fails*
# when the Caddyfile does not exist yet, on a host where the Caddy playbook has
# not run.
- name: "Add the {{ host }} site block to the Caddyfile"
ansible.builtin.blockinfile:
path: "{{ caddyfile_path }}"
+11 -87
View File
@@ -1,33 +1,5 @@
---
# Gitea as a rootless Podman Quadlet unit.
#
# Everything here runs as {{ podman_user }} — the connection user. No become, no
# XDG_RUNTIME_DIR: the SSH connection gave us a real PAM session, so systemd's
# user manager is reachable through the standard environment.
# The data tree is created here rather than left to the image, and that is not
# housekeeping. With keep-id:uid={{ gitea_uid }} the container process runs as
# `git`, so /usr/bin/entrypoint is the *only* part of the image's start-up that
# root can do — and it needs root for /etc/passwd, /etc/s6 and this tree.
#
# Container root is a subuid on the host, but inside its user namespace it holds
# the usual capabilities, so `mkdir`, `chmod` and `chown` all succeed; what it
# creates is simply left owned by that subuid. The image chowns the directories it
# knows about back to `git` — `chown -R ${USER}:git /data/gitea` and `/data/git`
# in /etc/s6/gitea/setup, which is where app.ini comes from — so those work either
# way. The things it does *not* chown are what this task is for, and they are the
# ones that would otherwise be subuid-owned and invisible to the app and to a
# plain tar backup:
#
# gitea/ app.ini, gitea.db, sessions, avatars, indexers
# gitea/conf where the entrypoint would generate app.ini
# gitea/log Gitea's file log
# git/ repositories, LFS, and the git account's HOME
# git/.ssh authorized_keys — written by Gitea, read by the host's sshd
# ssh/ only ever a *mount point* — see below
#
# The sshd *inside* the image is the exception; it cannot use this tree at all.
# See {{ container_sshd_hostkeys }} below.
# Create the tree as podman_user: anything left to the image stays subuid-owned and misses a plain tar backup.
- name: Ensure the Gitea data directory exists
ansible.builtin.file:
path: "{{ host_directory }}"
@@ -48,27 +20,12 @@
- { path: gitea/conf, mode: '0755' }
- { path: gitea/log, mode: '0755' }
- { path: git, mode: '0755' }
# 0700 because that is what the image's own setup script chmods it to
# (`chmod 700 /data/git/.ssh` in /etc/s6/gitea/setup, which runs as root
# before `su-exec git`). Set here so the mode never depends on that script
# having run.
# The image's setup chmods this to 0700; setting it here does not depend on that script.
- { path: git/.ssh, mode: '0700' }
# The in-image sshd's host keys are mounted over /data/ssh from
# {{ container_sshd_hostkeys }}, so this directory is never written to — it
# has to *exist*, because it is the mount point. If it does not, Podman
# creates it during container set-up, inside the container's user namespace,
# and it lands here owned by the subuid rather than by {{ podman_user }}:
#
# drwxr-xr-t 2 165536 165536 4096 ssh
#
# which the check at the end of this role then (correctly) fails on. Creating
# it first is what keeps the data tree wholly {{ podman_user }}'s.
# This is a mount point, so it must exist; letting Podman create it leaves it subuid-owned and fails the owner check.
- { path: ssh, mode: '0755' }
# The image's setup creates this file if it is missing, as root — and it is the
# one file under /data the setup never chowns back to `git`, so on a fresh host it
# would be left subuid-owned. Writing it here, as {{ podman_user }} and 0600,
# both avoids that and states the value the image's own environment carries.
# The image would create this as root and never chown it back, leaving it subuid-owned.
- name: Write the container-side .ssh/environment file
ansible.builtin.copy:
dest: "{{ host_directory }}/git/.ssh/environment"
@@ -78,16 +35,7 @@
group: "{{ podman_user }}"
mode: '0600'
# The image always starts an sshd of its own inside the container, and that sshd
# wants host keys. This deployment does not use it — git-over-SSH arrives on the
# host's sshd on port 22 — but it has to have somewhere to write, and the process
# that runs it maps to a subuid that cannot touch the data directory. Keeping it
# out here means no subuid-owned file ever lands in {{ host_directory }}, so a
# plain tar/rsync of the data tree stays complete and error-free, and the check
# at the end of this role stays meaningful.
#
# 0777 is deliberate and bounded: /home/{{ podman_user }} is 0750, so no other
# host user can reach this path at all. The mode only matters inside the userns.
# The in-image sshd runs as a subuid that cannot touch the data directory, and 0777 is safe because the home directory is 0750.
- name: Ensure the scratch host key directory exists
ansible.builtin.file:
path: "{{ container_sshd_hostkeys }}"
@@ -107,16 +55,7 @@
- Reload systemd user units
- Restart Gitea
# Quadlet honours [Install] WantedBy= when it generates the unit, so
# `systemctl --user enable` is neither needed nor possible on a generated unit.
#
# All three steps are in one block because they fail the same way and want the
# same evidence. `systemctl start` reports success as soon as it has handed the
# unit to systemd, so a container that dies a second later — a `User=0` missing,
# an unwritable /data, a subuid that cannot run the image's start-up — surfaces
# only as `activating` from `is-active`, or as a health check that never passes,
# with the reason in the journal and nowhere else. Restart=always makes that a
# crash loop rather than an error.
# Quadlet honours [Install] WantedBy=, so the generated unit is never enabled.
- name: Start and verify Gitea
block:
- name: Start Gitea
@@ -126,9 +65,7 @@
state: started
daemon_reload: true
# Retried rather than checked once: podman reports `activating` for the
# interval between systemd starting the container and its readiness
# notification.
# Retried because podman reports `activating` between start and readiness.
- name: Verify Gitea is running
ansible.builtin.command: "systemctl --user is-active {{ unit_name }}"
register: gitea_active
@@ -137,9 +74,7 @@
delay: 5
changed_when: false
# `is-active` says the container is running; it does not say the app can
# serve. The image ships no HEALTHCHECK, so the unit defines one (HealthCmd=,
# see the template) and this runs exactly that command once, synchronously.
# The image ships no HEALTHCHECK, so the unit defines one and this runs it synchronously.
- name: Wait for Gitea to pass its health check
ansible.builtin.command: "podman healthcheck run {{ container_name }}"
register: gitea_health
@@ -148,10 +83,7 @@
delay: 5
changed_when: false
rescue:
# systemd reports "control process exited with error code" and nothing else,
# which is useless for the usual failures. The journal is where the image's
# own start-up output is — that is where a s6 or entrypoint permission
# failure shows up.
# systemd only reports "control process exited with error code", so read the journal for the real reason.
- name: Collect Gitea diagnostics
ansible.builtin.command: "{{ item }}"
loop:
@@ -170,13 +102,7 @@
ansible.builtin.fail:
msg: "Gitea did not start — see the diagnostics above."
# A wrong keep-id is the one failure here that does not look like one: Gitea
# still serves, but from files it owns as an unmapped subuid on the host — files
# that then disappear from a tar backup and cannot be deleted without
# `podman unshare`. Check the whole tree, not a sample.
#
# Deliberately not `failed_when: false`: if `find` cannot read something in the
# data directory, the tree is not provably clean, and that is the finding.
# A wrong keep-id still serves, but writes subuid-owned files a tar backup misses, so check the whole tree and let find fail.
- name: List anything in the data tree not owned by {{ podman_user }}
ansible.builtin.command: >-
find {{ host_directory }} ! -user {{ podman_user }}
@@ -198,9 +124,7 @@
path: "{{ host_directory }}/gitea/gitea.db"
register: gitea_db
# Not a failure: a fresh instance legitimately has no database yet. But on a
# migration it means the copy in readme.md has not run, and the instance that is
# now serving is an empty installer rather than this repository's Gitea.
# Not a failure on a fresh instance, but a migration whose copy never ran will serve an empty installer.
- name: Report a data directory with no database in it
ansible.builtin.debug:
msg: >-
+5 -49
View File
@@ -1,44 +1,11 @@
---
# The host half of Gitea's git-over-SSH support.
#
# Port 22 belongs to the host's sshd and stays that way: clients keep using
# git@git.tanshu.com:user/repo.git, and no remote URL changes. Four host-side
# pieces make that work:
#
# 1. a `{{ git_user }}` login user with no interactive access — every key
# Gitea accepts carries a forced command, so sshd runs the shim and
# nothing else;
# 2. {{ shim_path }}, the shim those forced commands invoke;
# 3. {{ podman_exec_wrapper }} plus a one-command sudoers rule — how the shim,
# running as `{{ git_user }}`, reaches a container owned by
# {{ podman_user }};
# 4. the sshd and sudoers drop-ins that tie the above together.
#
# What differs from the Docker-era role is the second hop. It used to be an ssh
# into an sshd running *inside* the container; under keep-id that sshd runs as a
# subuid and cannot read the authorized_keys Gitea writes as {{ podman_user }},
# so the hop is a `podman exec` instead. readme.md, "How git over SSH works
# now", is where the reasoning lives.
#
# Every task here is root-only (users, /usr/local/bin, /etc/ssh, /etc/sudoers),
# so each elevates on its own. The play carries no become, because the Quadlet
# units have to belong to the connection user.
# Git-over-SSH uses the host's sshd on port 22, and the shim reaches the container through a one-command sudoers rule.
- name: Ensure the git group exists
ansible.builtin.group:
name: "{{ git_user }}"
become: true
# The shell matters even though nobody ever gets one: sshd runs a forced command
# through the login user's shell (`/bin/bash -c '...'`), so a nologin shell would
# break git-over-SSH outright.
#
# The uid is deliberately left to the system. On monoco the host git account was
# uid 1003 because the container's git account was 1003 and the two shared one
# uid space; under keep-id the container's 1003 is mapped onto {{ podman_user }}
# instead, so nothing on this host depends on this number any more — not the
# key file (owned by name), not the sudoers rule (matched by name), not the
# authorized_keys hook (run as {{ podman_user }}).
# sshd runs the forced command through the login user's shell, so it must not be nologin; the uid is left to the system.
- name: Add the git login user
ansible.builtin.user:
name: "{{ git_user }}"
@@ -49,11 +16,6 @@
create_home: true
become: true
# There is deliberately no ~/.ssh for this user any more. Its old contents were
# the shim's client key and the authorized_keys Gitea maintains; the key is
# obsolete (nothing authenticates over ssh in this design) and the file lives in
# the container's data tree now, where Gitea writes it. The migration notes in
# readme.md move the file into place.
- name: Install the git-over-SSH passthrough shim
ansible.builtin.template:
@@ -82,9 +44,7 @@
mode: '0755'
become: true
# valid with `visudo -cf %s`: a syntax error in /etc/sudoers.d does not fail one
# rule, it fails *every* sudo invocation on the host, which on a server reached
# by ssh is a nasty way to find out.
# A syntax error in /etc/sudoers.d breaks every sudo on the host, so validate before installing.
- name: Let the git user reach the container as {{ podman_user }}
ansible.builtin.template:
src: sudoers-gitea.j2
@@ -107,10 +67,7 @@
register: gitea_sshd_dropin
become: true
# A drop-in that does not parse is not a failed play, it is a host nobody can
# log in to after the next reboot: sshd reads the whole directory at start-up.
# Validate the resulting configuration before it is ever loaded, and take the
# file away again if it does not parse.
# A drop-in that does not parse locks everyone out on the next reboot, so validate it and remove the file if it fails.
- name: Validate the resulting sshd configuration
ansible.builtin.command: /usr/sbin/sshd -t
changed_when: false
@@ -130,8 +87,7 @@
sshd is still running — and still restarting — on its previous
configuration. Run `/usr/sbin/sshd -t` on the host to see why.
# `reload`, not `restart`: a reload re-execs sshd without dropping the connection
# this play is running over.
# A reload re-execs sshd without dropping the connection this play runs over.
- name: Reload sshd
ansible.builtin.systemd_service:
name: "{{ sshd_service }}"
+15 -69
View File
@@ -1,114 +1,60 @@
---
# ---------------------------------------------------------------------------
# Rootless Podman / Quadlet
# ---------------------------------------------------------------------------
# Duplicated on purpose from caddy/vars/default.yml: a play cannot include that
# file wholesale, because it also sets `host_directory` — to the Caddy data
# directory — and every playbook defines that for itself. Keep these three in
# sync with caddy/vars/default.yml. (Its fourth, podman_unprivileged_port_start,
# belongs to the podman tasks, which this play does not run: Gitea publishes
# nothing host-side.)
#
# podman_user is derived from the connection: this play carries no become at
# play level, so the Quadlet units belong to whoever Ansible connects as.
# Requires gather_facts (set in playbook.yml).
# Duplicated from caddy/vars/default.yml on purpose, so keep them in sync.
# podman_user comes from the connection user, so the play needs gather_facts.
podman_user: "{{ ansible_facts['user_id'] }}"
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
# ---------------------------------------------------------------------------
# Gitea
# ---------------------------------------------------------------------------
host: git.tanshu.com
container_name: gitea
unit_name: "{{ container_name }}.service"
# Fully qualified on purpose: rootless Podman resolves short names against
# unqualified-search-registries, and there is no TTY to answer a prompt when
# the systemd unit starts.
# Fully qualified because rootless Podman would otherwise prompt for a registry, and a unit has no TTY.
image: docker.io/gitea/gitea:latest
# The Quadlet file is <network_name>.network. caddy/roles/register pins the
# Podman network name to <network_name>_net (so: gitea.network, gitea_net).
# caddy/roles/register pins the Podman network to <network_name>_net, so the Quadlet file is gitea.network.
network_name: "{{ container_name }}"
host_directory: "{{ podman_data_dir }}/{{ container_name }}"
# Port inside the container that Caddy proxies to — app.ini's HTTP_PORT. Not
# published on the host: Caddy reaches it by container name.
# The port Caddy proxies to inside the container, where app.ini's HTTP_PORT listens. Nothing is published on the host.
container_port: 3000
# The uid/gid of the `git` account the image creates for itself
# (git:x:1003:1003, verified in the image), and therefore the uid Gitea runs as.
# The number is load-bearing three times over:
#
# * UserNS in the Quadlet unit maps it onto podman_user, which is what keeps
# every file Gitea writes owned by podman_user on the host — plain tar and
# rsync backups, no subuids;
# * USER_UID/USER_GID force the entrypoint to keep it, so the file layout and
# the mapping cannot drift apart;
# * roles/ssh execs into the container as exactly this uid.
#
# Change one and the other two have to change with it.
# The uid/gid of the image's `git` account, and therefore the uid Gitea runs as.
# UserNS maps it onto podman_user, USER_UID/GID keep it, and roles/ssh execs as it, so change all three together.
gitea_uid: 1003
gitea_gid: 1003
# Where the image keeps its data *inside* the container, and therefore the
# GITEA_CUSTOM the image's own environment already sets.
# The image's data directory inside the container, and the GITEA_CUSTOM it already sets.
gitea_custom: /data/gitea
# Gitea's own unauthenticated health endpoint. The image ships no HEALTHCHECK,
# so the unit defines one from this and the play gates on it.
# The image ships no HEALTHCHECK, so the unit defines one from this endpoint and the play gates on it.
health_cmd: "curl -fsS -o /dev/null http://127.0.0.1:{{ container_port }}/api/healthz"
# Only log timestamps are affected: Gitea stores each user's own timezone. The
# Docker-era container bind-mounted the host's /etc/localtime and /etc/timezone
# instead; TZ is the same intent without depending on either file existing.
# Only log timestamps are affected: Gitea stores each user's own timezone.
timezone: Asia/Kolkata
# A scratch directory for the sshd *inside* the image, which this deployment
# does not use but which still has to write host keys somewhere. It is kept
# outside the data tree on purpose: the process that runs it is the container's
# root, which under keep-id is a subuid, and a subuid-owned file inside
# {{ host_directory }} would break both a plain tar backup and the play's
# "nothing in here belongs to another user" check. See readme.md.
# The in-image sshd needs host keys somewhere, but it runs as a subuid, so keep them outside the data tree.
container_sshd_hostkeys: "{{ podman_data_dir }}/{{ container_name }}-sshd"
# ---------------------------------------------------------------------------
# The git-over-SSH entry point on the host
# ---------------------------------------------------------------------------
# Clients keep using git@git.tanshu.com:user/repo.git on port 22, served by the
# *host's* sshd, exactly as they did on monoco. No sshd runs inside the
# container: under keep-id the container's root is a subuid that cannot read the
# authorized_keys Gitea writes as podman_user, so the second hop is a `podman
# exec` instead of an ssh. See readme.md, "How git over SSH works now".
#
# `git_home` only has to exist — sshd runs the forced command from it — and its
# uid is deliberately left to the system: nothing in this design pairs the host
# account with the container's uid. See roles/ssh/tasks/main.yaml.
# Clients keep using git@git.tanshu.com:user/repo.git on port 22, served by the host's sshd.
# `git_home` only has to exist, and its uid is left to the system.
git_user: git
git_home: /home/git
# The command Gitea writes into every authorized_keys line it maintains. This is
# a contract, not a knob: it is already baked into the lines sitting in the file
# copied from monoco, and renaming it locks every user out of git-over-SSH until
# Gitea rewrites them. See roles/ssh/templates/gitea.j2.
# Renaming this path locks users out because every authorized_keys line Gitea writes already contains it.
shim_path: /usr/local/bin/gitea
# Runs as podman_user, via the sudoers drop-in, and does the podman exec.
# Runs as podman_user through the sudoers drop-in and does the podman exec.
podman_exec_wrapper: /usr/local/bin/gitea-container-exec
# What sshd calls to obtain the git user's authorized_keys.
authorized_keys_command: /usr/local/bin/gitea-authorized-keys
sshd_dropin_path: /etc/ssh/sshd_config.d/60-gitea.conf
sshd_service: ssh
sudoers_path: /etc/sudoers.d/gitea-ssh-passthrough
# ---------------------------------------------------------------------------
# The shared Caddy unit and its Caddyfile, both owned by caddy/playbook.yml
# ---------------------------------------------------------------------------
caddy_name: caddy
caddyfile_path: "{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile"