2 Commits

Author SHA1 Message Date
1f3bcd6b69 Fix gRPC auth: inject bearer token via PerRPCCredentials
The gRPC client was not sending the authorization header, causing
"missing authorization header" errors even when a token was configured.
Also fix config test to isolate from real ~/.config/mcrctl.toml.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 19:39:00 -07:00
0fe52afbb2 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>
2026-04-01 17:02:23 -07:00
5 changed files with 206 additions and 10 deletions

View File

@@ -62,10 +62,14 @@ func newClient(serverURL, grpcAddr, token, caCertFile string) (*apiClient, error
if grpcAddr != "" { if grpcAddr != "" {
creds := credentials.NewTLS(tlsCfg) creds := credentials.NewTLS(tlsCfg)
cc, err := grpc.NewClient(grpcAddr, dialOpts := []grpc.DialOption{
grpc.WithTransportCredentials(creds), grpc.WithTransportCredentials(creds),
grpc.WithDefaultCallOptions(grpc.ForceCodecV2(mcrv1.JSONCodec{})), grpc.WithDefaultCallOptions(grpc.ForceCodecV2(mcrv1.JSONCodec{})),
) }
if token != "" {
dialOpts = append(dialOpts, grpc.WithPerRPCCredentials(bearerToken(token)))
}
cc, err := grpc.NewClient(grpcAddr, dialOpts...)
if err != nil { if err != nil {
return nil, fmt.Errorf("grpc dial: %w", err) return nil, fmt.Errorf("grpc dial: %w", err)
} }
@@ -91,6 +95,16 @@ func (c *apiClient) useGRPC() bool {
return c.grpcConn != nil return c.grpcConn != nil
} }
// bearerToken implements grpc.PerRPCCredentials, injecting the
// Authorization header into every gRPC call.
type bearerToken string
func (t bearerToken) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) {
return map[string]string{"authorization": "Bearer " + string(t)}, nil
}
func (t bearerToken) RequireTransportSecurity() bool { return true }
// apiError is the JSON error envelope returned by the REST API. // apiError is the JSON error envelope returned by the REST API.
type apiError struct { type apiError struct {
Error string `json:"error"` Error string `json:"error"`

56
cmd/mcrctl/config.go Normal file
View 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")
}

93
cmd/mcrctl/config_test.go Normal file
View File

@@ -0,0 +1,93 @@
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) {
// Point XDG_CONFIG_HOME at an empty dir so we don't find the real config.
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
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)
}
}

View File

@@ -18,6 +18,7 @@ var version = "dev"
// Global flags, resolved in PersistentPreRunE. // Global flags, resolved in PersistentPreRunE.
var ( var (
flagConfig string
flagServer string flagServer string
flagGRPC string flagGRPC string
flagToken string flagToken string
@@ -32,15 +33,27 @@ func main() {
Use: "mcrctl", Use: "mcrctl",
Short: "Metacircular Container Registry admin CLI", Short: "Metacircular Container Registry admin CLI",
Version: version, Version: version,
PersistentPreRunE: func(_ *cobra.Command, _ []string) error { PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
// Resolve token: flag overrides env. cfg, err := loadConfig(flagConfig)
token := flagToken if err != nil {
if token == "" { return err
token = os.Getenv("MCR_TOKEN")
} }
var err error // Resolution order: flag > env > config file.
client, err = newClient(flagServer, flagGRPC, token, flagCACert) 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 { if err != nil {
return err 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(&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(&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().StringVar(&flagCACert, "ca-cert", "", "custom CA certificate PEM file")
root.PersistentFlags().BoolVar(&flagJSON, "json", false, "output as JSON instead of table") root.PersistentFlags().BoolVar(&flagJSON, "json", false, "output as JSON instead of table")
@@ -740,6 +754,18 @@ func runSnapshot(_ *cobra.Command, _ []string) error {
// ---------- helpers ---------- // ---------- 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. // confirmPrompt displays msg and waits for y/n from stdin.
func confirmPrompt(msg string) bool { func confirmPrompt(msg string) bool {
fmt.Fprintf(os.Stderr, "%s [y/N] ", msg) fmt.Fprintf(os.Stderr, "%s [y/N] ", msg)

View File

@@ -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