mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-27 07:14:08 +09:00
207 lines
12 KiB
Markdown
207 lines
12 KiB
Markdown
# Fleet Contract — "runs where?" answered once
|
||
|
||
> Status: LIVE (plan 26, Fleet Parity Sweep).
|
||
> Audience: anyone adding a panel feature that could touch more than the
|
||
> primary host. Cite this instead of re-deciding fleet-awareness ad hoc.
|
||
|
||
ServerKit manages a *fleet* of servers: the panel host plus any number of
|
||
connected agents (the Go agent under `agent/`, reached over the Socket.IO
|
||
`/agent` gateway or the HTTP long-poll fallback). Historically most panel
|
||
features assumed a single host, and each new feature re-decided — usually
|
||
implicitly — whether it was fleet-aware. This document is the written
|
||
contract so that decision is made once, on purpose.
|
||
|
||
If you are onboarding a feature to the fleet, skip to
|
||
[the checklist](#onboarding-a-feature-to-the-fleet).
|
||
|
||
---
|
||
|
||
## The six rules (locked)
|
||
|
||
These are the load-bearing decisions every fleet feature inherits.
|
||
|
||
1. **Address by `server_id`, always.** A job that targets a server sets
|
||
`owner_type='server'`, `owner_id=<server_id>`, and repeats the id in
|
||
`payload.server_id` (the manifest precedent). UI surfaces select a
|
||
target with `TargetPicker`, which emits `{kind:'agent', server_id, …}`.
|
||
The panel host itself is addressed as `server_id=None`/`'local'`.
|
||
|
||
2. **Capability-gate, never error.** Every fleet feature names a capability
|
||
string (e.g. `docker`, `systemd.restart`, `doctor.probe`). An agent that
|
||
doesn't advertise it is shown/recorded as **`unsupported on this agent`**
|
||
(a skipped status) — never an error, never a crash. Older agents keep
|
||
working forever. Check with `agent_registry.has_capability(server_id,
|
||
feature)`; the `TargetPicker` filters non-capable agents out of the list.
|
||
|
||
3. **Per-server results are ROWS, not blobs.** New fleet result tables key
|
||
by `(server_id, check_key)` (see `FleetDoctorResult`). The legacy
|
||
panel-host blob (e.g. the `doctor_last_report` settings key) stays for
|
||
compatibility; the API/UI merges the two. Never widen a per-host blob
|
||
into an ad-hoc `{server_id: …}` map — add rows.
|
||
|
||
4. **Sweeps are jobs with a budget.** Fan-out over agents happens **inside
|
||
job handlers only**, never on a request thread — the gateway registry is
|
||
single-worker and in-memory (see `CLAUDE.md`, `docs/ARCHITECTURE.md`,
|
||
`SECURITY.md`). Use the one primitive: `fleet_sweep()` in
|
||
`app/services/fleet_sweep.py`. It gives you a bounded pool (≤4 concurrent
|
||
agents by default), a per-agent timeout, and a hard wall-clock budget. A
|
||
slow or hung agent yields a `timeout` result row, not a stalled panel.
|
||
|
||
5. **Compose from today's commands first.** Prefer composing a feature from
|
||
the commands every already-deployed agent understands
|
||
(`systemd:status`, `system:metrics`, registry/heartbeat data) so it works
|
||
on the whole fleet on day one. Add a new batched agent command only as a
|
||
capability-gated *optimisation* the panel negotiates to when present, with
|
||
the composed path as the permanent fallback. The worked example is the
|
||
doctor: v1 composes `systemd:status` + `system:metrics`; v2 prefers a single
|
||
`doctor:probe` round trip when the agent advertises `doctor.probe`, and
|
||
falls back to the composed calls if the probe errors — both paths emit
|
||
identical rows. Full spec + negotiation code in
|
||
[`docs/AGENT_DOCTOR_PROBE_SPEC.md`](AGENT_DOCTOR_PROBE_SPEC.md). The
|
||
read-only Observe-mode survey (`survey:read`, capability `survey`) follows the
|
||
same shape — a catalog of read-only primitives the agent enforces, gated by
|
||
`survey:read` + the `survey` capability, degrading to "unsupported" on old
|
||
agents. Full contract in
|
||
[`docs/AGENT_SURVEY_SPEC.md`](AGENT_SURVEY_SPEC.md).
|
||
|
||
6. **Remote mutations are allowlisted + audited.** Anything that changes
|
||
state on a remote box goes through an explicit allowlist that maps the
|
||
operation → agent command + required permission scope + required
|
||
capability. Anything not on the allowlist is refused server-side. Every
|
||
remote mutation writes an `AuditService.log(...)` entry. The v1 repair
|
||
allowlist is **service restart only** (`fleet.service` →
|
||
`systemd:restart`).
|
||
|
||
---
|
||
|
||
## Capability classes × runs-where
|
||
|
||
Each panel capability falls into one of these classes. The class dictates
|
||
where it runs and how results aggregate.
|
||
|
||
| Class | Runs where | Aggregation | Addressing |
|
||
|---|---|---|---|
|
||
| **Host probe** (read a fact about a box: is nginx up, disk headroom, agent version) | Per-agent when connected; panel-side for facts the panel already knows (heartbeat age, version-vs-latest, DNS resolution) | Rows keyed `(server_id, check_key)`; UI shows an "All servers" rollup + per-server drill-down | `server_id` |
|
||
| **Config write** (write a managed file / restart a unit) | Per-agent, allowlisted + audited (rule 6) | Per-item repair result rows | `server_id` + permission scope + capability |
|
||
| **Scheduled sweep** (run a probe across the fleet on a cadence) | Job handler only, via `fleet_sweep()` (rule 4) | Rows; the schedule piggybacks an existing cadence where possible | iterate connected/fleet servers |
|
||
| **Metric collection** (CPU/mem/disk time-series) | Per-agent → `ServerMetrics` rows on every heartbeat | Existing rows; FleetMonitor reads them | `server_id` |
|
||
| **UI surface** (a page/panel) | Panel; zero-agent installs see no fleet chrome at all; single-box view is unchanged | Consumes the rollup + rows | `TargetPicker` |
|
||
|
||
**Panel-scoped by design** (do *not* fan these out): anything that is a
|
||
property of the control plane itself — the panel's own DB, the panel's
|
||
canonical domain, org-wide setup items. Setup-health items carry a `scope`
|
||
field (`panel` | `per-server`) precisely so this stays explicit (plan 22 /
|
||
plan 26 #15).
|
||
|
||
---
|
||
|
||
## Addressing conventions
|
||
|
||
- **A connected agent:** `server_id` = the `Server.id` UUID. Enumerate live
|
||
agents with `agent_registry.get_connected_servers()`, map back with
|
||
`Server.query.get(server_id)`, read live capabilities with
|
||
`agent_registry.get_capabilities(server_id)`.
|
||
- **The panel host:** `server_id` is `None` or the literal `'local'`. Remote
|
||
services (`remote_docker_service`, etc.) treat `not server_id or server_id
|
||
== 'local'` as "run the local service".
|
||
- **A server-targeted job:** `owner_type='server'`, `owner_id=server_id`,
|
||
`payload={'server_id': server_id, …}`.
|
||
- **A fleet-wide sweep job:** no single server; the handler enumerates and
|
||
calls `fleet_sweep()`.
|
||
|
||
---
|
||
|
||
## The sweep primitive
|
||
|
||
`app/services/fleet_sweep.py`:
|
||
|
||
```python
|
||
from app.services.fleet_sweep import fleet_sweep
|
||
|
||
def compose(server_id, per_agent_timeout):
|
||
# runs on a worker thread, inside an app context; may send_command + query DB
|
||
caps = agent_registry.get_capabilities(server_id) or {}
|
||
if not caps.get('systemd'):
|
||
return {'status': 'unsupported', 'checks': []}
|
||
res = agent_registry.send_command(server_id, 'systemd:status',
|
||
{'unit': 'nginx'}, timeout=per_agent_timeout)
|
||
...
|
||
return {'status': 'ok', 'checks': [...]}
|
||
|
||
results = fleet_sweep(compose, servers, pool=4, per_agent_timeout=15, budget=90)
|
||
# results == {server_id: {'status': 'ok'|'failed'|'offline'|'unsupported'|'timeout', ...}}
|
||
```
|
||
|
||
- Every row carries a `status` from `ok`/`failed`/`offline`/`unsupported`/`timeout`.
|
||
- A composer exception → `failed`. A budget overrun → `timeout`. Neither
|
||
raises out of the sweep.
|
||
- The composer runs inside a Flask app context (the helper pushes one per
|
||
worker) so it may query the DB and the registry freely.
|
||
- **Never call `fleet_sweep()` from a request handler** — only from a job.
|
||
|
||
---
|
||
|
||
## Onboarding a feature to the fleet
|
||
|
||
A short checklist. If you can answer all of these, the feature is contract-compliant.
|
||
|
||
1. **Which capability class is it?** (host probe / config write / sweep /
|
||
metric / UI). That fixes where it runs and how it aggregates.
|
||
2. **What capability string does it require?** Gate on it with
|
||
`agent_registry.has_capability`; agents without it record `unsupported`.
|
||
3. **Does it read or write?** A write must be on an allowlist (rule 6): map
|
||
operation → agent command + permission scope + capability, refuse
|
||
anything else server-side, and audit-log every call.
|
||
4. **Does it fan out?** Then it is a job (rule 4). Use `fleet_sweep()`, pick
|
||
a pool/timeout/budget, and piggyback an existing schedule cadence if one
|
||
fits.
|
||
5. **How do results aggregate?** Rows keyed `(server_id, check_key)`, merged
|
||
with any legacy panel blob (rule 3). The UI gets an "All servers" rollup +
|
||
per-server drill-down; a zero-agent install must look exactly as it did
|
||
before.
|
||
6. **Can it be composed from today's commands?** Prefer that (rule 5). If a
|
||
new agent command genuinely pays for itself, ship it capability-gated with
|
||
the composed path as the permanent fallback.
|
||
7. **Update the parity ledger below** with the feature's today-state + verdict.
|
||
|
||
---
|
||
|
||
## Parity ledger (Appendix A — living)
|
||
|
||
Where each capability stands. Update the verdict when you move the line.
|
||
Snapshot baseline: 2026-07-05 (Cron/Doctor/Survey rows moved 2026-07-06,
|
||
plan 28 — the agent v2 capability pack lit up the batched doctor probe,
|
||
the read-only survey, and in-place remote cron edit on agent ≥1.2.0).
|
||
|
||
| Capability | Fleet story today | Verdict |
|
||
|---|---|---|
|
||
| Metrics/heartbeat | per-agent → `ServerMetrics` rows, FleetMonitor | fleet-aware |
|
||
| Docker ops | `remote_docker_service`, full command set | fleet-aware |
|
||
| Cron | `remote_cron_service` — status/list/add/update/remove/toggle; in-place edit via `cron:update` (capability `cron.update`, agent ≥1.2.0); no run-now op yet | fleet-aware (edit shipped; run-now open) |
|
||
| Files | `remote_file_service` (allowed_paths-gated) | fleet-aware |
|
||
| Systemd | read (list/status/logs); restart is capability-gated `systemd.restart` | read + gated restart |
|
||
| Packages/runtimes | full remote support | fleet-aware |
|
||
| Terminal | remote via gateway (WS only) | fleet-aware |
|
||
| WireGuard/tunnels | pairing + publish rails | fleet-aware |
|
||
| Agent upgrades | staged rollouts, offline queue | fleet-aware |
|
||
| **Doctor** | **fleet sweep: services/disk/agent-freshness/site-DNS per server; DNS compares each box's IP. Per-server DNS is bounded to 10 domains/sweep but the checked window ROTATES per sweep (persistent counter) so all domains are eventually verified (plan 31 #13). Batched `doctor:probe` path lit up by agent ≥1.2.0 (capability `doctor.probe`), else composed v1 fallback** | **fleet-aware (v1 + gated v2 probe)** |
|
||
| Survey / Observe | read-only `survey:read` executor shipped in agent ≥1.2.0 (capability `survey`); fixed primitive allowlist, catalog-as-untrusted-input; degrades to "unsupported" on older agents | fleet-aware (agent ≥1.2.0) |
|
||
| Drift | panel-local file checks | deferred (templates — `ServerTemplates` owns config compliance) |
|
||
| Backups | policies execute on panel host only | deferred (plan 23) |
|
||
| Setup health | `scope` field: panel items panel-scoped, per-server items evaluated in the fleet sweep | fleet-aware (per-server hook) |
|
||
| Proxy stacks | per-server via FleetProxy UI | partial by design |
|
||
| Manifest apply | `server:` targeting resolves id; remote dispatch deferred | partial (plan 17) |
|
||
| Mail live delivery | preflight shipped; runbook `docs/runbooks/mail-live-delivery.md` | see runbook |
|
||
| Remote access multi-host | shipped; runbook `docs/runbooks/remote-access-two-host.md` | see runbook |
|
||
|
||
### Explicitly out of scope (marked so nobody re-opens them ad hoc)
|
||
|
||
- **Fleet drift / config compliance** — config-template territory;
|
||
`ServerTemplates` owns it.
|
||
- **Cross-server backups / DR restores** — deferred (plan 23).
|
||
- **Workspace scoping of servers/fleet surfaces** — fleet stays admin (plan 19).
|
||
- **Gateway horizontal scaling** — the single-worker constraint stands
|
||
(a `HORIZONTAL_SCALING_SPEC` exists; explicitly not adopted).
|
||
- **Streaming over the long-poll transport** — by design; sweeps are
|
||
request/response only.
|