Files
vantage-ctl/internal/cmd/root.go
T
mrhid6 e30b25b2bc feat: Add the vantagectl restore and verify subcommands
--force requires a typed database name on a terminal and --confirm-db
without one, so a copy-pasted restore command carries its intended target
and cannot destroy a different database.

Also silences cobra's own error print (root.go) so a failure is reported
once by main.go instead of twice, and pins the Changed()-based env
fallback in resolveGlobals with a test for an explicitly empty --db.
2026-09-07 14:04:25 +00:00

126 lines
3.5 KiB
Go

// 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: 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
}