fix(deploy): put ingress on the proxy, and make the lint script runnable
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
This commit is contained in:
@@ -15,6 +15,10 @@ concurrency:
|
||||
env:
|
||||
API_HOST: bob.hanzalova.internal
|
||||
API_PORT: "23296"
|
||||
# Ingress is the office proxy, not bob (doc/plan/design.md §6.2): nginx there
|
||||
# serves the static dashboard and reverse-proxies /v1 across the mesh. The
|
||||
# dashboard therefore ships to the proxy, and only the binaries ship to bob.
|
||||
WEB_HOST: hanzalova.internal
|
||||
WEB_ROOT: /var/www/tireless
|
||||
VITE_API_BASE_URL: ""
|
||||
|
||||
@@ -41,6 +45,7 @@ jobs:
|
||||
working-directory: dashboard
|
||||
run: |
|
||||
npm ci
|
||||
npm run lint
|
||||
npm run build
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
@@ -72,6 +77,7 @@ jobs:
|
||||
StrictHostKeyChecking accept-new
|
||||
EOF
|
||||
ssh gitea_ci@"$API_HOST" hostname -f
|
||||
ssh gitea_ci@"$WEB_HOST" hostname -f
|
||||
|
||||
- name: render config
|
||||
env:
|
||||
@@ -105,15 +111,21 @@ jobs:
|
||||
done
|
||||
rsync $R asset/firewalld/tireless-api.xml \
|
||||
gitea_ci@"$API_HOST":/etc/firewalld/services/tireless-api.xml
|
||||
|
||||
- name: ship dashboard
|
||||
run: |
|
||||
# To the proxy, not to bob — that is where nginx serves it from.
|
||||
R="--rsync-path=sudo rsync --mkpath"
|
||||
rsync $R -a --delete dashboard/dist/ \
|
||||
gitea_ci@"$API_HOST":"$WEB_ROOT/"
|
||||
gitea_ci@"$WEB_HOST":"$WEB_ROOT/"
|
||||
ssh gitea_ci@"$WEB_HOST" "sudo restorecon -R $WEB_ROOT"
|
||||
|
||||
- name: apply system state
|
||||
run: |
|
||||
ssh gitea_ci@"$API_HOST" bash -euo pipefail <<EOF
|
||||
sudo systemd-sysusers
|
||||
sudo restorecon -R /usr/local/bin/tireless-api /usr/local/bin/tireless-worker \
|
||||
/usr/local/bin/tireless /etc/tireless /var/lib/tireless /var/www/tireless
|
||||
/usr/local/bin/tireless /etc/tireless /var/lib/tireless
|
||||
|
||||
# firewalld only learns a freshly-shipped service after a reload (§6).
|
||||
sudo firewall-cmd --reload
|
||||
@@ -125,13 +137,22 @@ jobs:
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart tireless-api.service
|
||||
sudo systemctl restart tireless-poller.service
|
||||
|
||||
# The runner is restarted last and deliberately: it may be mid-agent-run,
|
||||
# and TimeoutStopSec=120 will not wait out a job with an hour-long
|
||||
# ceiling. A killed run is not lost — its claim lease expires and the
|
||||
# job returns to the pool (design.md §4.2) — but it does cost the tokens
|
||||
# already spent. That is the accepted trade for a simple deploy; see
|
||||
# design.md §10 for why it bites hardest when tireless deploys itself.
|
||||
sudo systemctl restart tireless-runner.service
|
||||
EOF
|
||||
|
||||
- name: health probe
|
||||
run: |
|
||||
ssh gitea_ci@"$API_HOST" \
|
||||
"curl -fsS http://127.0.0.1:$API_PORT/v1/ready"
|
||||
# Probe from the proxy, over the mesh — the path a user actually takes.
|
||||
# A loopback probe on bob would pass even if firewalld were closed.
|
||||
ssh gitea_ci@"$WEB_HOST" \
|
||||
"curl -fsS http://$API_HOST:$API_PORT/v1/ready"
|
||||
for unit in tireless-api tireless-poller tireless-runner; do
|
||||
ssh gitea_ci@"$API_HOST" "systemctl is-active \$unit.service"
|
||||
done
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -13,3 +13,7 @@ dashboard/.vite/
|
||||
# local operator overrides
|
||||
/.env
|
||||
/.env.*
|
||||
|
||||
# TypeScript incremental build state — a build artifact that was tracked,
|
||||
# so every build produced a spurious diff in every pull request.
|
||||
dashboard/tsconfig.tsbuildinfo
|
||||
|
||||
34
dashboard/eslint.config.js
Normal file
34
dashboard/eslint.config.js
Normal file
@@ -0,0 +1,34 @@
|
||||
// Flat config, required by ESLint 9. `npm run lint` referenced this file before
|
||||
// it existed, so the script had never run — the deploy workflow runs it now.
|
||||
import js from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
// Build output and the Rust-generated bindings are not ours to lint.
|
||||
// `src/api/generated` is written by ts-rs on `cargo test -p tireless-entities`;
|
||||
// editing it by hand is a mistake the .cargo/config.toml comment already warns
|
||||
// about, and linting it would invite exactly that.
|
||||
{ ignores: ['dist', 'src/api/generated'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
20
dashboard/package-lock.json
generated
20
dashboard/package-lock.json
generated
@@ -21,6 +21,7 @@
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.16",
|
||||
"globals": "^17.9.0",
|
||||
"prettier": "^3.4.2",
|
||||
"typescript": "~5.7.2",
|
||||
"typescript-eslint": "^8.18.0",
|
||||
@@ -576,6 +577,19 @@
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/globals": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
|
||||
"integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/js": {
|
||||
"version": "9.39.5",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz",
|
||||
@@ -2277,9 +2291,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
|
||||
"integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
|
||||
"version": "17.9.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.9.0.tgz",
|
||||
"integrity": "sha512-m/MvAW61QVU5VDNF1Vj8axt016h8w7L5TU1e9zlab7XIttAT2YAlCwl75K1fOqvMM9apmD7lbCIRhpfkhmxhCg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.16",
|
||||
"globals": "^17.9.0",
|
||||
"prettier": "^3.4.2",
|
||||
"typescript": "~5.7.2",
|
||||
"typescript-eslint": "^8.18.0",
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/generated/AgentKind.ts","./src/api/generated/AgentRun.ts","./src/api/generated/BillingMode.ts","./src/api/generated/Forge.ts","./src/api/generated/IssueRef.ts","./src/api/generated/Job.ts","./src/api/generated/JobKind.ts","./src/api/generated/JobState.ts","./src/api/generated/LabelProtocol.ts","./src/api/generated/PollSchedule.ts","./src/api/generated/PullRequestRef.ts","./src/api/generated/RunOutcome.ts","./src/api/generated/TrackedRepo.ts","./src/routes/Jobs.tsx","./src/routes/Lanes.tsx","./src/routes/Repos.tsx"],"version":"5.7.3"}
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/generated/Acceptance.ts","./src/api/generated/AgentKind.ts","./src/api/generated/AgentRun.ts","./src/api/generated/BillingMode.ts","./src/api/generated/ChildSpec.ts","./src/api/generated/FileTouch.ts","./src/api/generated/Forge.ts","./src/api/generated/IssueRef.ts","./src/api/generated/Job.ts","./src/api/generated/JobKind.ts","./src/api/generated/JobState.ts","./src/api/generated/LabelProtocol.ts","./src/api/generated/PlanSpec.ts","./src/api/generated/PlannedChild.ts","./src/api/generated/PollSchedule.ts","./src/api/generated/PullRequestRef.ts","./src/api/generated/RunOutcome.ts","./src/api/generated/TrackedRepo.ts","./src/routes/Jobs.tsx","./src/routes/Lanes.tsx","./src/routes/Repos.tsx"],"version":"5.7.3"}
|
||||
@@ -15,6 +15,11 @@ 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' "$*"; }
|
||||
@@ -96,7 +101,39 @@ SUDOERS
|
||||
provision_host "$API_HOST"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Manual steps that cannot be automated
|
||||
# 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'
|
||||
|
||||
@@ -129,7 +166,21 @@ Remaining one-time steps (operator, on the target host):
|
||||
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).
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user