diff --git a/go.work b/go.work index ab47347..ed44a2f 100644 --- a/go.work +++ b/go.work @@ -6,4 +6,5 @@ use ( ./server ./shared ./sitesvc + ./vantagectl ) diff --git a/vantagectl/go.mod b/vantagectl/go.mod new file mode 100644 index 0000000..fe0d83f --- /dev/null +++ b/vantagectl/go.mod @@ -0,0 +1,23 @@ +module gitea.hostxtra.co.uk/mrhid6/vantage/vantagectl + +go 1.26 + +replace gitea.hostxtra.co.uk/mrhid6/vantage/shared => ../shared + +require ( + github.com/spf13/cobra v1.10.2 + go.mongodb.org/mongo-driver/v2 v2.8.0 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/klauspost/compress v1.17.6 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + 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 +) diff --git a/vantagectl/go.sum b/vantagectl/go.sum new file mode 100644 index 0000000..0583637 --- /dev/null +++ b/vantagectl/go.sum @@ -0,0 +1,56 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI= +github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8= +go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +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/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/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= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +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/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= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/vantagectl/internal/cmd/backup.go b/vantagectl/internal/cmd/backup.go new file mode 100644 index 0000000..e256bc1 --- /dev/null +++ b/vantagectl/internal/cmd/backup.go @@ -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"} +} diff --git a/vantagectl/internal/cmd/inspect.go b/vantagectl/internal/cmd/inspect.go new file mode 100644 index 0000000..1cdcfd7 --- /dev/null +++ b/vantagectl/internal/cmd/inspect.go @@ -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"} +} diff --git a/vantagectl/internal/cmd/restore.go b/vantagectl/internal/cmd/restore.go new file mode 100644 index 0000000..2a98555 --- /dev/null +++ b/vantagectl/internal/cmd/restore.go @@ -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"} +} diff --git a/vantagectl/internal/cmd/root.go b/vantagectl/internal/cmd/root.go new file mode 100644 index 0000000..d1eeb45 --- /dev/null +++ b/vantagectl/internal/cmd/root.go @@ -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 +} diff --git a/vantagectl/internal/cmd/root_test.go b/vantagectl/internal/cmd/root_test.go new file mode 100644 index 0000000..b15285c --- /dev/null +++ b/vantagectl/internal/cmd/root_test.go @@ -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) + } +} diff --git a/vantagectl/internal/cmd/verify.go b/vantagectl/internal/cmd/verify.go new file mode 100644 index 0000000..893ba24 --- /dev/null +++ b/vantagectl/internal/cmd/verify.go @@ -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"} +} diff --git a/vantagectl/main.go b/vantagectl/main.go new file mode 100644 index 0000000..5fbf96f --- /dev/null +++ b/vantagectl/main.go @@ -0,0 +1,19 @@ +// Command vantagectl backs up and restores a Vantage control plane. +package main + +import ( + "fmt" + "os" + + "gitea.hostxtra.co.uk/mrhid6/vantage/vantagectl/internal/cmd" +) + +// Version is stamped at build time with -ldflags "-X main.Version=...". +var Version = "dev" + +func main() { + if err := cmd.Execute(Version); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } +}