feat: Add the vantagectl module and its cobra root
Its own module rather than a package under shared, so cobra and pflag stay out of the module graphs of server, admin and sitesvc, which never use them.
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
package cmd
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func newBackupCmd() *cobra.Command {
|
||||
return &cobra.Command{Use: "backup", Short: "Write an archive of the database"}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package cmd
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func newInspectCmd() *cobra.Command {
|
||||
return &cobra.Command{Use: "inspect ARCHIVE", Short: "Print an archive's manifest"}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package cmd
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func newRestoreCmd() *cobra.Command {
|
||||
return &cobra.Command{Use: "restore ARCHIVE", Short: "Load an archive into a database"}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// Package cmd is vantagectl's command tree.
|
||||
//
|
||||
// It holds argument parsing and operator-facing output only. Everything it does
|
||||
// to a database goes through shared/backup, which the server can also import.
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
const connectTimeout = 30 * time.Second
|
||||
|
||||
// globalOpts is what every subcommand needs.
|
||||
type globalOpts struct {
|
||||
MongoURI string
|
||||
Database string
|
||||
KeyHex string
|
||||
}
|
||||
|
||||
// NewRoot builds the command tree.
|
||||
func NewRoot(version string) *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "vantagectl",
|
||||
Short: "Back up and restore a Vantage control plane",
|
||||
Version: version,
|
||||
Long: "vantagectl backs up and restores the MongoDB database behind a Vantage\n" +
|
||||
"control plane.\n\n" +
|
||||
"It talks to MongoDB directly and never to the Vantage API, so it works\n" +
|
||||
"against a control plane that is down, half-migrated, or gone.\n\n" +
|
||||
"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,
|
||||
}
|
||||
|
||||
f := root.PersistentFlags()
|
||||
f.String("mongo-uri", "", "MongoDB connection string (env MONGO_URI)")
|
||||
f.String("db", "", "database name (env MONGO_DB, or the URI path)")
|
||||
|
||||
root.AddCommand(newBackupCmd(), newRestoreCmd(), newInspectCmd(), newVerifyCmd())
|
||||
return root
|
||||
}
|
||||
|
||||
// Execute runs the tree.
|
||||
func Execute(version string) error {
|
||||
return NewRoot(version).Execute()
|
||||
}
|
||||
|
||||
// resolveGlobals applies the environment fallback.
|
||||
//
|
||||
// Explicit flags win. The check is on Changed rather than on emptiness, so
|
||||
// `--db ""` is an explicit empty value rather than an invitation to read the
|
||||
// environment behind the operator's back.
|
||||
func resolveGlobals(c *cobra.Command) (*globalOpts, error) {
|
||||
root := c.Root()
|
||||
f := root.PersistentFlags()
|
||||
|
||||
uri, err := f.GetString("mongo-uri")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !f.Changed("mongo-uri") {
|
||||
uri = os.Getenv("MONGO_URI")
|
||||
}
|
||||
if uri == "" {
|
||||
return nil, fmt.Errorf("no MongoDB URI: pass --mongo-uri or set MONGO_URI")
|
||||
}
|
||||
|
||||
db, err := f.GetString("db")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !f.Changed("db") {
|
||||
db = os.Getenv("MONGO_DB")
|
||||
}
|
||||
if db == "" {
|
||||
db = databaseFromURI(uri)
|
||||
}
|
||||
if db == "" {
|
||||
return nil, fmt.Errorf("no database name: pass --db, set MONGO_DB, or put one in the URI path")
|
||||
}
|
||||
|
||||
return &globalOpts{
|
||||
MongoURI: uri,
|
||||
Database: db,
|
||||
KeyHex: strings.TrimSpace(os.Getenv("KEY_ENCRYPTION_KEY")),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// databaseFromURI reads the database out of the URI path. sitesvc takes its
|
||||
// database name this way too, so an operator who has configured one has
|
||||
// configured both.
|
||||
func databaseFromURI(uri string) string {
|
||||
u, err := url.Parse(uri)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.Trim(u.Path, "/")
|
||||
}
|
||||
|
||||
// connect dials MongoDB and proves the connection before a caller commits to
|
||||
// anything.
|
||||
func connect(ctx context.Context, g *globalOpts) (*mongo.Client, error) {
|
||||
client, err := mongo.Connect(options.Client().ApplyURI(g.MongoURI))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to MongoDB: %w", err)
|
||||
}
|
||||
pingCtx, cancel := context.WithTimeout(ctx, connectTimeout)
|
||||
defer cancel()
|
||||
if err := client.Ping(pingCtx, nil); err != nil {
|
||||
_ = client.Disconnect(context.Background())
|
||||
return nil, fmt.Errorf("MongoDB did not answer: %w", err)
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRootListsEverySubcommand(t *testing.T) {
|
||||
root := NewRoot("test")
|
||||
var buf bytes.Buffer
|
||||
root.SetOut(&buf)
|
||||
root.SetArgs([]string{"--help"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
for _, want := range []string{"backup", "restore", "inspect", "verify"} {
|
||||
if !strings.Contains(buf.String(), want) {
|
||||
t.Fatalf("help does not mention %q:\n%s", want, buf.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobalFlagsFallBackToEnvironment(t *testing.T) {
|
||||
t.Setenv("MONGO_URI", "mongodb://env:27017")
|
||||
t.Setenv("MONGO_DB", "envdb")
|
||||
t.Setenv("KEY_ENCRYPTION_KEY", "envkey")
|
||||
|
||||
root := NewRoot("test")
|
||||
g, err := resolveGlobals(root)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveGlobals: %v", err)
|
||||
}
|
||||
if g.MongoURI != "mongodb://env:27017" {
|
||||
t.Fatalf("MongoURI %q", g.MongoURI)
|
||||
}
|
||||
if g.Database != "envdb" {
|
||||
t.Fatalf("Database %q", g.Database)
|
||||
}
|
||||
if g.KeyHex != "envkey" {
|
||||
t.Fatalf("KeyHex %q", g.KeyHex)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitFlagsBeatEnvironment(t *testing.T) {
|
||||
t.Setenv("MONGO_URI", "mongodb://env:27017")
|
||||
t.Setenv("MONGO_DB", "envdb")
|
||||
|
||||
root := NewRoot("test")
|
||||
if err := root.PersistentFlags().Set("mongo-uri", "mongodb://flag:27017"); err != nil {
|
||||
t.Fatalf("set flag: %v", err)
|
||||
}
|
||||
if err := root.PersistentFlags().Set("db", "flagdb"); err != nil {
|
||||
t.Fatalf("set flag: %v", err)
|
||||
}
|
||||
g, err := resolveGlobals(root)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveGlobals: %v", err)
|
||||
}
|
||||
if g.MongoURI != "mongodb://flag:27017" {
|
||||
t.Fatalf("MongoURI %q; the flag must win over the environment", g.MongoURI)
|
||||
}
|
||||
if g.Database != "flagdb" {
|
||||
t.Fatalf("Database %q", g.Database)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseFallsBackToURIPath(t *testing.T) {
|
||||
t.Setenv("MONGO_URI", "mongodb://host:27017/fromuri")
|
||||
root := NewRoot("test")
|
||||
g, err := resolveGlobals(root)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveGlobals: %v", err)
|
||||
}
|
||||
if g.Database != "fromuri" {
|
||||
t.Fatalf("Database %q, want fromuri", g.Database)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingURIIsAnError(t *testing.T) {
|
||||
t.Setenv("MONGO_URI", "")
|
||||
root := NewRoot("test")
|
||||
if _, err := resolveGlobals(root); err == nil {
|
||||
t.Fatal("resolveGlobals accepted an empty MONGO_URI")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionIsReported(t *testing.T) {
|
||||
root := NewRoot("1.2.3")
|
||||
if root.Version != "1.2.3" {
|
||||
t.Fatalf("Version %q", root.Version)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package cmd
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func newVerifyCmd() *cobra.Command {
|
||||
return &cobra.Command{Use: "verify ARCHIVE", Short: "Check an archive against the key in hand"}
|
||||
}
|
||||
Reference in New Issue
Block a user