fix: Fixes to running on kubernetes
This commit is contained in:
@@ -120,7 +120,7 @@ Upload a public key, assign it per server, revoke softly. The agent diffs desire
|
||||
|
||||
### Workflows
|
||||
|
||||
A library of reusable **steps** (bash or PowerShell scripts with declared inputs, outputs, and secret refs) composed into **workflows** targeting a set of servers. Running one snapshots the resolved steps into a `WorkflowRun`, then dispatches `RunStepCmd` over the agent command stream. Step stdout/stderr streams back as `StepOutputChunk` and is written to a log file on disk; the UI streams it live. Steps support `on_failure: stop|continue|retry`, per-run env passed between steps via `output_env`, and a per-run workspace directory the agent cleans up at the end.
|
||||
A library of reusable **steps** (bash or PowerShell scripts with declared inputs, outputs, and secret refs) composed into **workflows** targeting a set of servers. Running one snapshots the resolved steps into a `WorkflowRun`, then dispatches `RunStepCmd` over the agent command stream. Step stdout/stderr streams back as `StepOutputChunk` and is written to MongoDB (`workflow_log_lines`, one document per line); the UI streams it live. Steps support `on_failure: stop|continue|retry`, per-run env passed between steps via `output_env`, and a per-run workspace directory the agent cleans up at the end.
|
||||
|
||||
Default steps are seeded per org at boot (`SeedDefaultSteps`) from `VANTAGE_DEFAULT_STEPS_DIR`, which `server/Dockerfile` bakes to `/opt/default-steps` from the repo's `default_steps/`. Deliberately **not** under `/data` — that is a bind mount, so the library would be editable from the host. Adding a step there means committing a file and rebuilding, which is why `default_steps/` is in the `server` rebuild trigger. **Steps with `source: "default"` are read-only**: `UpdateStep`/`DeleteStep` refuse with `ErrDefaultStep` (409), because seeding rewrites them on every boot, so an edit would silently revert and a delete would come back. `web/` mirrors this — the step modal opens read-only, Delete is hidden, and the designer's per-step script override is `readOnly` for a default library step — but as elsewhere, the API is the boundary and the UI is the courtesy. Seeding writes straight to the collection rather than through `UpdateStep`, so the guard does not lock out the seeder. Logs are swept by retention (`workflow_log_retention_days`; nil = 30 days, 0 = forever).
|
||||
|
||||
@@ -155,6 +155,56 @@ encrypted, single-use, and consumed when the tunnel opens. None of them reach
|
||||
the agent — the session is negotiated end-to-end between guacd and the target
|
||||
daemon, so the agent relays bytes it cannot read.
|
||||
|
||||
### Running more than one server replica
|
||||
|
||||
An agent's `CommandStream` terminates on exactly **one** server process. Every
|
||||
piece of coordination below exists because of that single fact: with several
|
||||
replicas, the process asked to do something to an agent is almost never the
|
||||
process holding that agent's stream.
|
||||
|
||||
`server/internal/bus` is the Redis message bus that closes the gap. It adds no
|
||||
infrastructure — Redis was already required for sessions — and it is **not
|
||||
optional on a single-replica deployment**: dispatch takes the bus path always,
|
||||
so the code running in production is the code running everywhere, rather than a
|
||||
rare cross-pod branch that only fails under load.
|
||||
|
||||
| Concern | How it crosses replicas |
|
||||
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Which pod owns an agent | `vantage:agent:<server_id>` holds the owner's node ID with a 30s TTL, renewed every 10s. `Dispatcher.IsConnected` is an `EXISTS` on it |
|
||||
| Sending a command | published to `vantage:cmd:<server_id>`; the owner pod acks on `vantage:ack:<command_id>`. **Request/ack, not a queue** — a command whose owner died must fail loudly (503) rather than queue |
|
||||
| Step results | the owner pod publishes to `vantage:res:<command_id>`; the pod driving the run subscribes **before** dispatching, or a fast agent answers into a channel nobody has joined |
|
||||
| Step output | never crosses. The dispatch envelope carries the secret mask list, so the owner pod masks and writes lines itself — unmasked bytes stay off the bus |
|
||||
| Console relay | the envelope asks the owner pod to bind the listener, and the ack returns **that pod's** address for guacd. The relay's failure reason comes back on `vantage:proxyend:<proxy_id>` |
|
||||
| Background jobs | `bus.RunAsLeader` — one Redis lock named `housekeeping` |
|
||||
|
||||
**Workflow logs are in MongoDB** (`workflow_log_lines`, one document per line,
|
||||
with a `workflow_log_seq` counter document per run/server). Two pods write the
|
||||
same log concurrently — the run's pod emits markers, the agent's pod emits
|
||||
output — so ordering only means anything if both draw sequence numbers from the
|
||||
same counter. `StepRun.log_offset` is that sequence number now, not a byte
|
||||
offset. Writes are batched (128 lines or 250ms) and capped: 8 KB per line,
|
||||
200k lines per server-run, after which one final `[vantage] log truncated`
|
||||
marker is written and the rest is dropped. Without that cap a `yes` in a step
|
||||
is a database incident. **Nothing writes to `/data` any more**, which is why
|
||||
`server.persistence` now defaults to off and `VANTAGE_WORKFLOW_LOG_DIR` is gone.
|
||||
|
||||
**The leader lock is not an optimisation.** N replicas each running the monitor
|
||||
scheduler means each check fires N times, each incident notification reaches the
|
||||
customer N times, and each hourly rollup is written N times; N reapers race to
|
||||
purge the same Free instance. `monitorsched`, `StartReaper`, `StartLogSweeper`,
|
||||
`StartAuditSweeper` and the offline sweep therefore all run inside one
|
||||
`RunAsLeader("housekeeping", …)` — one role, one lock. Each takes a context
|
||||
cancelled the instant leadership is lost, and must return when it is.
|
||||
|
||||
Redis rather than a Kubernetes `Lease` so Compose takes the identical path: one
|
||||
implementation to reason about, not two.
|
||||
|
||||
Two deployment requirements come with `replicaCount > 1`: every replica must
|
||||
share **one** Redis (a per-pod Redis partitions the bus and every agent looks
|
||||
offline to two thirds of the fleet), and `POD_IP` must be set — the chart does
|
||||
it from the downward API — because `PROXY_ADVERTISE_HOST` names the Service, and
|
||||
a Service cannot address the one pod holding a console listener.
|
||||
|
||||
### Inventory and OS updates
|
||||
|
||||
Agents report CPU/memory/swap/partitions/kernel — metrics every 30s, full static snapshot every 15 min. They also check for pending OS package updates hourly and can apply them on command (`ApplyUpdatesCmd`).
|
||||
@@ -330,6 +380,7 @@ Key-state polling stays on the 30s `SyncKeys` interval. Full message definitions
|
||||
Unauthenticated:
|
||||
|
||||
```
|
||||
GET /healthz /readyz # liveness / readiness probes
|
||||
GET /install /install.ps1 # dynamic agent install scripts
|
||||
GET /update /update.ps1
|
||||
GET /auth/bootstrap-status
|
||||
@@ -436,7 +487,7 @@ Paddle is merchant of record; `admin/internal/paddle` is a thin REST client (no
|
||||
|
||||
## MongoDB Collections
|
||||
|
||||
`servers` · `keys` · `assignments` · `orgs` · `users` · `org_oidc` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `migrations`
|
||||
`servers` · `keys` · `assignments` · `orgs` · `users` · `org_oidc` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `migrations`
|
||||
|
||||
Every document except `migrations` carries `org_id`. Struct definitions are the source of truth — see `server/internal/models/`.
|
||||
|
||||
@@ -448,6 +499,7 @@ Notes that are not obvious from the structs:
|
||||
- `assignments.revoked_at: null` means active. Revocation is soft, preserving audit history.
|
||||
- `workflow_runs.steps_snapshot` freezes the resolved steps so editing the library never rewrites history.
|
||||
- `console_sessions.token_consumed_at` is set atomically to enforce one-time use.
|
||||
- `workflow_log_lines` is keyed `(run_id, server_id, seq)` — the index is not an optimisation, every read is a range scan over it. `workflow_log_seq` holds one counter document per `run_id/server_id`, which is what lets two pods interleave into one ordered log. Neither carries `instance_id`: they are reached only through a run, and a run is already scoped.
|
||||
- `users.auth_source` is `local`, `oidc` or `hq`. An `hq` user was projected from a Vantage HQ account and carries `hq_user_id`; HQ owns its role, password and existence.
|
||||
|
||||
Admin's own database is separate and holds `accounts` · `admin_instances` · `licenses` · `subscriptions` · `plans` · `catalogue` · `entitlements` · `paddle_events` · `staff_users` · `customer_users` · `instance_members` · `admin_audit`. `paddle_events` is the webhook idempotency log, unique on `event_id`: an event is claimed there before processing, and a duplicate of a handled event is a 200 no-op. `instance_members` is unique on `(instance_id, customer_user_id)` — one person holds at most one user in one instance, which makes a grant idempotent-by-refusal rather than silently doubling a projection. It is an _index_ of the control-plane rows, not the authority (see "Grants project, they do not federate"). Admin has no migrations collection; `models.Backfill` runs on every boot and is idempotent by filtering on the absence of what it writes.
|
||||
@@ -537,7 +589,9 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a
|
||||
| `PROXY_ADVERTISE_HOST` | no | default `server`; the hostname guacd resolves the control plane by, handed to guacd as the relay's address. Wrong here and every console session fails at connect |
|
||||
| `PROXY_LISTEN_HOST` | no | default `0.0.0.0`; the interface the ephemeral relay listener binds |
|
||||
| `APP_ROOT_LABEL` | no | default `vantage`; wrong value disables the host/session org guard |
|
||||
| `VANTAGE_WORKFLOW_LOG_DIR` | no | where run logs are written |
|
||||
| `POD_IP` | no | this pod's own address, set by the Helm chart from the downward API. **Takes precedence over `PROXY_ADVERTISE_HOST`** — a console relay listener belongs to one replica, and a Service address names all of them |
|
||||
| `VANTAGE_MIGRATE_ONLY` | no | run schema setup (migrations, index builders, default-step seeding) and exit without serving. `GRPC_HOST` is not required in this mode. Set by the Helm chart's pre-upgrade Job |
|
||||
| `VANTAGE_SKIP_MIGRATIONS` | no | serve without running schema setup, on the assumption a Job already did. Set by the chart's Deployment whenever `server.migrationJob.enabled`. Unset under Compose, where one process still migrates and then serves |
|
||||
| `FREE_INSTANCE_REAP_AFTER` | no | duration past a Free licence's expiry before the instance and all its data are deleted. **Empty disables the reaper, and empty is the default.** Set to `336h` in `docker-compose.site.yml` only — a self-hosted deployment must never reap. Must match admin's value, which only names the date in warning emails |
|
||||
|
||||
**sitesvc** (`deploy/docker-compose.site.yml` only):
|
||||
@@ -670,10 +724,24 @@ cd /opt/vantage && docker compose -f docker-compose.yml -f docker-compose.site.y
|
||||
|
||||
The gap this leaves: **changing a repo variable pushes no commit, so nothing rebuilds.** After editing `ADMIN_API_URL`, `HQ_URL` or `ADMIN_ENV`, run the workflow manually — that is what `workflow_dispatch` is there for. Base images also stop being refreshed on a service nobody touches; a periodic manual run covers that.
|
||||
|
||||
### `chart-release.yml` — validates on every chart change, publishes on `chart/v*` tags
|
||||
|
||||
Two jobs' worth of work in one, split by trigger. Any push or PR touching `deploy/chart/` lints the chart and renders it four ways: defaults, a multi-replica install, external Redis and MongoDB, and a set of values that **must be refused**. That last one is the point — every safety rail in this chart is a template `fail`, and `helm lint` happily accepts a chart whose templates never execute, so only rendering proves they still fire.
|
||||
|
||||
Publishing runs only on a `chart/v*` tag, to the Gitea Helm registry at `/api/packages/<owner>/helm/api/charts`. **`Chart.yaml` is the source of truth for the version**; the tag only selects which one to publish, and a tag that disagrees with `Chart.yaml` fails rather than stamping over it — the alternative leaves the repository disagreeing with what shipped. A version already in the registry is rejected by Gitea, which is intended: published chart versions are immutable.
|
||||
|
||||
The registry host comes from `github.server_url`, so it cannot drift from the instance the workflow is running on, and it reuses `REGISTRY_USER` / `REGISTRY_PASSWORD` — the same `write:packages` token the images use.
|
||||
|
||||
```bash
|
||||
helm repo add vantage https://gitea.hostxtra.co.uk/api/packages/mrhid6/helm
|
||||
helm install vantage vantage/vantage --version 0.1.0
|
||||
```
|
||||
|
||||
### Tagging
|
||||
|
||||
```bash
|
||||
git tag agent/v1.0.0 && git push origin agent/v1.0.0 # agent release
|
||||
git tag chart/v0.1.0 && git push origin chart/v0.1.0 # helm chart package
|
||||
git push origin main # server + web deploy
|
||||
```
|
||||
|
||||
@@ -717,6 +785,7 @@ git push origin main # server + web deploy
|
||||
- **`org_id` on every document** — isolation enforced at the query layer, not by separate databases.
|
||||
- **root only** — manages `/root/.ssh/authorized_keys`; no per-user key management.
|
||||
- **Windows agents are second-class by design** — register, heartbeat, run steps, report inventory; no `authorized_keys` management.
|
||||
- **Both `server` and `web` scale horizontally** — see "Running more than one server replica" below. `web` holds nothing; `server` holds per-agent state that is routed between replicas over Redis rather than duplicated.
|
||||
- **Deletion lives in the control plane** — admin sends the warnings because it knows the billing address; the control plane performs the delete because it is the only service that knows which collections carry `instance_id`. Mirroring that list into admin would drift, and a drift there deletes the wrong rows.
|
||||
|
||||
## graphify
|
||||
|
||||
Reference in New Issue
Block a user