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) <noreply@anthropic.com>
This commit is contained in:
56
cmd/mcrctl/config.go
Normal file
56
cmd/mcrctl/config.go
Normal file
@@ -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")
|
||||
}
|
||||
91
cmd/mcrctl/config_test.go
Normal file
91
cmd/mcrctl/config_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user