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