From 0fe52afbb2c37d2cd5083ec7280f32bf02eb9465 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Wed, 1 Apr 2026 17:02:23 -0700 Subject: [PATCH] Add TOML config file support to mcrctl Loads defaults from ~/.config/mcrctl.toml (or XDG_CONFIG_HOME). Resolution order: flag > env (MCR_TOKEN) > config file. Adds --config flag to specify an explicit path. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/mcrctl/config.go | 56 +++++++++++++++++++++++ cmd/mcrctl/config_test.go | 91 +++++++++++++++++++++++++++++++++++++ cmd/mcrctl/main.go | 42 +++++++++++++---- deploy/examples/mcrctl.toml | 7 +++ 4 files changed, 188 insertions(+), 8 deletions(-) create mode 100644 cmd/mcrctl/config.go create mode 100644 cmd/mcrctl/config_test.go create mode 100644 deploy/examples/mcrctl.toml diff --git a/cmd/mcrctl/config.go b/cmd/mcrctl/config.go new file mode 100644 index 0000000..fbc285b --- /dev/null +++ b/cmd/mcrctl/config.go @@ -0,0 +1,56 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + + toml "github.com/pelletier/go-toml/v2" +) + +// config holds CLI defaults loaded from a TOML file. +type config struct { + Server string `toml:"server"` + GRPC string `toml:"grpc"` + Token string `toml:"token"` + CACert string `toml:"ca_cert"` +} + +// loadConfig reads a TOML config file from the given path. If path is +// empty it searches the default location (~/.config/mcrctl.toml). A +// missing file at the default location is not an error — an explicit +// --config path that doesn't exist is. +func loadConfig(path string) (config, error) { + explicit := path != "" + if !explicit { + path = defaultConfigPath() + } + + data, err := os.ReadFile(path) //nolint:gosec // operator-supplied config path + if err != nil { + if !explicit && os.IsNotExist(err) { + return config{}, nil + } + return config{}, fmt.Errorf("reading config: %w", err) + } + + var cfg config + if err := toml.Unmarshal(data, &cfg); err != nil { + return config{}, fmt.Errorf("parsing config %s: %w", path, err) + } + return cfg, nil +} + +// defaultConfigPath returns ~/.config/mcrctl.toml, respecting +// XDG_CONFIG_HOME if set. +func defaultConfigPath() string { + dir := os.Getenv("XDG_CONFIG_HOME") + if dir == "" { + home, err := os.UserHomeDir() + if err != nil { + return "mcrctl.toml" + } + dir = filepath.Join(home, ".config") + } + return filepath.Join(dir, "mcrctl.toml") +} diff --git a/cmd/mcrctl/config_test.go b/cmd/mcrctl/config_test.go new file mode 100644 index 0000000..4eb9f8f --- /dev/null +++ b/cmd/mcrctl/config_test.go @@ -0,0 +1,91 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadConfigExplicitPath(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "mcrctl.toml") + data := []byte(`server = "https://reg.example.com" +grpc = "reg.example.com:9443" +token = "file-token" +ca_cert = "/path/to/ca.pem" +`) + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + + cfg, err := loadConfig(path) + if err != nil { + t.Fatal(err) + } + + if cfg.Server != "https://reg.example.com" { + t.Errorf("Server = %q, want %q", cfg.Server, "https://reg.example.com") + } + if cfg.GRPC != "reg.example.com:9443" { + t.Errorf("GRPC = %q, want %q", cfg.GRPC, "reg.example.com:9443") + } + if cfg.Token != "file-token" { + t.Errorf("Token = %q, want %q", cfg.Token, "file-token") + } + if cfg.CACert != "/path/to/ca.pem" { + t.Errorf("CACert = %q, want %q", cfg.CACert, "/path/to/ca.pem") + } +} + +func TestLoadConfigMissingDefaultIsOK(t *testing.T) { + // Empty path triggers default search; missing file is not an error. + cfg, err := loadConfig("") + if err != nil { + t.Fatal(err) + } + if cfg.Server != "" || cfg.GRPC != "" || cfg.Token != "" { + t.Errorf("expected zero config from missing default, got %+v", cfg) + } +} + +func TestLoadConfigMissingExplicitIsError(t *testing.T) { + _, err := loadConfig("/nonexistent/mcrctl.toml") + if err == nil { + t.Fatal("expected error for missing explicit config path") + } +} + +func TestLoadConfigInvalidTOML(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "bad.toml") + if err := os.WriteFile(path, []byte("not valid [[[ toml"), 0o600); err != nil { + t.Fatal(err) + } + + _, err := loadConfig(path) + if err == nil { + t.Fatal("expected error for invalid TOML") + } +} + +func TestLoadConfigPartial(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "mcrctl.toml") + data := []byte(`server = "https://reg.example.com" +`) + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + + cfg, err := loadConfig(path) + if err != nil { + t.Fatal(err) + } + + if cfg.Server != "https://reg.example.com" { + t.Errorf("Server = %q, want %q", cfg.Server, "https://reg.example.com") + } + if cfg.GRPC != "" { + t.Errorf("GRPC should be empty, got %q", cfg.GRPC) + } +} diff --git a/cmd/mcrctl/main.go b/cmd/mcrctl/main.go index 07282dd..13dd1d0 100644 --- a/cmd/mcrctl/main.go +++ b/cmd/mcrctl/main.go @@ -18,6 +18,7 @@ var version = "dev" // Global flags, resolved in PersistentPreRunE. var ( + flagConfig string flagServer string flagGRPC string flagToken string @@ -32,15 +33,27 @@ func main() { Use: "mcrctl", Short: "Metacircular Container Registry admin CLI", Version: version, - PersistentPreRunE: func(_ *cobra.Command, _ []string) error { - // Resolve token: flag overrides env. - token := flagToken - if token == "" { - token = os.Getenv("MCR_TOKEN") + PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := loadConfig(flagConfig) + if err != nil { + return err } - var err error - client, err = newClient(flagServer, flagGRPC, token, flagCACert) + // Resolution order: flag > env > config file. + server := applyDefault(cmd, "server", flagServer, cfg.Server) + grpcAddr := applyDefault(cmd, "grpc", flagGRPC, cfg.GRPC) + caCert := applyDefault(cmd, "ca-cert", flagCACert, cfg.CACert) + + token := flagToken + if !cmd.Flags().Changed("token") { + if envToken := os.Getenv("MCR_TOKEN"); envToken != "" { + token = envToken + } else if cfg.Token != "" { + token = cfg.Token + } + } + + client, err = newClient(server, grpcAddr, token, caCert) if err != nil { return err } @@ -53,9 +66,10 @@ func main() { }, } + root.PersistentFlags().StringVar(&flagConfig, "config", "", "config file (default ~/.config/mcrctl.toml)") root.PersistentFlags().StringVar(&flagServer, "server", "", "REST API base URL (e.g. https://registry.example.com)") root.PersistentFlags().StringVar(&flagGRPC, "grpc", "", "gRPC server address (e.g. registry.example.com:9443)") - root.PersistentFlags().StringVar(&flagToken, "token", "", "bearer token (fallback: MCR_TOKEN env)") + root.PersistentFlags().StringVar(&flagToken, "token", "", "bearer token (fallback: MCR_TOKEN env, config file)") root.PersistentFlags().StringVar(&flagCACert, "ca-cert", "", "custom CA certificate PEM file") root.PersistentFlags().BoolVar(&flagJSON, "json", false, "output as JSON instead of table") @@ -740,6 +754,18 @@ func runSnapshot(_ *cobra.Command, _ []string) error { // ---------- helpers ---------- +// applyDefault returns the flag value if the flag was explicitly set on +// the command line, otherwise falls back to the config file value. +func applyDefault(cmd *cobra.Command, name, flagVal, cfgVal string) string { + if cmd.Flags().Changed(name) { + return flagVal + } + if cfgVal != "" { + return cfgVal + } + return flagVal +} + // confirmPrompt displays msg and waits for y/n from stdin. func confirmPrompt(msg string) bool { fmt.Fprintf(os.Stderr, "%s [y/N] ", msg) diff --git a/deploy/examples/mcrctl.toml b/deploy/examples/mcrctl.toml new file mode 100644 index 0000000..9401148 --- /dev/null +++ b/deploy/examples/mcrctl.toml @@ -0,0 +1,7 @@ +# mcrctl — Metacircular Container Registry CLI configuration. +# Copy to ~/.config/mcrctl.toml and edit. + +server = "https://registry.example.com:8443" # REST API base URL +grpc = "registry.example.com:9443" # gRPC server address (optional) +token = "" # bearer token (MCR_TOKEN env overrides) +ca_cert = "" # custom CA certificate PEM file