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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
|
||||
+3
-1
@@ -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=
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user