diff --git a/go.mod b/go.mod index fe0d83f..c09a1cb 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 0583637..91f6299 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/cmd/backup.go b/internal/cmd/backup.go index e256bc1..538f71f 100644 --- a/internal/cmd/backup.go +++ b/internal/cmd/backup.go @@ -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")) } diff --git a/internal/cmd/inspect.go b/internal/cmd/inspect.go index 1cdcfd7..5f6580b 100644 --- a/internal/cmd/inspect.go +++ b/internal/cmd/inspect.go @@ -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]) } diff --git a/internal/cmd/inspect_test.go b/internal/cmd/inspect_test.go new file mode 100644 index 0000000..81125fc --- /dev/null +++ b/internal/cmd/inspect_test.go @@ -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) + } +}