feat: Add the vantagectl backup and inspect subcommands

Progress output goes to stderr so --out - stays a clean pipe into restic,
age or aws s3 cp. Archive names carry no colon, because these get copied
onto Windows shares.
This commit is contained in:
2026-09-07 13:57:04 +00:00
parent b7f34045e6
commit 92ce0b3e5b
5 changed files with 255 additions and 13 deletions
+102 -2
View File
@@ -1,7 +1,107 @@
package cmd
import "github.com/spf13/cobra"
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/backup"
"github.com/spf13/cobra"
)
func newBackupCmd() *cobra.Command {
return &cobra.Command{Use: "backup", Short: "Write an archive of the database"}
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, closeOut, name, err := backupDestination(out, g.Database)
if err != nil {
return err
}
defer closeOut()
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
}
// 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, func(), string, error) {
if out == "-" {
return os.Stdout, func() {}, "stdout", nil
}
name := archiveName(database, time.Now().UTC())
path := filepath.Join(out, name)
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return nil, nil, "", fmt.Errorf("create %s: %w", path, err)
}
return f, func() { f.Close() }, path, nil
}
// 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"))
}
+73 -2
View File
@@ -1,7 +1,78 @@
package cmd
import "github.com/spf13/cobra"
import (
"fmt"
"io"
"strings"
"text/tabwriter"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/backup"
"github.com/spf13/cobra"
)
func newInspectCmd() *cobra.Command {
return &cobra.Command{Use: "inspect ARCHIVE", Short: "Print an archive's manifest"}
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])
}
+70
View File
@@ -0,0 +1,70 @@
package cmd
import (
"bytes"
"strings"
"testing"
"time"
"gitea.hostxtra.co.uk/mrhid6/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)
}
}