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:
+4
-3
@@ -5,6 +5,7 @@ go 1.26
|
||||
replace gitea.hostxtra.co.uk/mrhid6/vantage/shared => ../shared
|
||||
|
||||
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
|
||||
)
|
||||
@@ -17,7 +18,7 @@ require (
|
||||
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.33.0 // indirect
|
||||
golang.org/x/sync v0.11.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
golang.org/x/crypto v0.54.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
)
|
||||
|
||||
+6
-6
@@ -26,16 +26,16 @@ go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzyb
|
||||
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.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
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.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
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=
|
||||
@@ -47,8 +47,8 @@ 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.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
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=
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user