diff --git a/.gitea/workflows/chart-release.yml b/.gitea/workflows/chart-release.yml index a94810e..a6f17f1 100644 --- a/.gitea/workflows/chart-release.yml +++ b/.gitea/workflows/chart-release.yml @@ -80,7 +80,7 @@ jobs: run: | helm template test "$CHART_DIR" \ --set backup.enabled=true \ - --set backup.image=gitea.hostxtra.co.uk/mrhid6/vantage/vantagectl:latest \ + --set backup.image=gitea.hostxtra.co.uk/vantage/vantage-ctl:latest \ --set backup.pvcName=vantage-backups > /dev/null - name: Render against external Redis and MongoDB @@ -158,7 +158,7 @@ jobs: --set ingress.grpc.host=agents.example.com refuses "backup enabled with no pvcName" \ --set backup.enabled=true \ - --set backup.image=gitea.hostxtra.co.uk/mrhid6/vantage/vantagectl:latest + --set backup.image=gitea.hostxtra.co.uk/vantage/vantage-ctl:latest refuses "backup enabled with no image" \ --set backup.enabled=true \ --set backup.pvcName=vantage-backups diff --git a/.gitea/workflows/vantagectl-release.yml b/.gitea/workflows/vantagectl-release.yml deleted file mode 100644 index 019a4d2..0000000 --- a/.gitea/workflows/vantagectl-release.yml +++ /dev/null @@ -1,133 +0,0 @@ -name: vantagectl Release - -on: - push: - tags: - - "vantagectl/v*" - -jobs: - build: - runs-on: ubuntu-docker - container: node:26 - env: - GOPRIVATE: gitea.hostxtra.co.uk/* - steps: - - name: Checkout - uses: actions/checkout@v4 - - # vantage-shared is a private module, so the Go builds below cannot - # resolve it without a credential. - - name: Write the module fetch credential - run: | - umask 077 - printf 'machine gitea.hostxtra.co.uk\nlogin %s\npassword %s\n' \ - "${{ secrets.REGISTRY_USER }}" "${{ secrets.RELEASE_TOKEN }}" \ - > "$HOME/.netrc" - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: "1.26" - cache: true - cache-dependency-path: vantagectl/go.sum - - - name: Extract version - id: version - run: echo "VERSION=${GITHUB_REF_NAME#vantagectl/}" >> $GITHUB_OUTPUT - - - name: Test - working-directory: vantagectl - run: go test ./... - - - name: Build - working-directory: vantagectl - env: - VERSION: ${{ steps.version.outputs.VERSION }} - run: | - mkdir -p dist - for target in linux/amd64 linux/arm64 darwin/arm64 windows/amd64; do - goos="${target%/*}" - goarch="${target#*/}" - out="dist/vantagectl-${goos}-${goarch}" - if [ "$goos" = "windows" ]; then out="${out}.exe"; fi - CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build \ - -ldflags="-s -w -X main.Version=${VERSION}" \ - -o "$out" . - done - - - name: Checksums - working-directory: vantagectl/dist - run: sha256sum vantagectl-* > checksums.txt - - - name: Create release - uses: https://gitea.com/actions/gitea-release-action@v1 - with: - token: ${{ secrets.RELEASE_TOKEN }} - files: | - vantagectl/dist/vantagectl-linux-amd64 - vantagectl/dist/vantagectl-linux-arm64 - vantagectl/dist/vantagectl-darwin-arm64 - vantagectl/dist/vantagectl-windows-amd64.exe - vantagectl/dist/checksums.txt - - # The image is built here rather than in server-deploy.yml on every push to - # main, because vantagectl is a released tool rather than a running service. - # An operator restoring a database should be able to name the version they - # ran; ":latest, rebuilt whenever main moved" cannot be named after the - # fact. It is a separate job from the binaries because it needs a - # docker-capable runner rather than a Go one, and it does not need the - # binaries — the image builds from source in its own stage. - image: - runs-on: ubuntu-docker - container: docker:dind - steps: - - name: Setup - run: apk add --update nodejs npm git - - - name: Checkout - uses: actions/checkout@v4 - - - name: Extract version - id: version - run: | - # v0.1.0 for the binary stamp, 0.1.0 for the image tag: a - # leading v is conventional on a git tag and unconventional on - # a container tag. - VERSION="${GITHUB_REF_NAME#vantagectl/}" - echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT - echo "IMAGE_TAG=${VERSION#v}" >> $GITHUB_OUTPUT - - # The image build resolves the private vantage-shared module, so - # it needs a credential of its own — this job does not share the - # build job's filesystem. - - name: Write the module fetch credential - run: | - umask 077 - printf 'machine gitea.hostxtra.co.uk\nlogin %s\npassword %s\n' \ - "${{ secrets.REGISTRY_USER }}" "${{ secrets.RELEASE_TOKEN }}" \ - > "$HOME/.netrc" - - - name: Log in to registry - run: | - echo "${{ secrets.RELEASE_TOKEN }}" | \ - docker login ${{ vars.DOCKER_HOST }} \ - -u "${{ secrets.REGISTRY_USER }}" --password-stdin - - - name: Build and push image - env: - VERSION: ${{ steps.version.outputs.VERSION }} - IMAGE_TAG: ${{ steps.version.outputs.IMAGE_TAG }} - run: | - REPO="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/vantagectl" - # VERSION is passed through so `vantagectl --version` inside the - # image reports the tag it was built from rather than "dev". - # The context is vantagectl/ now that shared/ is an external - # module rather than a sibling directory. - docker build \ - --build-arg VERSION="${VERSION}" \ - --secret id=netrc,src="$HOME/.netrc" \ - -t "${REPO}:${IMAGE_TAG}" \ - -t "${REPO}:latest" \ - -f vantagectl/Dockerfile vantagectl/ - docker push "${REPO}:${IMAGE_TAG}" - docker push "${REPO}:latest" diff --git a/CLAUDE.md b/CLAUDE.md index 448a743..0ad8591 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `.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 diff --git a/go.work b/go.work index 240c5aa..4e24504 100644 --- a/go.work +++ b/go.work @@ -2,5 +2,4 @@ go 1.26 use ( ./server - ./vantagectl ) diff --git a/go.work.sum b/go.work.sum index e8e7d70..2143cee 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,6 +1,5 @@ cloud.google.com/go/compute v1.25.1/go.mod h1:oopOIR53ly6viBYxaDhBfJwzUAxf1zE//uf3IB011ls= cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= -gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0/go.mod h1:dWjeOFLltQ8sv9Pnn1xRxGfWGgqa2fkG0esuaJLoPXQ= github.com/Intevation/gval v1.3.0/go.mod h1:xmGyGpP5be12EL0P12h+dqiYG8qn2j3PJxIgkoOHO5o= github.com/Intevation/jsonpath v0.2.1/go.mod h1:WnZ8weMmwAx/fAO3SutjYFU+v7DFreNYnibV7CiaYIw= github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= @@ -31,6 +30,7 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/josephburnett/jd/v2 v2.3.0/go.mod h1:0I5+gbo7y8diuajJjm79AF44eqTheSJy1K7DSbIUFAQ= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= @@ -42,6 +42,7 @@ github.com/package-url/packageurl-go v0.1.3/go.mod h1:nKAWB8E6uk1MHqiS/lQb9pYBGH github.com/pandatix/go-cvss v0.6.2/go.mod h1:jDXYlQBZrc8nvrMUVVvTG8PhmuShOnKrxP53nOFkt8Q= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= @@ -67,6 +68,7 @@ golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57/go.mod h1:3AWMyWHS+caVoiEXpiq6+tzKA40J4vQT3MYr80ZtQpc= golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= diff --git a/vantagectl/.dockerignore b/vantagectl/.dockerignore deleted file mode 100644 index 7de6554..0000000 --- a/vantagectl/.dockerignore +++ /dev/null @@ -1,5 +0,0 @@ -# The build context is this directory now, so local build output would -# otherwise be shipped into the builder stage on every build. -dist -.env -*.lic diff --git a/vantagectl/Dockerfile b/vantagectl/Dockerfile deleted file mode 100644 index c17e5cb..0000000 --- a/vantagectl/Dockerfile +++ /dev/null @@ -1,41 +0,0 @@ -# Build stage -# -# Context is vantagectl/ itself. It used to be the repository root, so that -# shared/ could be copied in beside it; shared is now the private module -# gitea.hostxtra.co.uk/vantage/vantage-shared, fetched like any other -# dependency. The credential for it arrives as a BuildKit secret rather than a -# build arg, which would be baked into this stage's layer history. -FROM golang:1.26 AS builder - -WORKDIR /src - -ENV GOPRIVATE=gitea.hostxtra.co.uk/* - -# Manifests first so the dependency layer caches independently of source edits. -COPY go.mod go.sum ./ -RUN --mount=type=secret,id=netrc,target=/root/.netrc \ - go mod download - -COPY . . - -ARG VERSION=dev -RUN --mount=type=secret,id=netrc,target=/root/.netrc \ - CGO_ENABLED=0 GOOS=linux go build \ - -ldflags="-s -w -X main.Version=${VERSION}" -o /vantagectl . - -# Staged so the scratch image below can have a /tmp. It cannot mkdir one -# itself — scratch has no shell. -RUN mkdir -p /staging/tmp && chmod 1777 /staging/tmp - -# Runtime stage -FROM scratch - -COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ - -# restore extracts an archive here before verifying its checksums, and backup -# stages nothing but still inherits os.MkdirTemp's requirements. Without this -# every restore stops at "temp dir: stat /tmp: no such file or directory". -COPY --from=builder /staging/tmp /tmp -COPY --from=builder /vantagectl /vantagectl - -ENTRYPOINT ["/vantagectl"] diff --git a/vantagectl/go.mod b/vantagectl/go.mod deleted file mode 100644 index 52ebce7..0000000 --- a/vantagectl/go.mod +++ /dev/null @@ -1,24 +0,0 @@ -module gitea.hostxtra.co.uk/mrhid6/vantage/vantagectl - -go 1.26 - -require ( - gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0 - github.com/spf13/cobra v1.10.2 - go.mongodb.org/mongo-driver/v2 v2.8.0 - golang.org/x/term v0.45.0 -) - -require ( - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/klauspost/compress v1.17.6 // indirect - github.com/spf13/pflag v1.0.9 // indirect - github.com/xdg-go/pbkdf2 v1.0.0 // indirect - github.com/xdg-go/scram v1.2.0 // indirect - github.com/xdg-go/stringprep v1.0.4 // indirect - github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect - golang.org/x/crypto v0.54.0 // indirect - golang.org/x/sync v0.22.0 // indirect - golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.40.0 // indirect -) diff --git a/vantagectl/go.sum b/vantagectl/go.sum deleted file mode 100644 index 880b1a5..0000000 --- a/vantagectl/go.sum +++ /dev/null @@ -1,62 +0,0 @@ -gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0 h1:H6PCb8JHucrRiqPe9kGOhXUjBD66tKFHCP3qz5TjdZc= -gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0/go.mod h1:dWjeOFLltQ8sv9Pnn1xRxGfWGgqa2fkG0esuaJLoPXQ= -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI= -github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= -github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= -github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= -github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= -github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= -github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= -github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= -github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= -github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= -github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8= -go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= -golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/vantagectl/internal/cmd/backup.go b/vantagectl/internal/cmd/backup.go deleted file mode 100644 index a4ecd5c..0000000 --- a/vantagectl/internal/cmd/backup.go +++ /dev/null @@ -1,156 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - "io" - "os" - "path/filepath" - "time" - - "gitea.hostxtra.co.uk/vantage/vantage-shared/backup" - "github.com/spf13/cobra" -) - -func newBackupCmd() *cobra.Command { - var ( - out string - exclude []string - allowNoKey bool - ) - - c := &cobra.Command{ - Use: "backup", - Short: "Write an archive of the database", - Long: "backup writes every collection in the database to a gzipped tar\n" + - "archive, along with a fingerprint of KEY_ENCRYPTION_KEY.\n\n" + - "The key itself is never written. The fingerprint is what lets a later\n" + - "restore refuse rather than produce a database whose secrets nobody\n" + - "can read.\n\n" + - "Pass --out - to stream to stdout, which is how this composes with\n" + - "restic, age, or aws s3 cp -.", - Args: cobra.NoArgs, - RunE: func(c *cobra.Command, _ []string) error { - ctx := c.Context() - g, err := resolveGlobals(c) - if err != nil { - return err - } - client, err := connect(ctx, g) - if err != nil { - return err - } - defer client.Disconnect(context.Background()) - - w, dest, name, err := backupDestination(out, g.Database) - if err != nil { - return err - } - committed := false - defer func() { - if !committed { - dest.Cleanup() - } - }() - - m, err := backup.Dump(ctx, backup.DumpOptions{ - Client: client, - Database: g.Database, - Exclude: exclude, - KeyHex: g.KeyHex, - AllowNoKey: allowNoKey, - VantageVersion: c.Root().Version, - Out: w, - }) - if err != nil { - return err - } - if err := dest.Commit(); err != nil { - return err - } - committed = true - - // Progress goes to stderr so --out - stays a clean pipe. - var docs int64 - for _, coll := range m.Collections { - docs += coll.Documents - } - fmt.Fprintf(c.ErrOrStderr(), "wrote %s: %d collections, %d documents\n", - name, len(m.Collections), docs) - if m.KeyFingerprint == nil { - fmt.Fprintln(c.ErrOrStderr(), - "warning: no key recorded; nothing in this archive proves its "+ - "ciphertext can ever be read") - } - return nil - }, - } - - c.Flags().StringVar(&out, "out", ".", "directory to write the archive into, or - for stdout") - c.Flags().StringSliceVar(&exclude, "exclude", nil, - "collections to leave out, comma separated (recorded in the manifest)") - c.Flags().BoolVar(&allowNoKey, "allow-no-key", false, - "back up without KEY_ENCRYPTION_KEY set; only for a deployment storing no encrypted data") - return c -} - -// backupDestination resolves --out to a writer, a closer and a name to print. -func backupDestination(out, database string) (io.Writer, destination, string, error) { - if out == "-" { - return os.Stdout, stdoutDestination{}, "stdout", nil - } - name := archiveName(database, time.Now().UTC()) - path := filepath.Join(out, name) - // Written under a temporary name and renamed on success, the same - // discipline the agent uses for authorized_keys: a failed backup must not - // leave a partial file named exactly like a good archive. - tmp := path + ".partial" - f, err := os.OpenFile(tmp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) - if err != nil { - return nil, nil, "", fmt.Errorf("create %s: %w", tmp, err) - } - d := &fileDestination{f: f, tmp: tmp, final: path} - return f, d, path, nil -} - -// fileDestination finishes a file-backed backup. Commit renames the temporary -// file into place; Cleanup removes it if Commit was never called. -type fileDestination struct { - f *os.File - tmp string - final string -} - -func (d *fileDestination) Commit() error { - if err := d.f.Close(); err != nil { - return fmt.Errorf("close %s: %w", d.tmp, err) - } - if err := os.Rename(d.tmp, d.final); err != nil { - return fmt.Errorf("rename %s: %w", d.tmp, err) - } - return nil -} - -func (d *fileDestination) Cleanup() { - d.f.Close() - os.Remove(d.tmp) -} - -// destination is how the two --out modes finish. stdout commits by doing -// nothing; there is no partial file to clean up either. -type destination interface { - Commit() error - Cleanup() -} - -type stdoutDestination struct{} - -func (stdoutDestination) Commit() error { return nil } -func (stdoutDestination) Cleanup() {} - -// archiveName is sortable and carries no colon, because an operator will copy -// these onto a Windows share sooner or later and a colon is not a legal -// filename character there. -func archiveName(database string, at time.Time) string { - return fmt.Sprintf("vantage-backup-%s-%s.tar.gz", database, at.Format("20060102T150405Z")) -} diff --git a/vantagectl/internal/cmd/inspect.go b/vantagectl/internal/cmd/inspect.go deleted file mode 100644 index 29f2284..0000000 --- a/vantagectl/internal/cmd/inspect.go +++ /dev/null @@ -1,78 +0,0 @@ -package cmd - -import ( - "fmt" - "io" - "strings" - "text/tabwriter" - - "gitea.hostxtra.co.uk/vantage/vantage-shared/backup" - "github.com/spf13/cobra" -) - -func newInspectCmd() *cobra.Command { - return &cobra.Command{ - Use: "inspect ARCHIVE", - Short: "Print an archive's manifest", - Long: "inspect reads an archive and prints what it holds. It contacts no\n" + - "database, so it is safe to run against an archive of unknown origin\n" + - "and is the fastest way to find out whether one is worth anything.", - Args: cobra.ExactArgs(1), - RunE: func(c *cobra.Command, args []string) error { - archive, err := backup.Open(args[0]) - if err != nil { - return err - } - defer archive.Close() - renderManifest(c.OutOrStdout(), archive.Manifest()) - return nil - }, - } -} - -// renderManifest prints a manifest for a human. -func renderManifest(w io.Writer, m backup.Manifest) { - fmt.Fprintf(w, "Created %s\n", m.CreatedAt.UTC().Format("2006-01-02 15:04:05 MST")) - fmt.Fprintf(w, "Database %s\n", m.MongoDB) - fmt.Fprintf(w, "MongoDB %s\n", m.MongoServerVersion) - fmt.Fprintf(w, "Written by vantagectl %s on %s\n", m.VantageVersion, m.Hostname) - fmt.Fprintf(w, "Format version %d\n", m.FormatVersion) - - if m.KeyFingerprint == nil { - fmt.Fprintf(w, "Key none recorded — this archive cannot be checked "+ - "against any KEY_ENCRYPTION_KEY\n") - } else { - fmt.Fprintf(w, "Key %s\n", *m.KeyFingerprint) - } - if len(m.Excluded) > 0 { - fmt.Fprintf(w, "Excluded %s\n", strings.Join(m.Excluded, ", ")) - } - - var docs, bytes int64 - for _, c := range m.Collections { - docs += c.Documents - bytes += c.Bytes - } - fmt.Fprintf(w, "\n%d collections, %d documents, %s\n\n", - len(m.Collections), docs, humanBytes(bytes)) - - tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) - fmt.Fprintln(tw, "COLLECTION\tDOCUMENTS\tSIZE") - for _, c := range m.Collections { - fmt.Fprintf(tw, "%s\t%d\t%s\n", c.Name, c.Documents, humanBytes(c.Bytes)) - } - tw.Flush() -} - -func humanBytes(n int64) string { - const unit = 1024 - if n < unit { - return fmt.Sprintf("%d B", n) - } - div, exp := int64(unit), 0 - for v := n / unit; v >= unit; v /= unit { - div *= unit - exp++ - } - return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGTP"[exp]) -} diff --git a/vantagectl/internal/cmd/inspect_test.go b/vantagectl/internal/cmd/inspect_test.go deleted file mode 100644 index 0b1d837..0000000 --- a/vantagectl/internal/cmd/inspect_test.go +++ /dev/null @@ -1,116 +0,0 @@ -package cmd - -import ( - "bytes" - "os" - "strings" - "testing" - "time" - - "gitea.hostxtra.co.uk/vantage/vantage-shared/backup" -) - -func TestArchiveNameIsSortableAndNamesTheDatabase(t *testing.T) { - at := time.Date(2026, 9, 7, 14, 30, 5, 0, time.UTC) - got := archiveName("vantage", at) - if !strings.HasPrefix(got, "vantage-backup-vantage-") { - t.Fatalf("name %q does not name the database", got) - } - if !strings.HasSuffix(got, ".tar.gz") { - t.Fatalf("name %q has the wrong extension", got) - } - if strings.ContainsAny(got, ":") { - t.Fatalf("name %q contains a colon, which Windows will not accept", got) - } - if !strings.Contains(got, "20260907") { - t.Fatalf("name %q does not carry a sortable date", got) - } -} - -func TestRenderManifestShowsWhatMatters(t *testing.T) { - fp := "ab12" - m := backup.Manifest{ - FormatVersion: backup.FormatVersion, - CreatedAt: time.Date(2026, 9, 7, 14, 0, 0, 0, time.UTC), - VantageVersion: "1.4.0", - Hostname: "ops-box", - MongoDB: "vantage", - MongoServerVersion: "7.0.5", - KeyFingerprint: &fp, - Collections: []backup.CollectionEntry{ - {Name: "servers", Documents: 12, Bytes: 4096}, - {Name: "keys", Documents: 3, Bytes: 900}, - }, - Excluded: []string{"audit_logs"}, - } - - var buf bytes.Buffer - renderManifest(&buf, m) - out := buf.String() - - for _, want := range []string{ - "vantage", "1.4.0", "ops-box", "7.0.5", "ab12", - "servers", "12", "keys", "audit_logs", "2026-09-07", - } { - if !strings.Contains(out, want) { - t.Fatalf("inspect output missing %q:\n%s", want, out) - } - } -} - -func TestRenderManifestFlagsAMissingFingerprint(t *testing.T) { - var buf bytes.Buffer - renderManifest(&buf, backup.Manifest{FormatVersion: backup.FormatVersion}) - out := buf.String() - if !strings.Contains(out, "none recorded") { - t.Fatalf("a null fingerprint must be called out, got:\n%s", out) - } - if !strings.Contains(out, "cannot be checked") { - t.Fatalf("a null fingerprint must explain the consequence, got:\n%s", out) - } -} - -// TestBackupDestinationDoesNotLeaveAPartialArchive covers the failure path: a -// backup that errors must not leave a file named exactly like a good archive. -func TestBackupDestinationDoesNotLeaveAPartialArchive(t *testing.T) { - dir := t.TempDir() - w, dest, path, err := backupDestination(dir, "vantage") - if err != nil { - t.Fatalf("backupDestination: %v", err) - } - if _, err := w.Write([]byte("half an archive")); err != nil { - t.Fatalf("write: %v", err) - } - dest.Cleanup() - - entries, err := os.ReadDir(dir) - if err != nil { - t.Fatalf("readdir: %v", err) - } - if len(entries) != 0 { - t.Fatalf("a failed backup left %v behind", entries) - } - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Fatalf("%s exists after a failed backup", path) - } -} - -func TestBackupDestinationRenamesOnCommit(t *testing.T) { - dir := t.TempDir() - w, dest, path, err := backupDestination(dir, "vantage") - if err != nil { - t.Fatalf("backupDestination: %v", err) - } - if _, err := w.Write([]byte("a whole archive")); err != nil { - t.Fatalf("write: %v", err) - } - if err := dest.Commit(); err != nil { - t.Fatalf("Commit: %v", err) - } - if _, err := os.Stat(path); err != nil { - t.Fatalf("committed archive is not at %s: %v", path, err) - } - if _, err := os.Stat(path + ".partial"); !os.IsNotExist(err) { - t.Fatal("the temporary file was left behind") - } -} diff --git a/vantagectl/internal/cmd/restore.go b/vantagectl/internal/cmd/restore.go deleted file mode 100644 index b42a718..0000000 --- a/vantagectl/internal/cmd/restore.go +++ /dev/null @@ -1,134 +0,0 @@ -package cmd - -import ( - "bufio" - "context" - "errors" - "fmt" - "io" - "os" - "strings" - "text/tabwriter" - - "gitea.hostxtra.co.uk/vantage/vantage-shared/backup" - "github.com/spf13/cobra" - "golang.org/x/term" -) - -// ErrNotConfirmed is returned when a destructive restore was not confirmed. -var ErrNotConfirmed = errors.New("restore not confirmed") - -func newRestoreCmd() *cobra.Command { - var ( - force bool - confirmDB string - ignoreKeyErr bool - ) - - c := &cobra.Command{ - Use: "restore ARCHIVE", - Short: "Load an archive into a database", - Long: "restore loads an archive into a MongoDB database.\n\n" + - "The target is expected to be empty. A database that already holds data\n" + - "is refused unless --force is given, which drops each collection in the\n" + - "archive before loading it. There are no merge semantics: merging two\n" + - "control planes reconciles nothing, and upserting would resurrect\n" + - "revoked keys and deleted users.\n\n" + - "Restore does not touch Redis. Sessions are all it holds, so everyone\n" + - "signs in again.", - Args: cobra.ExactArgs(1), - RunE: func(c *cobra.Command, args []string) error { - ctx := c.Context() - g, err := resolveGlobals(c) - if err != nil { - return err - } - - archive, err := backup.Open(args[0]) - if err != nil { - return err - } - defer archive.Close() - - if force { - isTTY := term.IsTerminal(int(os.Stdin.Fd())) - if err := confirmDestruction(c.InOrStdin(), c.OutOrStdout(), isTTY, - confirmDB, g.Database); err != nil { - return err - } - } - - client, err := connect(ctx, g) - if err != nil { - return err - } - defer client.Disconnect(context.Background()) - - res, err := backup.Restore(ctx, backup.RestoreOptions{ - Client: client, - Database: g.Database, - Archive: archive, - Force: force, - KeyHex: g.KeyHex, - IgnoreKeyMismatch: ignoreKeyErr, - Warn: func(s string) { - fmt.Fprintln(c.ErrOrStderr(), "warning:", s) - }, - }) - if err != nil { - return err - } - - tw := tabwriter.NewWriter(c.OutOrStdout(), 0, 0, 2, ' ', 0) - fmt.Fprintln(tw, "COLLECTION\tDOCUMENTS\tINDEXES") - for _, coll := range res.Collections { - fmt.Fprintf(tw, "%s\t%d\t%d\n", coll.Name, coll.Documents, coll.Indexes) - } - tw.Flush() - fmt.Fprintf(c.OutOrStdout(), "\nrestored %d collections into %s\n", - len(res.Collections), g.Database) - return nil - }, - } - - c.Flags().BoolVar(&force, "force", false, - "drop each collection in the archive before loading it") - c.Flags().StringVar(&confirmDB, "confirm-db", "", - "name of the database being overwritten; required with --force when there is no terminal") - c.Flags().BoolVar(&ignoreKeyErr, "ignore-key-mismatch", false, - "restore even though KEY_ENCRYPTION_KEY does not match the archive") - return c -} - -// confirmDestruction gates a --force restore. -// -// On a terminal the operator types the database name. Without one — a -// Kubernetes Job, a CI step, a cron entry — the same assurance comes from -// --confirm-db, whose value must equal the target. Naming the database in the -// argument means a copy-pasted command carries its intended target with it and -// cannot destroy a different one. -func confirmDestruction(in io.Reader, out io.Writer, isTTY bool, confirmDB, database string) error { - if confirmDB != "" { - if confirmDB != database { - return fmt.Errorf("%w: --confirm-db says %q but the target is %q", - ErrNotConfirmed, confirmDB, database) - } - return nil - } - if !isTTY { - return fmt.Errorf("%w: --force with no terminal needs --confirm-db %s", - ErrNotConfirmed, database) - } - - fmt.Fprintf(out, "This drops every collection in the archive from %q and reloads it.\n", database) - fmt.Fprintf(out, "Type the database name to continue: ") - - line, err := bufio.NewReader(in).ReadString('\n') - if err != nil && err != io.EOF { - return fmt.Errorf("%w: %v", ErrNotConfirmed, err) - } - if strings.TrimSpace(line) != database { - return fmt.Errorf("%w: that is not %q", ErrNotConfirmed, database) - } - return nil -} diff --git a/vantagectl/internal/cmd/restore_test.go b/vantagectl/internal/cmd/restore_test.go deleted file mode 100644 index ebd7f90..0000000 --- a/vantagectl/internal/cmd/restore_test.go +++ /dev/null @@ -1,65 +0,0 @@ -package cmd - -import ( - "bytes" - "errors" - "strings" - "testing" -) - -func TestConfirmDestructionNonTTYRequiresMatchingFlag(t *testing.T) { - var out bytes.Buffer - err := confirmDestruction(strings.NewReader(""), &out, false, "", "vantage") - if !errors.Is(err, ErrNotConfirmed) { - t.Fatalf("got %v, want ErrNotConfirmed", err) - } - if !strings.Contains(err.Error(), "--confirm-db vantage") { - t.Fatalf("the error must tell the operator exactly what to pass, got: %v", err) - } -} - -func TestConfirmDestructionNonTTYRejectsWrongDatabase(t *testing.T) { - var out bytes.Buffer - err := confirmDestruction(strings.NewReader(""), &out, false, "staging", "production") - if !errors.Is(err, ErrNotConfirmed) { - t.Fatalf("got %v, want ErrNotConfirmed", err) - } - if !strings.Contains(err.Error(), "production") { - t.Fatalf("the error must name the real target, got: %v", err) - } -} - -func TestConfirmDestructionNonTTYAcceptsMatchingFlag(t *testing.T) { - var out bytes.Buffer - if err := confirmDestruction(strings.NewReader(""), &out, false, "vantage", "vantage"); err != nil { - t.Fatalf("matching --confirm-db rejected: %v", err) - } -} - -func TestConfirmDestructionTTYRequiresTypedName(t *testing.T) { - var out bytes.Buffer - if err := confirmDestruction(strings.NewReader("vantage\n"), &out, true, "", "vantage"); err != nil { - t.Fatalf("typed name rejected: %v", err) - } - if !strings.Contains(out.String(), "vantage") { - t.Fatalf("the prompt must name the database, got: %s", out.String()) - } -} - -func TestConfirmDestructionTTYRejectsWrongTypedName(t *testing.T) { - var out bytes.Buffer - err := confirmDestruction(strings.NewReader("something else\n"), &out, true, "", "vantage") - if !errors.Is(err, ErrNotConfirmed) { - t.Fatalf("got %v, want ErrNotConfirmed", err) - } -} - -func TestConfirmDestructionTTYFlagSkipsThePrompt(t *testing.T) { - var out bytes.Buffer - if err := confirmDestruction(strings.NewReader(""), &out, true, "vantage", "vantage"); err != nil { - t.Fatalf("matching --confirm-db rejected on a TTY: %v", err) - } - if out.Len() != 0 { - t.Fatalf("--confirm-db must skip the prompt, got: %s", out.String()) - } -} diff --git a/vantagectl/internal/cmd/root.go b/vantagectl/internal/cmd/root.go deleted file mode 100644 index c4f9b11..0000000 --- a/vantagectl/internal/cmd/root.go +++ /dev/null @@ -1,131 +0,0 @@ -// Package cmd is vantagectl's command tree. -// -// It holds argument parsing and operator-facing output only. Everything it does -// to a database goes through shared/backup, which the server can also import. -package cmd - -import ( - "context" - "fmt" - "net/url" - "os" - "strings" - "time" - - "github.com/spf13/cobra" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.mongodb.org/mongo-driver/v2/mongo/options" -) - -const connectTimeout = 30 * time.Second - -// globalOpts is what every subcommand needs. -type globalOpts struct { - MongoURI string - Database string - KeyHex string -} - -// NewRoot builds the command tree. -func NewRoot(version string) *cobra.Command { - root := &cobra.Command{ - Use: "vantagectl", - Short: "Back up and restore a Vantage control plane", - Version: version, - Long: "vantagectl backs up and restores the MongoDB database behind a Vantage\n" + - "control plane.\n\n" + - "It talks to MongoDB directly and never to the Vantage API, so it works\n" + - "against a control plane that is down, half-migrated, or gone.\n\n" + - "KEY_ENCRYPTION_KEY is never written into an archive. What an archive\n" + - "records is a fingerprint of it, so a restore can tell you that the key\n" + - "you hold is the wrong one before it writes a database nobody can read.", - SilenceUsage: true, - SilenceErrors: true, - } - - f := root.PersistentFlags() - f.String("mongo-uri", "", "MongoDB connection string (env MONGO_URI)") - f.String("db", "", "database name (env MONGO_DB, or the URI path)") - - root.AddCommand(newBackupCmd(), newRestoreCmd(), newInspectCmd(), newVerifyCmd()) - return root -} - -// Execute runs the tree. -func Execute(version string) error { - return NewRoot(version).Execute() -} - -// resolveGlobals applies the environment fallback. -// -// Explicit flags win. The check is on Changed rather than on emptiness, so -// `--db ""` is an explicit empty value rather than an invitation to read the -// environment behind the operator's back. -func resolveGlobals(c *cobra.Command) (*globalOpts, error) { - root := c.Root() - f := root.PersistentFlags() - - uri, err := f.GetString("mongo-uri") - if err != nil { - return nil, err - } - if !f.Changed("mongo-uri") { - uri = os.Getenv("MONGO_URI") - } - if uri == "" { - return nil, fmt.Errorf("no MongoDB URI: pass --mongo-uri or set MONGO_URI") - } - - db, err := f.GetString("db") - if err != nil { - return nil, err - } - if !f.Changed("db") { - db = os.Getenv("MONGO_DB") - } - if db == "" { - db = databaseFromURI(uri) - } - if db == "" { - return nil, fmt.Errorf("no database name: pass --db, set MONGO_DB, or put one in the URI path") - } - - return &globalOpts{ - MongoURI: uri, - Database: db, - KeyHex: keyFromEnv(), - }, nil -} - -// keyFromEnv reads KEY_ENCRYPTION_KEY. It is separate from resolveGlobals -// because verify needs the key even when there is no database to resolve. -func keyFromEnv() string { - return strings.TrimSpace(os.Getenv("KEY_ENCRYPTION_KEY")) -} - -// databaseFromURI reads the database out of the URI path. sitesvc takes its -// database name this way too, so an operator who has configured one has -// configured both. -func databaseFromURI(uri string) string { - u, err := url.Parse(uri) - if err != nil { - return "" - } - return strings.Trim(u.Path, "/") -} - -// connect dials MongoDB and proves the connection before a caller commits to -// anything. -func connect(ctx context.Context, g *globalOpts) (*mongo.Client, error) { - client, err := mongo.Connect(options.Client().ApplyURI(g.MongoURI)) - if err != nil { - return nil, fmt.Errorf("connect to MongoDB: %w", err) - } - pingCtx, cancel := context.WithTimeout(ctx, connectTimeout) - defer cancel() - if err := client.Ping(pingCtx, nil); err != nil { - _ = client.Disconnect(context.Background()) - return nil, fmt.Errorf("MongoDB did not answer: %w", err) - } - return client, nil -} diff --git a/vantagectl/internal/cmd/root_test.go b/vantagectl/internal/cmd/root_test.go deleted file mode 100644 index 7396c89..0000000 --- a/vantagectl/internal/cmd/root_test.go +++ /dev/null @@ -1,117 +0,0 @@ -package cmd - -import ( - "bytes" - "strings" - "testing" -) - -func TestRootListsEverySubcommand(t *testing.T) { - root := NewRoot("test") - var buf bytes.Buffer - root.SetOut(&buf) - root.SetArgs([]string{"--help"}) - if err := root.Execute(); err != nil { - t.Fatalf("Execute: %v", err) - } - for _, want := range []string{"backup", "restore", "inspect", "verify"} { - if !strings.Contains(buf.String(), want) { - t.Fatalf("help does not mention %q:\n%s", want, buf.String()) - } - } -} - -func TestGlobalFlagsFallBackToEnvironment(t *testing.T) { - t.Setenv("MONGO_URI", "mongodb://env:27017") - t.Setenv("MONGO_DB", "envdb") - t.Setenv("KEY_ENCRYPTION_KEY", "envkey") - - root := NewRoot("test") - g, err := resolveGlobals(root) - if err != nil { - t.Fatalf("resolveGlobals: %v", err) - } - if g.MongoURI != "mongodb://env:27017" { - t.Fatalf("MongoURI %q", g.MongoURI) - } - if g.Database != "envdb" { - t.Fatalf("Database %q", g.Database) - } - if g.KeyHex != "envkey" { - t.Fatalf("KeyHex %q", g.KeyHex) - } -} - -func TestExplicitFlagsBeatEnvironment(t *testing.T) { - t.Setenv("MONGO_URI", "mongodb://env:27017") - t.Setenv("MONGO_DB", "envdb") - - root := NewRoot("test") - if err := root.PersistentFlags().Set("mongo-uri", "mongodb://flag:27017"); err != nil { - t.Fatalf("set flag: %v", err) - } - if err := root.PersistentFlags().Set("db", "flagdb"); err != nil { - t.Fatalf("set flag: %v", err) - } - g, err := resolveGlobals(root) - if err != nil { - t.Fatalf("resolveGlobals: %v", err) - } - if g.MongoURI != "mongodb://flag:27017" { - t.Fatalf("MongoURI %q; the flag must win over the environment", g.MongoURI) - } - if g.Database != "flagdb" { - t.Fatalf("Database %q", g.Database) - } -} - -func TestDatabaseFallsBackToURIPath(t *testing.T) { - t.Setenv("MONGO_URI", "mongodb://host:27017/fromuri") - root := NewRoot("test") - g, err := resolveGlobals(root) - if err != nil { - t.Fatalf("resolveGlobals: %v", err) - } - if g.Database != "fromuri" { - t.Fatalf("Database %q, want fromuri", g.Database) - } -} - -func TestMissingURIIsAnError(t *testing.T) { - t.Setenv("MONGO_URI", "") - root := NewRoot("test") - if _, err := resolveGlobals(root); err == nil { - t.Fatal("resolveGlobals accepted an empty MONGO_URI") - } -} - -func TestVersionIsReported(t *testing.T) { - root := NewRoot("1.2.3") - if root.Version != "1.2.3" { - t.Fatalf("Version %q", root.Version) - } -} - -func TestExplicitlyEmptyFlagDoesNotFallBackToEnvironment(t *testing.T) { - t.Setenv("MONGO_URI", "mongodb://env:27017") - t.Setenv("MONGO_DB", "envdb") - - root := NewRoot("test") - // An operator who writes --db "" means the empty string. Reading the - // environment behind their back would be a different database than the one - // they named, which for a restore is the difference between the right - // machine and the wrong one. - if err := root.PersistentFlags().Set("db", ""); err != nil { - t.Fatalf("set flag: %v", err) - } - - // The URI carries no database path either, so nothing can rescue the empty - // value and resolveGlobals must refuse rather than reach for MONGO_DB. - if err := root.PersistentFlags().Set("mongo-uri", "mongodb://host:27017"); err != nil { - t.Fatalf("set flag: %v", err) - } - - if _, err := resolveGlobals(root); err == nil { - t.Fatal("an explicitly empty --db fell back to MONGO_DB instead of being refused") - } -} diff --git a/vantagectl/internal/cmd/verify.go b/vantagectl/internal/cmd/verify.go deleted file mode 100644 index ac2017e..0000000 --- a/vantagectl/internal/cmd/verify.go +++ /dev/null @@ -1,94 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - - "gitea.hostxtra.co.uk/vantage/vantage-shared/backup" - "github.com/spf13/cobra" - "go.mongodb.org/mongo-driver/v2/mongo" -) - -func newVerifyCmd() *cobra.Command { - return &cobra.Command{ - Use: "verify ARCHIVE", - Short: "Check an archive against the key in hand", - Long: "verify checks that an archive is intact and that the\n" + - "KEY_ENCRYPTION_KEY in this environment matches the one it was made\n" + - "with.\n\n" + - "Given --mongo-uri it goes further and opens a real ciphertext value\n" + - "from that database. A fingerprint proves two archives agree about a\n" + - "key; only the probe proves the key you hold reads the data.\n\n" + - "Exit status is non-zero when anything is wrong, so this is the command\n" + - "to put on a schedule.", - Args: cobra.ExactArgs(1), - RunE: func(c *cobra.Command, args []string) error { - ctx := c.Context() - - archive, err := backup.Open(args[0]) - if err != nil { - return err - } - defer archive.Close() - - // The key is read unconditionally. Archive-only mode — no MONGO_URI, - // which is what a scheduled check uses — must still compare the key - // in hand against the archive's fingerprint; leaving it unset there - // reported "KEY_ENCRYPTION_KEY is not set" for a key that was set - // and correct. - opt := backup.VerifyOptions{Archive: archive, KeyHex: keyFromEnv()} - - // A database is optional here. resolveGlobals fails without a URI or - // without a resolvable database name, and either error is a signal to - // verify the archive alone rather than a reason to stop. - var client *mongo.Client - if g, gerr := resolveGlobals(c); gerr == nil { - client, err = connect(ctx, g) - if err != nil { - return err - } - defer client.Disconnect(context.Background()) - opt.Client = client - opt.Database = g.Database - } else { - fmt.Fprintf(c.ErrOrStderr(), - "note: %v, so this checks the archive and the key only\n", gerr) - } - - rep, err := backup.Verify(ctx, opt) - if err != nil { - return err - } - - out := c.OutOrStdout() - fmt.Fprintln(out, "Archive intact, every member matches its checksum") - if rep.ArchiveFingerprint != nil { - fmt.Fprintf(out, "Archive key %s\n", *rep.ArchiveFingerprint) - } - if rep.KeyFingerprint != nil { - fmt.Fprintf(out, "Your key %s\n", *rep.KeyFingerprint) - } - if rep.KeyMatchesArchive { - fmt.Fprintln(out, "Key match yes") - } - switch { - case rep.ProbeDecrypted: - fmt.Fprintf(out, "Live probe decrypted a value from %s\n", rep.ProbeCollection) - case rep.ProbeAttempted: - fmt.Fprintf(out, "Live probe FAILED against %s\n", rep.ProbeCollection) - case opt.Client != nil: - fmt.Fprintln(out, "Live probe skipped; this database stores no ciphertext yet") - } - - if rep.OK() { - fmt.Fprintln(out, "\nThis archive will restore.") - return nil - } - fmt.Fprintln(out) - for _, p := range rep.Problems { - fmt.Fprintln(out, "problem:", p) - } - return fmt.Errorf("verification failed") - }, - } -} diff --git a/vantagectl/internal/cmd/verify_test.go b/vantagectl/internal/cmd/verify_test.go deleted file mode 100644 index c0d5180..0000000 --- a/vantagectl/internal/cmd/verify_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package cmd - -import ( - "bytes" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "gitea.hostxtra.co.uk/vantage/vantage-shared/backup" -) - -const testKeyHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" - -// writeArchive builds a minimal, valid archive without touching a database. -func writeArchive(t *testing.T, keyHex string) string { - t.Helper() - path := filepath.Join(t.TempDir(), "archive.tar.gz") - f, err := os.Create(path) - if err != nil { - t.Fatalf("create: %v", err) - } - defer f.Close() - - w := backup.NewWriter(f) - entry, err := w.WriteCollection("servers", nil) - if err != nil { - t.Fatalf("WriteCollection: %v", err) - } - fp, err := backup.FingerprintHex(keyHex) - if err != nil { - t.Fatalf("FingerprintHex: %v", err) - } - if err := w.Close(backup.Manifest{ - FormatVersion: backup.FormatVersion, - CreatedAt: time.Now().UTC(), - MongoDB: "vantage", - KeyFingerprint: &fp, - Collections: []backup.CollectionEntry{entry}, - }); err != nil { - t.Fatalf("Close: %v", err) - } - return path -} - -// TestVerifyReadsTheKeyWithoutADatabase is the regression guard for the key -// being read only on the branch that resolved a database: archive-only mode is -// what a scheduled check runs, and it reported "KEY_ENCRYPTION_KEY is not set" -// for a key that was set and correct. -func TestVerifyReadsTheKeyWithoutADatabase(t *testing.T) { - path := writeArchive(t, testKeyHex) - t.Setenv("MONGO_URI", "") - t.Setenv("MONGO_DB", "") - t.Setenv("KEY_ENCRYPTION_KEY", testKeyHex) - - root := NewRoot("test") - var out, errBuf bytes.Buffer - root.SetOut(&out) - root.SetErr(&errBuf) - root.SetArgs([]string{"verify", path}) - - if err := root.Execute(); err != nil { - t.Fatalf("verify failed with the correct key and no database: %v\n%s%s", - err, out.String(), errBuf.String()) - } - if !strings.Contains(out.String(), "Key match yes") { - t.Fatalf("archive-only verify did not compare the key:\n%s", out.String()) - } -} - -func TestVerifyReportsAKeyMismatchWithoutADatabase(t *testing.T) { - path := writeArchive(t, testKeyHex) - t.Setenv("MONGO_URI", "") - t.Setenv("MONGO_DB", "") - t.Setenv("KEY_ENCRYPTION_KEY", strings.Repeat("ab", 32)) - - root := NewRoot("test") - var out, errBuf bytes.Buffer - root.SetOut(&out) - root.SetErr(&errBuf) - root.SetArgs([]string{"verify", path}) - - if err := root.Execute(); err == nil { - t.Fatalf("verify accepted the wrong key:\n%s", out.String()) - } - if !strings.Contains(out.String(), "key mismatch") { - t.Fatalf("output does not name the mismatch:\n%s", out.String()) - } -} diff --git a/vantagectl/main.go b/vantagectl/main.go deleted file mode 100644 index 5fbf96f..0000000 --- a/vantagectl/main.go +++ /dev/null @@ -1,19 +0,0 @@ -// Command vantagectl backs up and restores a Vantage control plane. -package main - -import ( - "fmt" - "os" - - "gitea.hostxtra.co.uk/mrhid6/vantage/vantagectl/internal/cmd" -) - -// Version is stamped at build time with -ldflags "-X main.Version=...". -var Version = "dev" - -func main() { - if err := cmd.Execute(Version); err != nil { - fmt.Fprintln(os.Stderr, "error:", err) - os.Exit(1) - } -}