fix(deploy): quote --rsync-path, and let systemd own the state directory
Some checks failed
deploy / deploy (push) Failing after 5m49s

Run 4 failed at `ship artifacts` with `sudo: unrecognized option '--server'`.

`R="--rsync-path=sudo rsync --mkpath"` expanded unquoted as `rsync $R` splits
into three arguments — `--rsync-path=sudo`, plus a stray `rsync` that rsync
reads as a source path — so the remote end ran `sudo --server`. Use an array.
The dashboard step quoted it inline and was unaffected, which is why only half
the deploy was broken.

Every rsync destination and every sudo command in `apply system state` has now
been exercised directly against bob as gitea_ci, rather than by another six
minute round trip: seven rsync targets, sysusers, restorecon, firewalld and
daemon-reload all pass.

That surfaced the second fault. restorecon was given /var/lib/tireless, which
does not exist on a fresh host: infra-setup.sh tried to create it before
systemd-sysusers had created the account to own it, so the attempt always raced
and always lost. Declare StateDirectory=tireless on all three units instead —
systemd creates the directory, owns it as the service user and labels it — and
drop the path from restorecon, the grant, and infra-setup.

Refs #9

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013TxK1CWPkFXqdcXMJ4hVe6
This commit is contained in:
rob thijssen
2026-08-07 16:32:31 +03:00
parent 2e3aff7b17
commit 267cb3314d
5 changed files with 46 additions and 16 deletions

View File

@@ -96,23 +96,32 @@ jobs:
- name: ship artifacts
run: |
R="--rsync-path=sudo rsync --mkpath"
# An array, not a string. `R="--rsync-path=sudo rsync --mkpath"` used
# as `rsync $R` word-splits into three arguments — `--rsync-path=sudo`
# plus a stray `rsync` that rsync reads as a source path — and the
# remote end runs `sudo --server`, which sudo rejects.
#
# --mkpath because rsync will not create a missing destination
# directory for a single-file copy, and Fedora ships neither
# /etc/sysusers.d nor /etc/firewalld/services
# (architecture/deployment-gitea-actions.md §6).
R=(--rsync-path="sudo rsync --mkpath")
B=target/x86_64-unknown-linux-musl/release
rsync $R --chmod 0755 "$B/tireless-api" \
rsync "${R[@]}" --chmod 0755 "$B/tireless-api" \
gitea_ci@"$API_HOST":/usr/local/bin/tireless-api
rsync $R --chmod 0755 "$B/tireless-worker" \
rsync "${R[@]}" --chmod 0755 "$B/tireless-worker" \
gitea_ci@"$API_HOST":/usr/local/bin/tireless-worker
rsync $R --chmod 0755 "$B/tireless" \
rsync "${R[@]}" --chmod 0755 "$B/tireless" \
gitea_ci@"$API_HOST":/usr/local/bin/tireless
rsync $R --chmod 0640 config.toml \
rsync "${R[@]}" --chmod 0640 config.toml \
gitea_ci@"$API_HOST":/etc/tireless/config.toml
rsync $R asset/systemd/tireless.sysusers.conf \
rsync "${R[@]}" asset/systemd/tireless.sysusers.conf \
gitea_ci@"$API_HOST":/etc/sysusers.d/tireless.conf
for unit in tireless-api tireless-poller tireless-runner; do
rsync $R "asset/systemd/$unit.service" \
rsync "${R[@]}" "asset/systemd/$unit.service" \
gitea_ci@"$API_HOST":"/etc/systemd/system/$unit.service"
done
rsync $R asset/firewalld/tireless-api.xml \
rsync "${R[@]}" asset/firewalld/tireless-api.xml \
gitea_ci@"$API_HOST":/etc/firewalld/services/tireless-api.xml
- name: ship dashboard
@@ -129,8 +138,11 @@ jobs:
run: |
ssh gitea_ci@"$API_HOST" bash -euo pipefail <<EOF
sudo systemd-sysusers
# /var/lib/tireless is absent here on purpose: it does not exist yet on
# a fresh host, and the units declare StateDirectory=tireless, so
# systemd creates, owns and labels it at first start.
sudo restorecon -R /usr/local/bin/tireless-api /usr/local/bin/tireless-worker \
/usr/local/bin/tireless /etc/tireless /var/lib/tireless
/usr/local/bin/tireless /etc/tireless
# firewalld only learns a freshly-shipped service after a reload
# (architecture/deployment-gitea-actions.md §6).

View File

@@ -29,7 +29,12 @@ LockPersonality=true
MemoryDenyWriteExecute=true
SystemCallArchitectures=native
ReadWritePaths=/var/lib/tireless
# StateDirectory rather than a bare ReadWritePaths: systemd creates
# /var/lib/tireless on first start, owns it as the service user, and labels it
# for SELinux. A plain ReadWritePaths requires the directory to already exist,
# which on a fresh host it does not — the service account is created by
# systemd-sysusers during the same deploy that first starts these units.
StateDirectory=tireless
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
[Install]

View File

@@ -30,7 +30,12 @@ LockPersonality=true
MemoryDenyWriteExecute=true
SystemCallArchitectures=native
ReadWritePaths=/var/lib/tireless
# StateDirectory rather than a bare ReadWritePaths: systemd creates
# /var/lib/tireless on first start, owns it as the service user, and labels it
# for SELinux. A plain ReadWritePaths requires the directory to already exist,
# which on a fresh host it does not — the service account is created by
# systemd-sysusers during the same deploy that first starts these units.
StateDirectory=tireless
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
[Install]

View File

@@ -58,7 +58,12 @@ MemoryDenyWriteExecute=false
# The runner clones repos, runs builds and spawns agents, all under its state
# directory. PrivateTmp gives it an isolated /tmp for the toolchains that insist
# on one.
ReadWritePaths=/var/lib/tireless
# StateDirectory rather than a bare ReadWritePaths: systemd creates
# /var/lib/tireless on first start, owns it as the service user, and labels it
# for SELinux. A plain ReadWritePaths requires the directory to already exist,
# which on a fresh host it does not — the service account is created by
# systemd-sysusers during the same deploy that first starts these units.
StateDirectory=tireless
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
[Install]

View File

@@ -70,7 +70,7 @@ gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl daemon-reload
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl restart tireless-api.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl restart tireless-poller.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl restart tireless-runner.service
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/restorecon -R /usr/local/bin/tireless-api /usr/local/bin/tireless-worker /usr/local/bin/tireless /etc/tireless /var/lib/tireless
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/restorecon -R /usr/local/bin/tireless-api /usr/local/bin/tireless-worker /usr/local/bin/tireless /etc/tireless
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/semanage port -l
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/semanage port -a -t http_port_t -p tcp ${API_PORT}
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --reload
@@ -85,8 +85,10 @@ SUDOERS
# 2. Service account, directories, cert ACL
# ------------------------------------------------------------------------
ssh "$host" 'sudo install -d -o root -g root -m 0755 /etc/tireless'
ssh "$host" 'sudo install -d -o tireless -g tireless -m 0750 /var/lib/tireless || \
echo "tireless user not created yet — first deploy runs systemd-sysusers"'
# /var/lib/tireless is deliberately NOT created here: the service account
# does not exist until the first deploy runs systemd-sysusers, so this always
# raced. The units declare StateDirectory=tireless, which makes systemd create,
# own and label it at first start.
# The service account needs to read the host key for mTLS to Postgres (§11).
ssh "$host" 'sudo setfacl -m u:tireless:r "/etc/pki/tls/private/$(hostname -f).pem" || \
@@ -295,7 +297,8 @@ Remaining one-time steps (operator, on the target host):
# then: /login, and complete the browser flow
This writes /var/lib/tireless/.claude.json. The token refreshes in place,
which is why the unit grants ReadWritePaths=/var/lib/tireless.
which is why the unit declares StateDirectory=tireless (systemd creates
it, owns it as the service account, and keeps it writable).
Skip this only if you intend to run pay-as-you-go, in which case put
ANTHROPIC_API_KEY in /etc/tireless/tireless.env instead. Do not do both: