feat: Add the vantagectl restore and verify subcommands

--force requires a typed database name on a terminal and --confirm-db
without one, so a copy-pasted restore command carries its intended target
and cannot destroy a different database.

Also silences cobra's own error print (root.go) so a failure is reported
once by main.go instead of twice, and pins the Changed()-based env
fallback in resolveGlobals with a test for an explicitly empty --db.
This commit is contained in:
2026-09-07 14:04:25 +00:00
parent 461a79277d
commit 3ce963b0cd
7 changed files with 311 additions and 5 deletions
+2
View File
@@ -8,6 +8,7 @@ require (
gitea.hostxtra.co.uk/mrhid6/vantage/shared v0.0.0-00010101000000-000000000000
github.com/spf13/cobra v1.10.2
go.mongodb.org/mongo-driver/v2 v2.8.0
golang.org/x/term v0.45.0
)
require (
@@ -20,5 +21,6 @@ require (
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
)
+4
View File
@@ -41,8 +41,12 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
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=
+129 -2
View File
@@ -1,7 +1,134 @@
package cmd
import "github.com/spf13/cobra"
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os"
"strings"
"text/tabwriter"
"gitea.hostxtra.co.uk/mrhid6/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 {
return &cobra.Command{Use: "restore ARCHIVE", Short: "Load an archive into a database"}
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
}
+65
View File
@@ -0,0 +1,65 @@
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())
}
}
+2 -1
View File
@@ -39,7 +39,8 @@ func NewRoot(version string) *cobra.Command {
"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,
SilenceUsage: true,
SilenceErrors: true,
}
f := root.PersistentFlags()
+24
View File
@@ -91,3 +91,27 @@ func TestVersionIsReported(t *testing.T) {
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")
}
}
+85 -2
View File
@@ -1,7 +1,90 @@
package cmd
import "github.com/spf13/cobra"
import (
"context"
"fmt"
"gitea.hostxtra.co.uk/mrhid6/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"}
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()
opt := backup.VerifyOptions{Archive: archive}
// A database is optional here. resolveGlobals fails without a URI,
// so its 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
opt.KeyHex = g.KeyHex
} else {
fmt.Fprintln(c.ErrOrStderr(),
"note: no MongoDB URI, so this checks the archive and the key only")
}
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")
},
}
}