Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f3bcd6b69 | |||
| 0fe52afbb2 | |||
| bf206ae67c |
@@ -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
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")
|
||||||
|
}
|
||||||
93
cmd/mcrctl/config_test.go
Normal file
93
cmd/mcrctl/config_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
7
deploy/examples/mcrctl.toml
Normal file
7
deploy/examples/mcrctl.toml
Normal 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
|
||||||
2
go.mod
2
go.mod
@@ -3,7 +3,7 @@ module git.wntrmute.dev/mc/mcr
|
|||||||
go 1.25.7
|
go 1.25.7
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.wntrmute.dev/mc/mcdsl v1.5.0
|
git.wntrmute.dev/mc/mcdsl v1.6.0
|
||||||
github.com/go-chi/chi/v5 v5.2.5
|
github.com/go-chi/chi/v5 v5.2.5
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -1,5 +1,7 @@
|
|||||||
git.wntrmute.dev/mc/mcdsl v1.5.0 h1:JUlSYuvETRCycf+cZ56Gxp/1XZn0T7fOfWezM3m89qE=
|
git.wntrmute.dev/mc/mcdsl v1.5.0 h1:JUlSYuvETRCycf+cZ56Gxp/1XZn0T7fOfWezM3m89qE=
|
||||||
git.wntrmute.dev/mc/mcdsl v1.5.0/go.mod h1:MhYahIu7Sg53lE2zpQ20nlrsoNRjQzOJBAlCmom2wJc=
|
git.wntrmute.dev/mc/mcdsl v1.5.0/go.mod h1:MhYahIu7Sg53lE2zpQ20nlrsoNRjQzOJBAlCmom2wJc=
|
||||||
|
git.wntrmute.dev/mc/mcdsl v1.6.0 h1:Vn1uy6b1yZ4Y8fsl1+kLucVprrFKlQ4SN2cjUH/Eg2k=
|
||||||
|
git.wntrmute.dev/mc/mcdsl v1.6.0/go.mod h1:MhYahIu7Sg53lE2zpQ20nlrsoNRjQzOJBAlCmom2wJc=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
|
|||||||
2
vendor/git.wntrmute.dev/mc/mcdsl/sso/sso.go
vendored
2
vendor/git.wntrmute.dev/mc/mcdsl/sso/sso.go
vendored
@@ -229,7 +229,7 @@ func ValidateStateCookie(w http.ResponseWriter, r *http.Request, prefix, querySt
|
|||||||
// redirect back to it after SSO login completes.
|
// redirect back to it after SSO login completes.
|
||||||
func SetReturnToCookie(w http.ResponseWriter, r *http.Request, prefix string) {
|
func SetReturnToCookie(w http.ResponseWriter, r *http.Request, prefix string) {
|
||||||
path := r.URL.Path
|
path := r.URL.Path
|
||||||
if path == "" || path == "/login" || path == "/sso/callback" {
|
if path == "" || path == "/login" || strings.HasPrefix(path, "/sso/") {
|
||||||
path = "/"
|
path = "/"
|
||||||
}
|
}
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
|||||||
2
vendor/modules.txt
vendored
2
vendor/modules.txt
vendored
@@ -1,4 +1,4 @@
|
|||||||
# git.wntrmute.dev/mc/mcdsl v1.5.0
|
# git.wntrmute.dev/mc/mcdsl v1.6.0
|
||||||
## explicit; go 1.25.7
|
## explicit; go 1.25.7
|
||||||
git.wntrmute.dev/mc/mcdsl/auth
|
git.wntrmute.dev/mc/mcdsl/auth
|
||||||
git.wntrmute.dev/mc/mcdsl/config
|
git.wntrmute.dev/mc/mcdsl/config
|
||||||
|
|||||||
Reference in New Issue
Block a user