refactor: move vantagectl to vantage-ctl

vantagectl/ becomes the root of gitea.hostxtra.co.uk/vantage/vantage-ctl.
The command keeps the name vantagectl; only the repository and the image
path change, to vantage/vantage-ctl.

chart-release.yml's render checks are repointed at the new image. The
chart itself has no default backup.image and fails loudly without one, so
an existing cluster keeps working until someone changes the value.

go.work stays, now with a single use ./server entry: without it a go.work
further up the developer's filesystem is picked up instead.
This commit is contained in:
2026-09-08 09:07:14 +00:00
parent 44d9036440
commit e0cc3988fc
19 changed files with 67 additions and 1380 deletions
+62 -111
View File
@@ -58,10 +58,11 @@ vantage/
│ └── lib/ # api client, guac console, query client
├── installer/ # Windows: setup.ps1, nssm.exe, WiX .wxs
├── deploy/ # docker-compose.yml, Helm chart
└── .gitea/workflows/ # server-deploy.yml, chart-release.yml, vantagectl-release.yml
└── .gitea/workflows/ # server-deploy.yml, chart-release.yml
```
**Four repositories carry parts of Vantage that this one does not.**
**Five repositories carry parts of Vantage that this one does not.** What is
left here is the control plane and its UI, and nothing else.
| Repository | What it holds |
| ---------------- | ------------------------------------------------------------------------------------------------- |
@@ -70,6 +71,7 @@ vantage/
| `vantage-site` | the marketing site (`web/`, was `site/`) and its contact-form service (`server/`, was `sitesvc/`) |
| `vantage-docs` | the user documentation, at the repository root (was `docsite/`) |
| `vantage-agent` | the agent, at the repository root (was `agent/`), and the Windows `installer/` |
| `vantage-ctl` | `vantagectl`, the backup and restore CLI, at the repository root (was `vantagectl/`) |
**None of the three is a build dependency of anything here**, and nothing here
is a dependency of them. `vantage-site` and `vantage-docs` are wholly
@@ -90,9 +92,9 @@ email system: transport plus templates), `license/` (payload, sign, verify,
trusted keys, plans), `models/` (Instance, User, Settings), `provision/`,
`backup/`, `cryptobox/`, `indexes/`, `grpc/pb` + `grpc/codec`, and
`cmd/lkctl/`, and `proto/vantage/v1/vantage.proto`, which documents `grpc/pb`
and moved there to sit beside it. Two modules here depend on it — `server` and
`vantagectl` — each pinning a version in its own `go.mod`, as do
`vantage-admin`, `vantage-site` and `vantage-agent`. It was a
and moved there to sit beside it. **One** module here depends on it — `server`
pinning a version in its own `go.mod`, as do `vantage-admin`, `vantage-site`,
`vantage-agent` and `vantage-ctl`. It was a
directory in this repository until it was extracted with its history; the
`replace ../shared` directives and the `./shared` entry in `go.work` are gone
with it.
@@ -115,9 +117,14 @@ arg, because an arg survives in the builder layer's history and this one is a
Gitea token. Locally, either a netrc or
`git config --global url."git@gitea.hostxtra.co.uk:".insteadOf https://gitea.hostxtra.co.uk/`.
**`vantagectl` builds from its own directory as a result.** `server` still
builds from the repository root, and only because its runtime stage copies
`default_steps/`.
**`server` still builds from the repository root**, and only because its runtime
stage copies `default_steps/`. It is the only image this repository builds from
a context wider than one directory.
`go.work` survives with a single `use ./server` entry. That looks pointless and
is not: without it, a `go.work` further up the developer's filesystem is picked
up instead and the build fails with `directory prefix . does not contain modules
listed in go.work`.
---
@@ -608,102 +615,46 @@ inside each — all name that repository. They must agree with wherever
### Backup and restore
`vantagectl` is a standalone Go module (`vantagectl/`), not a subcommand of
`server`. It needs its own module rather than living inside `server`'s for the
same reason `admin` and `sitesvc` did before they left: `server` imports the rest of
`server`'s dependency graph, and `spf13/cobra` has no business in a process
that also terminates gRPC streams and serves the REST API. More to the point,
`vantagectl` has to run when the control plane **does not** — a backup or
restore against a database with no server container alive at all — so it
cannot be a mode of the binary whose crash is the reason you need it.
`vantagectl` is `vantage-ctl` now — its own repository, with the command still
named `vantagectl`. It is not a subcommand of `server` and never was: `server`
imports the whole control-plane dependency graph, `spf13/cobra` has no business
in a process that also terminates gRPC streams, and above all **it has to run
when the control plane does not**. A backup or restore against a database with
no server container alive is the normal case, so it cannot be a mode of the
binary whose failure is the reason you reached for it.
The actual logic lives in `shared/backup` (dump, restore, verify, manifest,
fingerprint), not in `vantagectl/internal/cmd`, which holds only argument
parsing and operator-facing output. That split is what lets `server` import
`shared/backup` later — a scheduled in-process backup, say — without a second
implementation to keep in sync. `shared/cryptobox` is the same move one layer
down: it is now the **single** AES-256-GCM implementation, and
`server/internal/services/crypto.go` delegates to it rather than keeping its
own copy that `shared/backup` would otherwise have had to duplicate to decrypt
a probe value during `verify`.
Almost none of its logic is in that repository either: dump, restore, verify,
manifest and fingerprint are `shared/backup` in `vantage-shared`, and
`internal/cmd` holds only argument parsing and operator-facing output. That
split is what would let `server` import `shared/backup` later — a scheduled
in-process backup, say — without a second implementation to keep in sync.
`shared/cryptobox` is the same move one layer down: it is the **single**
AES-256-GCM implementation, and `server/internal/services/crypto.go` delegates
to it rather than keeping its own copy that `shared/backup` would otherwise have
had to duplicate to decrypt a probe value during `verify`.
**The archive stores a SHA-256 fingerprint of `KEY_ENCRYPTION_KEY`, never the
key.** `backup` refuses to run without the key set in the environment unless
`--allow-no-key` is passed, because an archive with no fingerprint at all
cannot later tell a restore that the wrong key is in hand — it can only find
that out when the data comes back as noise. The fingerprint is what turns that
failure into a refusal at `restore` time instead.
**The one thing this repository owes it is `backup.ciphertextFields`**, which
lives in `vantage-shared` and mirrors `server/internal/models` **by hand**
`shared/` is a separate module and cannot import it. The map naming each
collection's `*_enc` fields (`keys`, `secrets`, `auth_providers`,
`console_sessions`) must change in the same commit as any of those bson tags,
and that commit is now in a different repository from the tags it tracks. Wrong
field names are **silent**: `verify`'s live probe finds no ciphertext and
reports "this database stores no ciphertext yet", so the one gate that catches
what a key fingerprint cannot becomes a no-op. `settings` is deliberately in
neither that map nor `CiphertextCollections()` — its ESO read token is a
SHA-256 hash, not ciphertext.
**Collections are enumerated live**`shared/backup` lists what the database
actually holds rather than reading `services.ScopedCollections`, the opposite
choice from the one instance-deletion purge makes. Purge must never miss a
tenant-scoped collection, so it keeps one hand-maintained registry; a backup
must never miss **any** collection, tenant-scoped or not (`migrations`,
`vulndb_meta`), so a static list is the wrong shape twice over — once for the
collections it would still owe `instance_id` deletion but not a backup, and
once for the two singleton collections that carry neither `instance_id` nor a
release note.
The chart's optional backup CronJob runs that image, and **`backup.image` has
no default** — the template `fail`s without one rather than guessing, so a
cluster set up before the move keeps working until someone changes the value.
It is `gitea.hostxtra.co.uk/vantage/vantage-ctl:latest` now, was
`mrhid6/vantage/vantagectl:latest`; `chart-release.yml`'s render checks name the
new path.
**Restore refuses a non-empty target database and has no merge semantics.**
There is no code path that upserts an archive's documents over existing ones:
merging two control planes' data reconciles nothing about which SSH keys are
still valid or which users still exist, and an upsert would resurrect a
revoked key or a deleted member from the older side. `--force` drops each
collection in the archive first, and is gated behind a second assurance:
`--confirm-db NAME` matching the target exactly, which works everywhere, or —
on a terminal only, and only when `--confirm-db` was not given — the target
database's name typed back at a prompt. `--confirm-db` is accepted on a
terminal too: it is the stronger of the two, because naming the target in the
command itself means a copied command carries its intended target with it and
cannot destroy a different one by accident. Without a terminal and without
`--confirm-db`, `--force` is refused.
**`--force` drops only what the archive names.** Collections already in the
target that the archive does not carry are left untouched and **named in a
warning** — an archive taken with `--exclude workflow_log_lines` restored over
a live database leaves the old lines joined to restored runs, which the
operator must be told. Dropping them instead would delete data nobody asked to
delete, and there is no way back from that.
**Index specifications are replayed verbatim, never reconstructed.**
`dumpIndexes` stores each spec as extended JSON over the raw BSON the server
reported, and `replayIndexes` hands it back to `createIndexes` through
`RunCommand` with only `v` and `ns` stripped and `_id_` skipped. Rebuilding a
`mongo.IndexModel` from a hand-picked set of options dropped
`partialFilterExpression` — which this codebase relies on in
`services/workflows.go` and `services/settings.go` — so a partial unique index
came back as a full one, failed on duplicate keys, and aborted the restore
mid-write. Reconstructing the key document from JSON also lost compound key
order, which is significant.
**`backup.ciphertextFields` mirrors `server/internal/models` by hand.**
`shared/` is a separate module — a separate *repository* now — and `models` is
under `server/internal`, so `shared/backup` cannot import it; the map naming each collection's `*_enc`
fields (`keys`, `secrets`, `auth_providers`, `console_sessions`) must change in
the same commit as any of those bson tags, the same hazard as
`web/lib/targets.ts` and `services.MaxWorkloadLogLines`. Wrong field names are
silent: `verify`'s live probe simply finds no ciphertext and reports "this
database stores no ciphertext yet", so the one gate that catches what a
fingerprint cannot no-ops. `settings` is deliberately in neither that map nor
`CiphertextCollections()` — its ESO read token is a SHA-256 hash, not
ciphertext.
**A file-backed `backup` writes to `<name>.tar.gz.partial` and renames on
success**, the same discipline the agent uses for `authorized_keys`. A failed
dump must not leave a partial file named exactly like a good archive; `--out -`
is untouched, since a broken pipe has no file to mislead anyone.
**`vantagectl/Dockerfile`'s runtime stage is `scratch`, and needs the same
explicit `/tmp` as `server/Dockerfile`.** `restore` extracts an archive to a
temporary directory before verifying its checksums, and a scratch image has no
`/tmp` for `os.MkdirTemp` to find — the same failure mode `vulnsched` hits on
`server`, but here it would break every restore rather than only vulnerability
scanning.
**Nothing in this repository rebuilds when `vantage-shared` changes.** It is an
external module, so a `shared/backup` fix reaches `vantagectl` when somebody
bumps `vantagectl/go.mod` and cuts a `vantagectl/v*` tag, and reaches nothing
else until its pin moves too — see the CI section below.
The rest — the refusals around `--force` and `--confirm-db`, the key
fingerprint, live collection enumeration, verbatim index replay, the `.partial`
rename — is documented in `vantage-ctl`.
### API tokens and OpenAPI
@@ -1329,7 +1280,7 @@ lives — one copy instead of the three that existed while they were apart.
### `server-deploy.yml` — triggered on every push to `main`
Builds and pushes **two** images to the Gitea container registry: `server` and `web`. Everything else that used to be built here now belongs to the repository that owns it — `vantage-site`, `vantage-docs` and `vantage-admin` each publish their own. **`vantagectl` is also not among them** — it is a released tool rather than a running service, and its image is version-tagged by `vantagectl-release.yml`.
Builds and pushes **two** images to the Gitea container registry: `server` and `web`. That is now the whole of this workflow. Everything else that was once built here belongs to the repository that owns it — `vantage-site`, `vantage-docs`, `vantage-admin` and `vantage-ctl` each publish their own, and `vantage-agent` cuts releases rather than images.
Note that despite the name, **this workflow does not deploy** — it only builds and pushes. There is no SSH step. Rolling images out is a separate manual step on the host:
@@ -1358,11 +1309,10 @@ you a pin is stale.
Every Go build in these workflows writes a netrc from `REGISTRY_USER` +
`RELEASE_TOKEN` before it runs, and sets `GOPRIVATE=gitea.hostxtra.co.uk/*`.
There are **three** such places here, and each needs its own because jobs do not
share a filesystem: `server-deploy.yml`'s single job and both jobs of
`vantagectl-release.yml`. The other repositories each carry their own —
`vantage-agent`'s `msi` job is the one to remember, because it is Windows, where
Go reads `%USERPROFILE%\_netrc` and not `.netrc`. The docker builds pass
There is exactly **one** such place left here: `server-deploy.yml`'s single job.
The other repositories each carry their own, one per job, because jobs do not
share a filesystem — `vantage-agent`'s `msi` job is the one to remember, because
it is Windows, where Go reads `%USERPROFILE%\_netrc` and not `.netrc`. The docker builds pass
it on as `--secret id=netrc`, never a build arg. **`RELEASE_TOKEN` needs read
access to the `vantage` org** on top of its existing scopes; without it every Go
build fails at `go mod download` with a 404 on the module, which reads like a
@@ -1389,13 +1339,14 @@ helm install vantage vantage/vantage --version 0.1.0
### Tagging
```bash
git tag chart/v0.1.0 && git push origin chart/v0.1.0 # helm chart package
git tag vantagectl/v0.1.0 && git push origin vantagectl/v0.1.0 # vantagectl release
git push origin main # server + web deploy
git tag chart/v0.1.0 && git push origin chart/v0.1.0 # helm chart package
git push origin main # server + web deploy
```
The agent is tagged in `vantage-agent`, still as `agent/v*` — that prefix is
what the control plane greps release tag names for, so it survived the move.
Only two things are tagged here now. Elsewhere: `vantage-agent` keeps the
`agent/v*` prefix, because the control plane greps release tag names for exactly
that string; `vantage-ctl` dropped its prefix for a bare `v*`, because nothing
reads it programmatically.
### Secrets / variables