// 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, SilenceErrors: 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: keyFromEnv(), }, nil } // keyFromEnv reads KEY_ENCRYPTION_KEY. It is separate from resolveGlobals // because verify needs the key even when there is no database to resolve. func keyFromEnv() string { return strings.TrimSpace(os.Getenv("KEY_ENCRYPTION_KEY")) } // 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 }