Three artefacts disagreed about where nginx runs. design.md §6.2 and the vhost both said the hanzalova proxy; the API bound 127.0.0.1 and the workflow rsynced the dashboard to bob. That combination deploys green and then serves nothing, since a proxy on another host cannot reach bob's loopback. Resolve it the way design.md already stated: nginx on the proxy, dashboard shipped there, API bound 0.0.0.0 behind firewalld and the mesh. The health probe now runs from the proxy over the mesh rather than from bob's loopback, so it fails when firewalld is closed instead of passing regardless. infra-setup.sh grows a proxy grant scoped to static files alone, and the nginx vhost install as a manual step — it needs a certificate, and nothing was telling the operator to install it at all. npm run lint had never run: eslint 9 needs a flat config and there was none. Add it, ignoring the ts-rs generated bindings, and run it in CI so it stays true. Untrack dashboard/tsconfig.tsbuildinfo, a build artifact that would have put a spurious diff in every pull request tireless opens. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013TxK1CWPkFXqdcXMJ4hVe6
195 lines
9.3 KiB
Bash
Executable File
195 lines
9.3 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# One-time host provisioning for tireless.
|
|
#
|
|
# Run by an operator from a workstation with full sudo — NOT by CI. See
|
|
# architecture/deployment-gitea-actions.md §2. Idempotent: re-running with no
|
|
# changes is a no-op beyond file copies.
|
|
#
|
|
# Per architecture/generic.md §7 this script never suppresses errors. Where a
|
|
# command may legitimately fail (a service not yet installed), the failure is
|
|
# handled explicitly and visibly.
|
|
|
|
set -euo pipefail
|
|
|
|
APP=tireless
|
|
API_HOST="${API_HOST:-bob.hanzalova.internal}"
|
|
API_PORT="${API_PORT:-23296}"
|
|
# Ingress runs on the office proxy, not on bob (doc/plan/design.md §6.2). The
|
|
# dashboard is served from there and /v1 is reverse-proxied across the mesh, so
|
|
# the proxy needs its own (much smaller) deploy grant.
|
|
WEB_HOST="${WEB_HOST:-hanzalova.internal}"
|
|
WEB_ROOT="${WEB_ROOT:-/var/www/tireless}"
|
|
RUNNER_PUBKEY="${RUNNER_PUBKEY:-$HOME/.ssh/id_gitea_ci.pub}"
|
|
|
|
info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
|
|
warn() { printf '\033[1;33m warn\033[0m %s\n' "$*" >&2; }
|
|
fatal() { printf '\033[1;31mfatal\033[0m %s\n' "$*" >&2; exit 1; }
|
|
|
|
[[ -f $RUNNER_PUBKEY ]] || fatal "runner public key not found at $RUNNER_PUBKEY.
|
|
The keypair is maintained at ~/.ssh/id_gitea_ci on roosta and is shared by every
|
|
project's deploy. Copy it — do not generate a new one."
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. gitea_ci account, key, journal access, scoped sudoers
|
|
# ---------------------------------------------------------------------------
|
|
provision_host() {
|
|
local host="$1"
|
|
info "provisioning $host"
|
|
|
|
if ! ssh -o ConnectTimeout=5 -o BatchMode=yes "$host" true; then
|
|
warn "$host unreachable; skipping (re-run once it is back)"
|
|
return 0
|
|
fi
|
|
|
|
ssh "$host" 'sudo useradd --system --create-home --home-dir /var/lib/gitea_ci \
|
|
--shell /usr/sbin/nologin gitea_ci || echo "gitea_ci already exists"'
|
|
ssh "$host" 'sudo install -d -o gitea_ci -g gitea_ci -m 0700 /var/lib/gitea_ci/.ssh'
|
|
rsync --rsync-path 'sudo rsync' --chown gitea_ci:gitea_ci --chmod 0600 \
|
|
"$RUNNER_PUBKEY" "$host:/var/lib/gitea_ci/.ssh/authorized_keys"
|
|
ssh "$host" 'sudo usermod -aG systemd-journal gitea_ci'
|
|
|
|
# Scoped sudoers — exactly the commands the deploy runs, nothing broader.
|
|
# Named <app>_gitea_ci so other apps on this host keep their own drop-in.
|
|
ssh "$host" "sudo tee /etc/sudoers.d/${APP}_gitea_ci >/dev/null" <<SUDOERS
|
|
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /usr/local/bin/tireless-api
|
|
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /usr/local/bin/tireless-worker
|
|
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /usr/local/bin/tireless
|
|
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/tireless/config.toml
|
|
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/sysusers.d/tireless.conf
|
|
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/systemd/system/tireless-api.service
|
|
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/systemd/system/tireless-poller.service
|
|
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/systemd/system/tireless-runner.service
|
|
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/firewalld/services/tireless-api.xml
|
|
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /var/www/tireless/
|
|
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemd-sysusers
|
|
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 /var/www/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
|
|
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --get-default-zone
|
|
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --zone=* --query-service=tireless-api
|
|
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --permanent --zone=* --add-service=tireless-api
|
|
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --zone=* --add-service=tireless-api
|
|
SUDOERS
|
|
ssh "$host" "sudo visudo -cf /etc/sudoers.d/${APP}_gitea_ci"
|
|
|
|
# ------------------------------------------------------------------------
|
|
# 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"'
|
|
|
|
# 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" || \
|
|
echo "deferred: tireless user does not exist yet"'
|
|
|
|
# SELinux: the API binds a non-standard port, which must be labelled before
|
|
# the first start or the bind is denied (§10).
|
|
ssh "$host" "sudo semanage port -l | grep -qE '^http_port_t.*\\b${API_PORT}\\b' \
|
|
&& echo 'port ${API_PORT} already labelled' \
|
|
|| sudo semanage port -a -t http_port_t -p tcp ${API_PORT}"
|
|
|
|
info "$host provisioned"
|
|
}
|
|
|
|
provision_host "$API_HOST"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. Proxy host: web root and a deploy grant scoped to it alone
|
|
# ---------------------------------------------------------------------------
|
|
provision_web_host() {
|
|
local host="$1"
|
|
info "provisioning ingress on $host"
|
|
|
|
if ! ssh -o ConnectTimeout=5 -o BatchMode=yes "$host" true; then
|
|
warn "$host unreachable; skipping (re-run once it is back)"
|
|
return 0
|
|
fi
|
|
|
|
ssh "$host" 'sudo useradd --system --create-home --home-dir /var/lib/gitea_ci \
|
|
--shell /usr/sbin/nologin gitea_ci || echo "gitea_ci already exists"'
|
|
ssh "$host" 'sudo install -d -o gitea_ci -g gitea_ci -m 0700 /var/lib/gitea_ci/.ssh'
|
|
rsync --rsync-path 'sudo rsync' --chown gitea_ci:gitea_ci --chmod 0600 \
|
|
"$RUNNER_PUBKEY" "$host:/var/lib/gitea_ci/.ssh/authorized_keys"
|
|
ssh "$host" "sudo install -d -o root -g root -m 0755 $WEB_ROOT"
|
|
|
|
# Deliberately narrower than the API host's grant: the proxy only ever
|
|
# receives static files. It gets no systemctl, no binaries, no config.
|
|
ssh "$host" "sudo tee /etc/sudoers.d/${APP}_web_gitea_ci >/dev/null" <<SUDOERS
|
|
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * ${WEB_ROOT}/
|
|
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/restorecon -R ${WEB_ROOT}
|
|
SUDOERS
|
|
ssh "$host" "sudo visudo -cf /etc/sudoers.d/${APP}_web_gitea_ci"
|
|
|
|
info "$host ingress provisioned"
|
|
}
|
|
|
|
provision_web_host "$WEB_HOST"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. Manual steps that cannot be automated
|
|
# ---------------------------------------------------------------------------
|
|
cat <<'MANUAL'
|
|
|
|
Remaining one-time steps (operator, on the target host):
|
|
|
|
1. Claude Code subscription login.
|
|
The OAuth flow is interactive and must be completed *as the service account*,
|
|
because Claude Code reads credentials from $HOME:
|
|
|
|
sudo -u tireless -H /usr/bin/npx -y @anthropic-ai/claude-code@2.1.220
|
|
# 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.
|
|
|
|
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:
|
|
the API key silently wins, and the subscription goes unused.
|
|
|
|
2. Gitea bot account.
|
|
Create a dedicated `tireless` user on git.lair.cafe (not your own account),
|
|
generate a token scoped to issue + PR write, and put it in
|
|
/etc/tireless/tireless.env as GITEA_TOKEN (0640 root:tireless).
|
|
|
|
Then, for each repo tireless should work on:
|
|
- add `tireless` as a collaborator with write access;
|
|
- enable branch protection on the default branch, denying `tireless` push;
|
|
- confirm it can still push refs matching `tireless/*`.
|
|
|
|
The protection rule is what keeps an unattended agent from writing to main.
|
|
Verify it rather than assuming it.
|
|
|
|
3. nginx vhost on the proxy.
|
|
The deploy ships the built dashboard to the proxy's web root but does not
|
|
install the vhost — that is one-time, and it needs a certificate:
|
|
|
|
- mint the per-service cert for tireless.internal per
|
|
architecture/internal-tls.md;
|
|
- copy asset/nginx/tireless.hanzalova.conf to the proxy's conf.d;
|
|
- sudo nginx -t && sudo systemctl reload nginx
|
|
|
|
The vhost serves the dashboard locally and reverse-proxies /v1 to
|
|
bob:23296 across the mesh. That is why the API binds 0.0.0.0 rather than
|
|
loopback, and why asset/firewalld/ opens the port. Those three facts are one
|
|
decision — if you move ingress onto bob, change all three together.
|
|
|
|
4. Postgres role and ident mapping (architecture/generic.md §5).
|
|
On magrathea AND frankie:
|
|
- create role `tireless_rw`, and a `tireless` database;
|
|
- drop /var/lib/pgsql/18/data/pg_ident.conf.d/<this-host-fqdn>.conf
|
|
containing: cert_cn <this-host-fqdn> tireless_rw
|
|
- sudo systemctl reload postgresql-18
|
|
|
|
Both servers, or a failover locks tireless out.
|
|
|
|
MANUAL
|
|
|
|
info "infra-setup complete"
|