Autonomous issue-to-PR driver for Claude Code and OpenCode, structured per lair/architecture generic.md. Workspace: entities/core/data/agent library crates plus api, worker and cli binaries. Two pieces of real logic land with tests — lane routing (cc for judgement, oc for specification) and the limit governor. Constraints encoded as code rather than comments: - agents are spawned as vendor binaries; tireless never calls a provider API - ANTHROPIC_API_KEY is never set by tireless, only passed through - assert_not_anthropic refuses to start an OpenCode lane pointed at Anthropic - every run passes the governor; provider rate-limit signals win over our own accounting Deployment assets target bob.hanzalova.internal:23296 (registered in port-allocations.md), fronted by hanzalova at tireless.internal. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DHhHtohxcdk1PL3tfnYJdH
144 lines
6.9 KiB
Bash
Executable File
144 lines
6.9 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}"
|
|
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. 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.119
|
|
# 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. 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"
|