diff --git a/CONFIG.md b/CONFIG.md new file mode 100644 index 000000000..65551d45e --- /dev/null +++ b/CONFIG.md @@ -0,0 +1,377 @@ +# Configuration File + +cortextool supports a kubeconfig-style configuration file for managing multiple Cortex clusters with ease. This allows you to define multiple clusters, authentication credentials, and contexts, and easily switch between them. + +## Quick Start + +1. **Create a config file** at `~/.cortextool/config`: + +```yaml +current-context: production + +contexts: + - name: production + context: + cluster: prod-cortex + user: prod-user + +clusters: + - name: prod-cortex + cluster: + address: https://cortex.prod.example.com + tls-ca-path: /path/to/ca.crt + tls-cert-path: /path/to/client.crt + tls-key-path: /path/to/client.key + +users: + - name: prod-user + user: + id: tenant-123 + auth-token: your-jwt-token +``` + +2. **Use cortextool commands** without specifying connection details: + +```bash +# The address, tenant ID, and TLS certs are loaded from the config file +cortextool rules list +cortextool alertmanager get +``` + +3. **Switch between contexts**: + +```bash +cortextool config use-context staging +``` + +## Configuration Structure + +The configuration file consists of four main sections: + +### 1. Current Context + +Specifies which context to use by default: + +```yaml +current-context: production +``` + +### 2. Contexts + +Contexts bind together a cluster and user credentials: + +```yaml +contexts: + - name: production + context: + cluster: prod-cortex # references a cluster name + user: prod-user # references a user name + - name: staging + context: + cluster: staging-cortex + user: staging-user +``` + +### 3. Clusters + +Clusters define connection details for Cortex instances: + +```yaml +clusters: + - name: prod-cortex + cluster: + address: https://cortex.prod.example.com + tls-ca-path: /path/to/ca.crt # Optional: TLS CA certificate + tls-cert-path: /path/to/client.crt # Optional: Client certificate for mTLS + tls-key-path: /path/to/client.key # Optional: Client certificate key + use-legacy-routes: false # Optional: Use /api/prom/ routes + ruler-api-path: /api/v1/rules # Optional: Custom ruler API path +``` + +### 4. Users + +Users define authentication credentials: + +```yaml +users: + - name: prod-user + user: + id: tenant-123 # Tenant ID + auth-token: jwt-token-here # Bearer token for JWT auth + # OR use basic auth: + # user: username + # key: password +``` + +## Configuration Precedence + +Configuration values are resolved in the following order (highest priority first): + +1. **Command-line flags**: `--address`, `--id`, `--tls-cert-path`, etc. +2. **Environment variables**: `CORTEX_ADDRESS`, `CORTEX_TENANT_ID`, `CORTEX_TLS_CLIENT_CERT`, etc. +3. **`--context` flag**: Override which context to use (instead of `current-context`) +4. **Config file**: Values from the current context +5. **Defaults**: Built-in defaults + +### Examples + +```bash +# Use config file defaults (current-context) +cortextool rules list + +# Override address with flag (other values still from config) +cortextool rules list --address https://different-cortex.com + +# Override with environment variable +export CORTEX_TENANT_ID=different-tenant +cortextool rules list + +# Use a different context temporarily with --context flag +cortextool --context staging rules list + +# Combine --context with individual flags +cortextool --context production --id different-tenant rules list + +# Use a different config file +cortextool --config /path/to/custom-config rules list +``` + +### Using the `--context` Flag + +The `--context` flag allows you to temporarily use a different context without changing the `current-context` in your config file. This is useful for: + +- **Quickly switching environments**: `cortextool --context staging rules list` +- **Testing with different credentials**: `cortextool --context test-user alertmanager get` +- **Scripts that need specific contexts**: Always use a specific context regardless of current-context + +**Example workflow:** + +```bash +# Set up multiple contexts +cortextool config set-context prod --cluster prod-cluster --user prod-user +cortextool config set-context staging --cluster staging-cluster --user staging-user +cortextool config use-context prod # Set prod as default + +# Use prod (current-context) +cortextool rules list + +# Temporarily use staging without changing current-context +cortextool --context staging rules list + +# Still using prod by default +cortextool rules list +``` + +**Error handling:** + +If you specify a context that doesn't exist, cortextool will fail with a clear error: + +```bash +cortextool --context nonexistent rules list +# Error: context "nonexistent" not found in config file +``` + +## Config Management Commands + +cortextool provides subcommands to manage your configuration file: + +### View Configuration + +Display the current configuration: + +```bash +cortextool config view +``` + +### List Contexts + +Show all available contexts: + +```bash +cortextool config get-contexts +``` + +Output: +``` +CURRENT NAME +* production + staging + development +``` + +### Show Current Context + +Display which context is currently active: + +```bash +cortextool config current-context +``` + +### Switch Context + +Change the active context: + +```bash +cortextool config use-context staging +``` + +### Manage Contexts + +Create or update a context: + +```bash +# Create a new context +cortextool config set-context my-context --cluster my-cluster --user my-user + +# Update an existing context +cortextool config set-context production --cluster new-prod-cluster +``` + +### Manage Clusters + +Create or update cluster configuration: + +```bash +cortextool config set-cluster prod-cortex \ + --address https://cortex.prod.example.com \ + --tls-ca-path /path/to/ca.crt \ + --tls-cert-path /path/to/client.crt \ + --tls-key-path /path/to/client.key +``` + +### Manage Credentials + +Create or update user credentials: + +```bash +# JWT authentication +cortextool config set-credentials prod-user \ + --id tenant-123 \ + --auth-token eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... + +# Basic authentication +cortextool config set-credentials staging-user \ + --id tenant-456 \ + --user admin \ + --key password123 +``` + +### Delete Context + +Remove a context: + +```bash +cortextool config delete-context old-context +``` + +## Default Config Location + +By default, cortextool looks for the config file at: + +``` +$HOME/.cortextool/config +``` + +You can override this with: + +- The `--config` flag: `cortextool --config /custom/path rules list` +- Set a custom default when creating the config file + +## Example Workflows + +### Multi-Cluster Setup + +```bash +# Set up production cluster +cortextool config set-cluster prod --address https://cortex.prod.example.com +cortextool config set-credentials prod-user --id prod-tenant --auth-token +cortextool config set-context prod --cluster prod --user prod-user + +# Set up staging cluster +cortextool config set-cluster staging --address https://cortex.staging.example.com +cortextool config set-credentials staging-user --id staging-tenant --auth-token +cortextool config set-context staging --cluster staging --user staging-user + +# Use production +cortextool config use-context prod +cortextool rules list + +# Switch to staging +cortextool config use-context staging +cortextool rules list +``` + +### Local Development + +```bash +# Set up local development environment +cortextool config set-cluster local --address http://localhost:9009 +cortextool config set-credentials dev --id dev +cortextool config set-context dev --cluster local --user dev +cortextool config use-context dev + +# Now work with local Cortex +cortextool rules load rules.yaml +``` + +### Using TLS with mTLS + +```bash +cortextool config set-cluster secure-cluster \ + --address https://cortex.secure.example.com \ + --tls-ca-path ~/.cortextool/certs/ca.crt \ + --tls-cert-path ~/.cortextool/certs/client.crt \ + --tls-key-path ~/.cortextool/certs/client.key + +cortextool config set-credentials secure-user --id tenant-secure --auth-token +cortextool config set-context secure --cluster secure-cluster --user secure-user +cortextool config use-context secure +``` + +## Migration from Environment Variables + +If you're currently using environment variables, you can continue to use them alongside the config file. They will override config file values when set. + +To migrate, convert your environment variables to a config file: + +```bash +# Before (environment variables) +export CORTEX_ADDRESS=https://cortex.example.com +export CORTEX_TENANT_ID=my-tenant +export CORTEX_AUTH_TOKEN=my-token +cortextool rules list + +# After (config file) +cortextool config set-cluster my-cluster --address https://cortex.example.com +cortextool config set-credentials my-user --id my-tenant --auth-token my-token +cortextool config set-context default --cluster my-cluster --user my-user +cortextool config use-context default +cortextool rules list # No environment variables needed! +``` + +## Troubleshooting + +### Config file not found + +If cortextool can't find your config file, ensure it exists at `~/.cortextool/config` or specify it with `--config`. + +### Invalid current context + +If you get an error about the current context: + +```bash +# Check available contexts +cortextool config get-contexts + +# Switch to a valid context +cortextool config use-context +``` + +### Missing required fields + +If a command fails with "cortex address is required" or "tenant ID is required", ensure your context references valid cluster and user entries with all required fields set. + +## See Also + +- [cortextool.example.yaml](./cortextool.example.yaml) - Full example configuration file +- [README.md](./README.md) - Main documentation diff --git a/README.md b/README.md index 0bf030c32..90dfac4cd 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,57 @@ Config commands interact with the Cortex api and read/create/update/delete user #### Configuration +cortextool supports three ways to provide configuration: + +1. **Configuration File** (recommended for managing multiple clusters) +2. **Environment Variables** +3. **Command-line Flags** + +##### Configuration File + +cortextool supports a kubeconfig-style configuration file for managing multiple Cortex clusters. This allows you to define clusters, credentials, and contexts, and easily switch between them. + +**Quick Start:** + +Create a config file at `~/.cortextool/config`: + +```yaml +current-context: production + +contexts: + - name: production + context: + cluster: prod-cortex + user: prod-user + +clusters: + - name: prod-cortex + cluster: + address: https://cortex.prod.example.com + tls-ca-path: /path/to/ca.crt + tls-cert-path: /path/to/client.crt + tls-key-path: /path/to/client.key + +users: + - name: prod-user + user: + id: tenant-123 + auth-token: your-jwt-token +``` + +Then use cortextool without specifying connection details: + +```bash +cortextool rules list +cortextool config use-context staging +# Or temporarily use a different context +cortextool --context staging rules list +``` + +See [CONFIG.md](./CONFIG.md) for complete documentation and [cortextool.example.yaml](./cortextool.example.yaml) for a full example. + +##### Environment Variables and Flags + | Env Variables | Flag | Description | | ----------------- | --------- | ------------------------------------------------------------------------------------------------------------- | | CORTEX_ADDRESS | `address` | Address of the API of the desired Cortex cluster. | @@ -36,6 +87,15 @@ Config commands interact with the Cortex api and read/create/update/delete user | CORTEX_AUTH_TOKEN | `authToken`| In cases where the Cortex API is set behind gateway authenticating by bearer token, a token can be set as a bearer token header. | | CORTEX_TENANT_ID | `id` | The tenant ID of the Cortex instance to interact with. | +##### Global Flags + +| Flag | Description | +| --------- | ------------------------------------------------------------------------------------------------------------- | +| `--config` | Path to cortextool config file (default: `$HOME/.cortextool/config`) | +| `--context`| Name of the context to use from config file (overrides `current-context`) | + +**Configuration Precedence:** Command-line flags > Environment variables > `--context` flag > Config file (current-context) > Defaults + #### Alertmanager The following commands are used by users to interact with their Cortex alertmanager configuration, as well as their alert template files. diff --git a/cmd/cortextool/main.go b/cmd/cortextool/main.go index baad0cb1a..257b65335 100644 --- a/cmd/cortextool/main.go +++ b/cmd/cortextool/main.go @@ -4,9 +4,11 @@ import ( "fmt" "os" + log "github.com/sirupsen/logrus" "gopkg.in/alecthomas/kingpin.v2" "github.com/cortexproject/cortex-tools/pkg/commands" + "github.com/cortexproject/cortex-tools/pkg/config" "github.com/cortexproject/cortex-tools/pkg/version" ) @@ -21,10 +23,51 @@ var ( aclCommand commands.AccessControlCommand analyseCommand commands.AnalyseCommand bucketValidateCommand commands.BucketValidationCommand + configCommand commands.ConfigCommand + + configPath string + contextName string ) +func loadConfig(_ *kingpin.ParseContext) error { + cfg, err := config.LoadConfig(configPath) + if err != nil { + log.Warnf("Failed to load config file: %v", err) + return nil // Don't fail if config file has errors + } + if cfg != nil { + log.Debugf("Loaded config from %s", configPath) + } + + // If --context flag is specified, validate it exists + if contextName != "" { + if cfg == nil { + return fmt.Errorf("--context flag requires a config file, but no config file found at %s", configPath) + } + // Validate context exists + _, err := cfg.GetContext(contextName) + if err != nil { + return fmt.Errorf("context %q not found in config file: %w", contextName, err) + } + log.Debugf("Using context %q from --context flag", contextName) + } + + // Set the global config and context override for commands to use + commands.SetConfig(cfg, contextName) + return nil +} + func main() { app := kingpin.New("cortextool", "A command-line tool to manage cortex.") + app.Flag("config", "Path to cortextool config file."). + Default(config.DefaultConfigPath()). + StringVar(&configPath) + app.Flag("context", "Name of the context to use from config file."). + StringVar(&contextName) + + // Load config after flags are parsed but before commands run + app.PreAction(loadConfig) + logConfig.Register(app) alertCommand.Register(app) alertmanagerCommand.Register(app) @@ -35,6 +78,7 @@ func main() { aclCommand.Register(app) analyseCommand.Register(app) bucketValidateCommand.Register(app) + configCommand.Register(app, &configPath) app.Command("version", "Get the version of the cortextool CLI").Action(func(_ *kingpin.ParseContext) error { fmt.Print(version.Template) diff --git a/cortextool.example.yaml b/cortextool.example.yaml new file mode 100644 index 000000000..958e0608c --- /dev/null +++ b/cortextool.example.yaml @@ -0,0 +1,87 @@ +# Example cortextool configuration file +# This file demonstrates the kubeconfig-style configuration for managing multiple Cortex clusters + +# The current context to use (can be changed with: cortextool config use-context ) +current-context: production + +# Contexts bind together a cluster and user credentials +contexts: + - name: production + context: + cluster: prod-cortex + user: prod-admin + + - name: staging + context: + cluster: staging-cortex + user: staging-user + + - name: development + context: + cluster: dev-cortex + user: dev-user + +# Clusters define Cortex cluster connection details +clusters: + - name: prod-cortex + cluster: + # Cortex API address (required) + address: https://cortex.prod.example.com + + # TLS configuration for mTLS + tls-ca-path: /path/to/prod/ca.crt + tls-cert-path: /path/to/prod/client.crt + tls-key-path: /path/to/prod/client.key + + # Use legacy API routes (/api/prom/ instead of /api/v1/) + use-legacy-routes: false + + # Custom ruler API path (default: /api/v1/rules) + # ruler-api-path: /prometheus/api/v1/rules + + - name: staging-cortex + cluster: + address: https://cortex.staging.example.com + # TLS is optional - omit if not using mTLS + + - name: dev-cortex + cluster: + address: http://localhost:9009 + use-legacy-routes: false + +# Users define authentication credentials for tenants +users: + - name: prod-admin + user: + # Tenant ID (required for most commands) + id: prod-tenant-123 + + # Bearer token for JWT authentication + auth-token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... + + # Alternatively, use basic auth: + # user: admin + # key: password123 + + - name: staging-user + user: + id: staging-tenant-456 + # Basic authentication + user: staging-user + key: staging-password + + - name: dev-user + user: + id: dev + # No authentication required for local development + +# Configuration Precedence: +# Command-line flags > Environment variables > Config file > Defaults +# +# Example: Override config file values with flags +# cortextool rules list --address https://different-cortex.com --id different-tenant +# +# Or with environment variables: +# export CORTEX_ADDRESS=https://different-cortex.com +# export CORTEX_TENANT_ID=different-tenant +# cortextool rules list diff --git a/pkg/commands/alerts.go b/pkg/commands/alerts.go index ba5950fb5..5f25828ea 100644 --- a/pkg/commands/alerts.go +++ b/pkg/commands/alerts.go @@ -64,8 +64,8 @@ type AlertCommand struct { // Register rule related commands and flags with the kingpin application func (a *AlertmanagerCommand) Register(app *kingpin.Application) { alertCmd := app.Command("alertmanager", "View & edit alertmanager configs stored in cortex.").PreAction(a.setup) - alertCmd.Flag("address", "Address of the cortex cluster, alternatively set CORTEX_ADDRESS.").Envar("CORTEX_ADDRESS").Required().StringVar(&a.ClientConfig.Address) - alertCmd.Flag("id", "Cortex tenant id, alternatively set CORTEX_TENANT_ID.").Envar("CORTEX_TENANT_ID").Required().StringVar(&a.ClientConfig.ID) + alertCmd.Flag("address", "Address of the cortex cluster, alternatively set CORTEX_ADDRESS or use config file.").Envar("CORTEX_ADDRESS").StringVar(&a.ClientConfig.Address) + alertCmd.Flag("id", "Cortex tenant id, alternatively set CORTEX_TENANT_ID or use config file.").Envar("CORTEX_TENANT_ID").StringVar(&a.ClientConfig.ID) alertCmd.Flag("authToken", "Authentication token for bearer token or JWT auth, alternatively set CORTEX_AUTH_TOKEN.").Default("").Envar("CORTEX_AUTH_TOKEN").StringVar(&a.ClientConfig.AuthToken) alertCmd.Flag("user", "API user to use when contacting cortex, alternatively set CORTEX_API_USER. If empty, CORTEX_TENANT_ID will be used instead.").Default("").Envar("CORTEX_API_USER").StringVar(&a.ClientConfig.User) alertCmd.Flag("key", "API key to use when contacting cortex, alternatively set CORTEX_API_KEY.").Default("").Envar("CORTEX_API_KEY").StringVar(&a.ClientConfig.Key) @@ -85,6 +85,19 @@ func (a *AlertmanagerCommand) Register(app *kingpin.Application) { } func (a *AlertmanagerCommand) setup(_ *kingpin.ParseContext) error { + // Apply config file defaults + if err := ApplyConfigDefaults(&a.ClientConfig); err != nil { + return err + } + + // Validate required fields (they may come from config file) + if a.ClientConfig.Address == "" { + return fmt.Errorf("cortex address is required (use --address flag, CORTEX_ADDRESS env var, or config file)") + } + if a.ClientConfig.ID == "" { + return fmt.Errorf("tenant ID is required (use --id flag, CORTEX_TENANT_ID env var, or config file)") + } + cli, err := client.New(a.ClientConfig) if err != nil { return err diff --git a/pkg/commands/common.go b/pkg/commands/common.go new file mode 100644 index 000000000..d36364ef6 --- /dev/null +++ b/pkg/commands/common.go @@ -0,0 +1,93 @@ +package commands + +import ( + "github.com/cortexproject/cortex-tools/pkg/client" + "github.com/cortexproject/cortex-tools/pkg/config" +) + +var ( + globalConfig *config.Config + globalContextOverride string +) + +// SetConfig sets the global configuration and optional context override for all commands to use +func SetConfig(cfg *config.Config, contextName string) { + globalConfig = cfg + globalContextOverride = contextName +} + +// GetConfig returns the global configuration +func GetConfig() *config.Config { + return globalConfig +} + +// ApplyConfigDefaults applies config file defaults to a client.Config +// Config file values are used as defaults if the field is empty +// Precedence: CLI flags/Env vars > --context flag > current-context > Defaults +func ApplyConfigDefaults(clientCfg *client.Config) error { + if globalConfig == nil { + return nil + } + + // Get the context to use (from --context flag or current-context) + var contextCfg *config.ContextConfig + var err error + + if globalContextOverride != "" { + // Use context from --context flag + contextCfg, err = globalConfig.GetContext(globalContextOverride) + } else { + // Use current-context from config file + contextCfg, err = globalConfig.GetCurrentContext() + } + + if err != nil { + // If there's no context or it's invalid, skip applying defaults + return nil + } + + // Apply config defaults only if the field is empty (not set by flag or env var) + if clientCfg.Address == "" && contextCfg.Address != "" { + clientCfg.Address = contextCfg.Address + } + + if clientCfg.ID == "" && contextCfg.ID != "" { + clientCfg.ID = contextCfg.ID + } + + if clientCfg.User == "" && contextCfg.User != "" { + clientCfg.User = contextCfg.User + } + + if clientCfg.Key == "" && contextCfg.Key != "" { + clientCfg.Key = contextCfg.Key + } + + if clientCfg.AuthToken == "" && contextCfg.AuthToken != "" { + clientCfg.AuthToken = contextCfg.AuthToken + } + + if clientCfg.TLS.CAPath == "" && contextCfg.TLSCAPath != "" { + clientCfg.TLS.CAPath = contextCfg.TLSCAPath + } + + if clientCfg.TLS.CertPath == "" && contextCfg.TLSCertPath != "" { + clientCfg.TLS.CertPath = contextCfg.TLSCertPath + } + + if clientCfg.TLS.KeyPath == "" && contextCfg.TLSKeyPath != "" { + clientCfg.TLS.KeyPath = contextCfg.TLSKeyPath + } + + if clientCfg.RulerAPIPath == "" && contextCfg.RulerAPIPath != "" { + clientCfg.RulerAPIPath = contextCfg.RulerAPIPath + } + + // UseLegacyRoutes is a boolean, so we can't check for "empty" + // We'll only apply the config value if it's true + if !clientCfg.UseLegacyRoutes && contextCfg.UseLegacyRoutes { + clientCfg.UseLegacyRoutes = contextCfg.UseLegacyRoutes + } + + return nil +} diff --git a/pkg/commands/config_command.go b/pkg/commands/config_command.go new file mode 100644 index 000000000..47ccc28bc --- /dev/null +++ b/pkg/commands/config_command.go @@ -0,0 +1,369 @@ +package commands + +import ( + "fmt" + + log "github.com/sirupsen/logrus" + "gopkg.in/alecthomas/kingpin.v2" + "gopkg.in/yaml.v3" + + "github.com/cortexproject/cortex-tools/pkg/config" +) + +// ConfigCommand handles config file management +type ConfigCommand struct { + configPathPtr *string // pointer to the global config path + + // set-context flags + contextName string + clusterName string + userName string + + // set-cluster flags + address string + tlsCAPath string + tlsCertPath string + tlsKeyPath string + useLegacyRoutes bool + rulerAPIPath string + + // set-credentials flags + id string + user string + key string + authToken string + + // use-context flags + useContextName string +} + +// Register config command and its subcommands +func (c *ConfigCommand) Register(app *kingpin.Application, configPath *string) { + c.configPathPtr = configPath + configCmd := app.Command("config", "Manage cortextool configuration.") + + // view command + configCmd.Command("view", "Display merged configuration."). + Action(c.viewConfig) + + // get-contexts command + configCmd.Command("get-contexts", "List all contexts."). + Action(c.getContexts) + + // current-context command + configCmd.Command("current-context", "Display the current context."). + Action(c.currentContext) + + // use-context command + useContextCmd := configCmd.Command("use-context", "Set the current context."). + Action(c.useContext) + useContextCmd.Arg("name", "Context name to use."). + Required(). + StringVar(&c.useContextName) + + // set-context command + setContextCmd := configCmd.Command("set-context", "Create or modify a context."). + Action(c.setContext) + setContextCmd.Arg("name", "Context name."). + Required(). + StringVar(&c.contextName) + setContextCmd.Flag("cluster", "Cluster name for the context."). + StringVar(&c.clusterName) + setContextCmd.Flag("user", "User name for the context."). + StringVar(&c.userName) + + // set-cluster command + setClusterCmd := configCmd.Command("set-cluster", "Create or modify a cluster."). + Action(c.setCluster) + setClusterCmd.Arg("name", "Cluster name."). + Required(). + StringVar(&c.clusterName) + setClusterCmd.Flag("address", "Cortex cluster address."). + StringVar(&c.address) + setClusterCmd.Flag("tls-ca-path", "TLS CA certificate path."). + StringVar(&c.tlsCAPath) + setClusterCmd.Flag("tls-cert-path", "TLS client certificate path."). + StringVar(&c.tlsCertPath) + setClusterCmd.Flag("tls-key-path", "TLS client key path."). + StringVar(&c.tlsKeyPath) + setClusterCmd.Flag("use-legacy-routes", "Use legacy API routes."). + BoolVar(&c.useLegacyRoutes) + setClusterCmd.Flag("ruler-api-path", "Custom ruler API path."). + StringVar(&c.rulerAPIPath) + + // set-credentials command + setCredsCmd := configCmd.Command("set-credentials", "Create or modify user credentials."). + Action(c.setCredentials) + setCredsCmd.Arg("name", "User name."). + Required(). + StringVar(&c.userName) + setCredsCmd.Flag("id", "Tenant ID."). + StringVar(&c.id) + setCredsCmd.Flag("user", "Basic auth username."). + StringVar(&c.user) + setCredsCmd.Flag("key", "Basic auth password/key."). + StringVar(&c.key) + setCredsCmd.Flag("auth-token", "Bearer token for JWT auth."). + StringVar(&c.authToken) + + // delete-context command + deleteContextCmd := configCmd.Command("delete-context", "Delete a context."). + Action(c.deleteContext) + deleteContextCmd.Arg("name", "Context name to delete."). + Required(). + StringVar(&c.contextName) +} + +func (c *ConfigCommand) loadOrCreateConfig() (*config.Config, error) { + cfg, err := config.LoadConfig(*c.configPathPtr) + if err != nil { + return nil, err + } + if cfg == nil { + cfg = &config.Config{} + } + return cfg, nil +} + +func (c *ConfigCommand) viewConfig(_ *kingpin.ParseContext) error { + cfg, err := config.LoadConfig(*c.configPathPtr) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + if cfg == nil { + fmt.Println("No configuration file found.") + return nil + } + + data, err := yaml.Marshal(cfg) + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + fmt.Print(string(data)) + return nil +} + +func (c *ConfigCommand) getContexts(_ *kingpin.ParseContext) error { + cfg, err := config.LoadConfig(*c.configPathPtr) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + if cfg == nil { + fmt.Println("No configuration file found.") + return nil + } + + if len(cfg.Contexts) == 0 { + fmt.Println("No contexts found.") + return nil + } + + fmt.Printf("CURRENT NAME\n") + for _, ctx := range cfg.Contexts { + current := "" + if ctx.Name == cfg.CurrentContext { + current = "*" + } + fmt.Printf("%-9s %s\n", current, ctx.Name) + } + return nil +} + +func (c *ConfigCommand) currentContext(_ *kingpin.ParseContext) error { + cfg, err := config.LoadConfig(*c.configPathPtr) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + if cfg == nil { + fmt.Println("No configuration file found.") + return nil + } + + if cfg.CurrentContext == "" { + fmt.Println("No current context set.") + return nil + } + + fmt.Println(cfg.CurrentContext) + return nil +} + +func (c *ConfigCommand) useContext(_ *kingpin.ParseContext) error { + cfg, err := c.loadOrCreateConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + // Check if context exists + found := false + for _, ctx := range cfg.Contexts { + if ctx.Name == c.useContextName { + found = true + break + } + } + if !found { + return fmt.Errorf("context %q not found", c.useContextName) + } + + cfg.CurrentContext = c.useContextName + + if err := config.SaveConfig(cfg, *c.configPathPtr); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + log.Infof("Switched to context %q", c.useContextName) + return nil +} + +func (c *ConfigCommand) setContext(_ *kingpin.ParseContext) error { + cfg, err := c.loadOrCreateConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + // If cluster or user not specified, check if context already exists and keep existing values + existing := false + for _, ctx := range cfg.Contexts { + if ctx.Name == c.contextName { + existing = true + if c.clusterName == "" { + c.clusterName = ctx.Context.Cluster + } + if c.userName == "" { + c.userName = ctx.Context.User + } + break + } + } + + if !existing { + if c.clusterName == "" || c.userName == "" { + return fmt.Errorf("--cluster and --user are required when creating a new context") + } + } + + cfg.SetContext(c.contextName, c.clusterName, c.userName) + + if err := config.SaveConfig(cfg, *c.configPathPtr); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + log.Infof("Context %q set", c.contextName) + return nil +} + +func (c *ConfigCommand) setCluster(_ *kingpin.ParseContext) error { + cfg, err := c.loadOrCreateConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + // Check if cluster exists and merge with existing values + cluster := config.Cluster{ + Address: c.address, + TLSCAPath: c.tlsCAPath, + TLSCertPath: c.tlsCertPath, + TLSKeyPath: c.tlsKeyPath, + UseLegacyRoutes: c.useLegacyRoutes, + RulerAPIPath: c.rulerAPIPath, + } + + // If cluster exists, merge with existing values (don't overwrite with empty strings) + for _, existing := range cfg.Clusters { + if existing.Name == c.clusterName { + if cluster.Address == "" { + cluster.Address = existing.Cluster.Address + } + if cluster.TLSCAPath == "" { + cluster.TLSCAPath = existing.Cluster.TLSCAPath + } + if cluster.TLSCertPath == "" { + cluster.TLSCertPath = existing.Cluster.TLSCertPath + } + if cluster.TLSKeyPath == "" { + cluster.TLSKeyPath = existing.Cluster.TLSKeyPath + } + if cluster.RulerAPIPath == "" { + cluster.RulerAPIPath = existing.Cluster.RulerAPIPath + } + // For boolean, we keep the new value (could be explicitly set to false) + break + } + } + + cfg.SetCluster(c.clusterName, cluster) + + if err := config.SaveConfig(cfg, *c.configPathPtr); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + log.Infof("Cluster %q set", c.clusterName) + return nil +} + +func (c *ConfigCommand) setCredentials(_ *kingpin.ParseContext) error { + cfg, err := c.loadOrCreateConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + user := config.User{ + ID: c.id, + User: c.user, + Key: c.key, + AuthToken: c.authToken, + } + + // If user exists, merge with existing values (don't overwrite with empty strings) + for _, existing := range cfg.Users { + if existing.Name == c.userName { + if user.ID == "" { + user.ID = existing.User.ID + } + if user.User == "" { + user.User = existing.User.User + } + if user.Key == "" { + user.Key = existing.User.Key + } + if user.AuthToken == "" { + user.AuthToken = existing.User.AuthToken + } + break + } + } + + cfg.SetUser(c.userName, user) + + if err := config.SaveConfig(cfg, *c.configPathPtr); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + log.Infof("Credentials for user %q set", c.userName) + return nil +} + +func (c *ConfigCommand) deleteContext(_ *kingpin.ParseContext) error { + cfg, err := c.loadOrCreateConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + if !cfg.DeleteContext(c.contextName) { + return fmt.Errorf("context %q not found", c.contextName) + } + + // If we deleted the current context, clear it + if cfg.CurrentContext == c.contextName { + cfg.CurrentContext = "" + } + + if err := config.SaveConfig(cfg, *c.configPathPtr); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + log.Infof("Context %q deleted", c.contextName) + return nil +} diff --git a/pkg/commands/rules.go b/pkg/commands/rules.go index 3521d0f0e..5d5acc89b 100644 --- a/pkg/commands/rules.go +++ b/pkg/commands/rules.go @@ -126,14 +126,12 @@ func (r *RuleCommand) Register(app *kingpin.Application) { // Require Cortex cluster address and tentant ID on all these commands for _, c := range []*kingpin.CmdClause{listCmd, printRulesCmd, getRuleGroupCmd, deleteRuleGroupCmd, deleteRuleNamespaceCmd, loadRulesCmd, diffRulesCmd, syncRulesCmd} { - c.Flag("address", "Address of the cortex cluster, alternatively set CORTEX_ADDRESS."). + c.Flag("address", "Address of the cortex cluster, alternatively set CORTEX_ADDRESS or use config file."). Envar("CORTEX_ADDRESS"). - Required(). StringVar(&r.ClientConfig.Address) - c.Flag("id", "Cortex tenant id, alternatively set CORTEX_TENANT_ID."). + c.Flag("id", "Cortex tenant id, alternatively set CORTEX_TENANT_ID or use config file."). Envar("CORTEX_TENANT_ID"). - Required(). StringVar(&r.ClientConfig.ID) c.Flag("use-legacy-routes", "If set, API requests to cortex will use the legacy /api/prom/ routes, alternatively set CORTEX_USE_LEGACY_ROUTES."). @@ -246,6 +244,19 @@ func (r *RuleCommand) setup(_ *kingpin.ParseContext) error { ruleLoadSuccessTimestamp, ) + // Apply config file defaults + if err := ApplyConfigDefaults(&r.ClientConfig); err != nil { + return err + } + + // Validate required fields (they may come from config file) + if r.ClientConfig.Address == "" { + return fmt.Errorf("cortex address is required (use --address flag, CORTEX_ADDRESS env var, or config file)") + } + if r.ClientConfig.ID == "" { + return fmt.Errorf("tenant ID is required (use --id flag, CORTEX_TENANT_ID env var, or config file)") + } + cli, err := client.New(r.ClientConfig) if err != nil { return err diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 000000000..7238ed0e2 --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,336 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// Config represents the cortextool configuration file structure +type Config struct { + CurrentContext string `yaml:"current-context"` + Contexts []NamedContext `yaml:"contexts"` + Clusters []NamedCluster `yaml:"clusters"` + Users []NamedUser `yaml:"users"` +} + +// NamedContext associates a name with a context +type NamedContext struct { + Name string `yaml:"name"` + Context Context `yaml:"context"` +} + +// Context references a cluster and user +type Context struct { + Cluster string `yaml:"cluster"` + User string `yaml:"user"` +} + +// NamedCluster associates a name with a cluster +type NamedCluster struct { + Name string `yaml:"name"` + Cluster Cluster `yaml:"cluster"` +} + +// Cluster contains cluster connection information +type Cluster struct { + Address string `yaml:"address"` + TLSCAPath string `yaml:"tls-ca-path,omitempty"` + TLSCertPath string `yaml:"tls-cert-path,omitempty"` + TLSKeyPath string `yaml:"tls-key-path,omitempty"` + UseLegacyRoutes bool `yaml:"use-legacy-routes,omitempty"` + RulerAPIPath string `yaml:"ruler-api-path,omitempty"` +} + +// NamedUser associates a name with user credentials +type NamedUser struct { + Name string `yaml:"name"` + User User `yaml:"user"` +} + +// User contains authentication information +type User struct { + ID string `yaml:"id"` + User string `yaml:"user,omitempty"` + Key string `yaml:"key,omitempty"` + AuthToken string `yaml:"auth-token,omitempty"` +} + +// ContextConfig represents the merged configuration from a specific context +type ContextConfig struct { + Address string + TLSCAPath string + TLSCertPath string + TLSKeyPath string + UseLegacyRoutes bool + RulerAPIPath string + ID string + User string + Key string + AuthToken string +} + +// DefaultConfigPath returns the default config file path +func DefaultConfigPath() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".cortextool", "config") +} + +// LoadConfig loads the configuration from the specified path +func LoadConfig(path string) (*Config, error) { + if path == "" { + path = DefaultConfigPath() + } + + // If file doesn't exist, return empty config (not an error) + if _, err := os.Stat(path); os.IsNotExist(err) { + return nil, nil + } + + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + var config Config + if err := yaml.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to parse config file: %w", err) + } + + return &config, nil +} + +// SaveConfig saves the configuration to the specified path +func SaveConfig(config *Config, path string) error { + if path == "" { + path = DefaultConfigPath() + } + + // Ensure directory exists + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + data, err := yaml.Marshal(config) + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + if err := os.WriteFile(path, data, 0600); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + return nil +} + +// GetCurrentContext returns the merged configuration for the current context +func (c *Config) GetCurrentContext() (*ContextConfig, error) { + if c == nil { + return nil, nil + } + + if c.CurrentContext == "" { + return nil, fmt.Errorf("no current context set") + } + + // Find the context + var ctx *Context + for _, nc := range c.Contexts { + if nc.Name == c.CurrentContext { + ctx = &nc.Context + break + } + } + if ctx == nil { + return nil, fmt.Errorf("context %q not found", c.CurrentContext) + } + + // Find the cluster + var cluster *Cluster + for _, nc := range c.Clusters { + if nc.Name == ctx.Cluster { + cluster = &nc.Cluster + break + } + } + if cluster == nil { + return nil, fmt.Errorf("cluster %q not found", ctx.Cluster) + } + + // Find the user + var user *User + for _, nu := range c.Users { + if nu.Name == ctx.User { + user = &nu.User + break + } + } + if user == nil { + return nil, fmt.Errorf("user %q not found", ctx.User) + } + + // Merge into ContextConfig + return &ContextConfig{ + Address: cluster.Address, + TLSCAPath: cluster.TLSCAPath, + TLSCertPath: cluster.TLSCertPath, + TLSKeyPath: cluster.TLSKeyPath, + UseLegacyRoutes: cluster.UseLegacyRoutes, + RulerAPIPath: cluster.RulerAPIPath, + ID: user.ID, + User: user.User, + Key: user.Key, + AuthToken: user.AuthToken, + }, nil +} + +// GetContext returns a specific context by name +func (c *Config) GetContext(name string) (*ContextConfig, error) { + if c == nil { + return nil, nil + } + + // Find the context + var ctx *Context + for _, nc := range c.Contexts { + if nc.Name == name { + ctx = &nc.Context + break + } + } + if ctx == nil { + return nil, fmt.Errorf("context %q not found", name) + } + + // Find the cluster + var cluster *Cluster + for _, nc := range c.Clusters { + if nc.Name == ctx.Cluster { + cluster = &nc.Cluster + break + } + } + if cluster == nil { + return nil, fmt.Errorf("cluster %q not found", ctx.Cluster) + } + + // Find the user + var user *User + for _, nu := range c.Users { + if nu.Name == ctx.User { + user = &nu.User + break + } + } + if user == nil { + return nil, fmt.Errorf("user %q not found", ctx.User) + } + + // Merge into ContextConfig + return &ContextConfig{ + Address: cluster.Address, + TLSCAPath: cluster.TLSCAPath, + TLSCertPath: cluster.TLSCertPath, + TLSKeyPath: cluster.TLSKeyPath, + UseLegacyRoutes: cluster.UseLegacyRoutes, + RulerAPIPath: cluster.RulerAPIPath, + ID: user.ID, + User: user.User, + Key: user.Key, + AuthToken: user.AuthToken, + }, nil +} + +// SetContext creates or updates a context +func (c *Config) SetContext(name, cluster, user string) { + // Check if context exists + for i, nc := range c.Contexts { + if nc.Name == name { + c.Contexts[i].Context.Cluster = cluster + c.Contexts[i].Context.User = user + return + } + } + + // Add new context + c.Contexts = append(c.Contexts, NamedContext{ + Name: name, + Context: Context{ + Cluster: cluster, + User: user, + }, + }) +} + +// SetCluster creates or updates a cluster +func (c *Config) SetCluster(name string, cluster Cluster) { + // Check if cluster exists + for i, nc := range c.Clusters { + if nc.Name == name { + c.Clusters[i].Cluster = cluster + return + } + } + + // Add new cluster + c.Clusters = append(c.Clusters, NamedCluster{ + Name: name, + Cluster: cluster, + }) +} + +// SetUser creates or updates a user +func (c *Config) SetUser(name string, user User) { + // Check if user exists + for i, nu := range c.Users { + if nu.Name == name { + c.Users[i].User = user + return + } + } + + // Add new user + c.Users = append(c.Users, NamedUser{ + Name: name, + User: user, + }) +} + +// DeleteContext removes a context +func (c *Config) DeleteContext(name string) bool { + for i, nc := range c.Contexts { + if nc.Name == name { + c.Contexts = append(c.Contexts[:i], c.Contexts[i+1:]...) + return true + } + } + return false +} + +// DeleteCluster removes a cluster +func (c *Config) DeleteCluster(name string) bool { + for i, nc := range c.Clusters { + if nc.Name == name { + c.Clusters = append(c.Clusters[:i], c.Clusters[i+1:]...) + return true + } + } + return false +} + +// DeleteUser removes a user +func (c *Config) DeleteUser(name string) bool { + for i, nu := range c.Users { + if nu.Name == name { + c.Users = append(c.Users[:i], c.Users[i+1:]...) + return true + } + } + return false +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go new file mode 100644 index 000000000..92d7707f1 --- /dev/null +++ b/pkg/config/config_test.go @@ -0,0 +1,424 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestLoadConfig(t *testing.T) { + // Create a temporary config file + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config") + + testConfig := &Config{ + CurrentContext: "test-context", + Contexts: []NamedContext{ + { + Name: "test-context", + Context: Context{ + Cluster: "test-cluster", + User: "test-user", + }, + }, + }, + Clusters: []NamedCluster{ + { + Name: "test-cluster", + Cluster: Cluster{ + Address: "https://cortex.example.com", + TLSCAPath: "/path/to/ca.crt", + TLSCertPath: "/path/to/client.crt", + TLSKeyPath: "/path/to/client.key", + UseLegacyRoutes: true, + RulerAPIPath: "/custom/ruler/path", + }, + }, + }, + Users: []NamedUser{ + { + Name: "test-user", + User: User{ + ID: "test-tenant", + AuthToken: "test-token", + }, + }, + }, + } + + // Write test config + data, err := yaml.Marshal(testConfig) + if err != nil { + t.Fatalf("failed to marshal test config: %v", err) + } + if err := os.WriteFile(configPath, data, 0600); err != nil { + t.Fatalf("failed to write test config: %v", err) + } + + // Test loading + loaded, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + if loaded == nil { + t.Fatal("LoadConfig returned nil config") + } + + if loaded.CurrentContext != "test-context" { + t.Errorf("expected current-context 'test-context', got %q", loaded.CurrentContext) + } + + if len(loaded.Contexts) != 1 { + t.Errorf("expected 1 context, got %d", len(loaded.Contexts)) + } + + if len(loaded.Clusters) != 1 { + t.Errorf("expected 1 cluster, got %d", len(loaded.Clusters)) + } + + if len(loaded.Users) != 1 { + t.Errorf("expected 1 user, got %d", len(loaded.Users)) + } +} + +func TestLoadConfigNonExistent(t *testing.T) { + // Loading a non-existent config should return nil, nil (not an error) + cfg, err := LoadConfig("/path/that/does/not/exist") + if err != nil { + t.Errorf("expected no error for non-existent config, got: %v", err) + } + if cfg != nil { + t.Errorf("expected nil config for non-existent file, got: %v", cfg) + } +} + +func TestGetCurrentContext(t *testing.T) { + cfg := &Config{ + CurrentContext: "test-context", + Contexts: []NamedContext{ + { + Name: "test-context", + Context: Context{ + Cluster: "test-cluster", + User: "test-user", + }, + }, + }, + Clusters: []NamedCluster{ + { + Name: "test-cluster", + Cluster: Cluster{ + Address: "https://cortex.example.com", + TLSCAPath: "/path/to/ca.crt", + UseLegacyRoutes: true, + }, + }, + }, + Users: []NamedUser{ + { + Name: "test-user", + User: User{ + ID: "test-tenant", + User: "basic-user", + Key: "basic-pass", + AuthToken: "test-token", + }, + }, + }, + } + + ctx, err := cfg.GetCurrentContext() + if err != nil { + t.Fatalf("GetCurrentContext failed: %v", err) + } + + if ctx.Address != "https://cortex.example.com" { + t.Errorf("expected address 'https://cortex.example.com', got %q", ctx.Address) + } + + if ctx.TLSCAPath != "/path/to/ca.crt" { + t.Errorf("expected tls-ca-path '/path/to/ca.crt', got %q", ctx.TLSCAPath) + } + + if ctx.UseLegacyRoutes != true { + t.Errorf("expected use-legacy-routes true, got %v", ctx.UseLegacyRoutes) + } + + if ctx.ID != "test-tenant" { + t.Errorf("expected id 'test-tenant', got %q", ctx.ID) + } + + if ctx.User != "basic-user" { + t.Errorf("expected user 'basic-user', got %q", ctx.User) + } + + if ctx.Key != "basic-pass" { + t.Errorf("expected key 'basic-pass', got %q", ctx.Key) + } + + if ctx.AuthToken != "test-token" { + t.Errorf("expected auth-token 'test-token', got %q", ctx.AuthToken) + } +} + +func TestGetCurrentContextErrors(t *testing.T) { + tests := []struct { + name string + config *Config + wantErr bool + }{ + { + name: "no current context set", + config: &Config{ + CurrentContext: "", + }, + wantErr: true, + }, + { + name: "context not found", + config: &Config{ + CurrentContext: "missing-context", + Contexts: []NamedContext{}, + }, + wantErr: true, + }, + { + name: "cluster not found", + config: &Config{ + CurrentContext: "test-context", + Contexts: []NamedContext{ + { + Name: "test-context", + Context: Context{ + Cluster: "missing-cluster", + User: "test-user", + }, + }, + }, + Clusters: []NamedCluster{}, + }, + wantErr: true, + }, + { + name: "user not found", + config: &Config{ + CurrentContext: "test-context", + Contexts: []NamedContext{ + { + Name: "test-context", + Context: Context{ + Cluster: "test-cluster", + User: "missing-user", + }, + }, + }, + Clusters: []NamedCluster{ + {Name: "test-cluster", Cluster: Cluster{Address: "http://test"}}, + }, + Users: []NamedUser{}, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := tt.config.GetCurrentContext() + if (err != nil) != tt.wantErr { + t.Errorf("GetCurrentContext() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestSetContext(t *testing.T) { + cfg := &Config{} + + // Add a new context + cfg.SetContext("new-context", "cluster1", "user1") + + if len(cfg.Contexts) != 1 { + t.Fatalf("expected 1 context, got %d", len(cfg.Contexts)) + } + + if cfg.Contexts[0].Name != "new-context" { + t.Errorf("expected name 'new-context', got %q", cfg.Contexts[0].Name) + } + + if cfg.Contexts[0].Context.Cluster != "cluster1" { + t.Errorf("expected cluster 'cluster1', got %q", cfg.Contexts[0].Context.Cluster) + } + + // Update existing context + cfg.SetContext("new-context", "cluster2", "user2") + + if len(cfg.Contexts) != 1 { + t.Fatalf("expected 1 context after update, got %d", len(cfg.Contexts)) + } + + if cfg.Contexts[0].Context.Cluster != "cluster2" { + t.Errorf("expected updated cluster 'cluster2', got %q", cfg.Contexts[0].Context.Cluster) + } +} + +func TestSetCluster(t *testing.T) { + cfg := &Config{} + + cluster := Cluster{ + Address: "https://cortex.example.com", + TLSCAPath: "/path/to/ca.crt", + TLSCertPath: "/path/to/client.crt", + TLSKeyPath: "/path/to/client.key", + } + + // Add a new cluster + cfg.SetCluster("test-cluster", cluster) + + if len(cfg.Clusters) != 1 { + t.Fatalf("expected 1 cluster, got %d", len(cfg.Clusters)) + } + + if cfg.Clusters[0].Name != "test-cluster" { + t.Errorf("expected name 'test-cluster', got %q", cfg.Clusters[0].Name) + } + + if cfg.Clusters[0].Cluster.Address != cluster.Address { + t.Errorf("expected address %q, got %q", cluster.Address, cfg.Clusters[0].Cluster.Address) + } + + // Update existing cluster + updatedCluster := Cluster{ + Address: "https://cortex2.example.com", + } + cfg.SetCluster("test-cluster", updatedCluster) + + if len(cfg.Clusters) != 1 { + t.Fatalf("expected 1 cluster after update, got %d", len(cfg.Clusters)) + } + + if cfg.Clusters[0].Cluster.Address != updatedCluster.Address { + t.Errorf("expected updated address %q, got %q", updatedCluster.Address, cfg.Clusters[0].Cluster.Address) + } +} + +func TestSetUser(t *testing.T) { + cfg := &Config{} + + user := User{ + ID: "tenant-123", + AuthToken: "token-abc", + } + + // Add a new user + cfg.SetUser("test-user", user) + + if len(cfg.Users) != 1 { + t.Fatalf("expected 1 user, got %d", len(cfg.Users)) + } + + if cfg.Users[0].Name != "test-user" { + t.Errorf("expected name 'test-user', got %q", cfg.Users[0].Name) + } + + if cfg.Users[0].User.ID != user.ID { + t.Errorf("expected id %q, got %q", user.ID, cfg.Users[0].User.ID) + } + + // Update existing user + updatedUser := User{ + ID: "tenant-456", + AuthToken: "token-xyz", + } + cfg.SetUser("test-user", updatedUser) + + if len(cfg.Users) != 1 { + t.Fatalf("expected 1 user after update, got %d", len(cfg.Users)) + } + + if cfg.Users[0].User.ID != updatedUser.ID { + t.Errorf("expected updated id %q, got %q", updatedUser.ID, cfg.Users[0].User.ID) + } +} + +func TestDeleteContext(t *testing.T) { + cfg := &Config{ + Contexts: []NamedContext{ + {Name: "context1", Context: Context{Cluster: "c1", User: "u1"}}, + {Name: "context2", Context: Context{Cluster: "c2", User: "u2"}}, + }, + } + + // Delete existing context + deleted := cfg.DeleteContext("context1") + if !deleted { + t.Error("expected DeleteContext to return true") + } + + if len(cfg.Contexts) != 1 { + t.Fatalf("expected 1 context after delete, got %d", len(cfg.Contexts)) + } + + if cfg.Contexts[0].Name != "context2" { + t.Errorf("expected remaining context 'context2', got %q", cfg.Contexts[0].Name) + } + + // Delete non-existent context + deleted = cfg.DeleteContext("non-existent") + if deleted { + t.Error("expected DeleteContext to return false for non-existent context") + } +} + +func TestSaveConfig(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "test-config") + + cfg := &Config{ + CurrentContext: "test", + Contexts: []NamedContext{ + {Name: "test", Context: Context{Cluster: "c", User: "u"}}, + }, + Clusters: []NamedCluster{ + {Name: "c", Cluster: Cluster{Address: "http://test"}}, + }, + Users: []NamedUser{ + {Name: "u", User: User{ID: "tenant"}}, + }, + } + + // Save config + err := SaveConfig(cfg, configPath) + if err != nil { + t.Fatalf("SaveConfig failed: %v", err) + } + + // Verify file exists + if _, err := os.Stat(configPath); os.IsNotExist(err) { + t.Fatal("config file was not created") + } + + // Load and verify + loaded, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("failed to load saved config: %v", err) + } + + if loaded.CurrentContext != cfg.CurrentContext { + t.Errorf("expected current-context %q, got %q", cfg.CurrentContext, loaded.CurrentContext) + } +} + +func TestDefaultConfigPath(t *testing.T) { + path := DefaultConfigPath() + if path == "" { + t.Error("DefaultConfigPath returned empty string") + } + + // Should end with .cortextool/config + if !filepath.IsAbs(path) { + t.Error("DefaultConfigPath should return absolute path") + } +} diff --git a/vendor/github.com/weaveworks/common/COPYING.LGPL-3 b/vendor/github.com/weaveworks/common/COPYING.LGPL-3 deleted file mode 100644 index 89c7d69ec..000000000 --- a/vendor/github.com/weaveworks/common/COPYING.LGPL-3 +++ /dev/null @@ -1,175 +0,0 @@ -./tools/integration/assert.sh is a copy of - - https://github.com/lehmannro/assert.sh/blob/master/assert.sh - -Since it was imported from its original source, it has only received -cosmetic modifications. As it is licensed under the LGPL-3, here's the -license text in its entirety: - - - - GNU LESSER GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - - This version of the GNU Lesser General Public License incorporates -the terms and conditions of version 3 of the GNU General Public -License, supplemented by the additional permissions listed below. - - 0. Additional Definitions. - - As used herein, "this License" refers to version 3 of the GNU Lesser -General Public License, and the "GNU GPL" refers to version 3 of the GNU -General Public License. - - "The Library" refers to a covered work governed by this License, -other than an Application or a Combined Work as defined below. - - An "Application" is any work that makes use of an interface provided -by the Library, but which is not otherwise based on the Library. -Defining a subclass of a class defined by the Library is deemed a mode -of using an interface provided by the Library. - - A "Combined Work" is a work produced by combining or linking an -Application with the Library. The particular version of the Library -with which the Combined Work was made is also called the "Linked -Version". - - The "Minimal Corresponding Source" for a Combined Work means the -Corresponding Source for the Combined Work, excluding any source code -for portions of the Combined Work that, considered in isolation, are -based on the Application, and not on the Linked Version. - - The "Corresponding Application Code" for a Combined Work means the -object code and/or source code for the Application, including any data -and utility programs needed for reproducing the Combined Work from the -Application, but excluding the System Libraries of the Combined Work. - - 1. Exception to Section 3 of the GNU GPL. - - You may convey a covered work under sections 3 and 4 of this License -without being bound by section 3 of the GNU GPL. - - 2. Conveying Modified Versions. - - If you modify a copy of the Library, and, in your modifications, a -facility refers to a function or data to be supplied by an Application -that uses the facility (other than as an argument passed when the -facility is invoked), then you may convey a copy of the modified -version: - - a) under this License, provided that you make a good faith effort to - ensure that, in the event an Application does not supply the - function or data, the facility still operates, and performs - whatever part of its purpose remains meaningful, or - - b) under the GNU GPL, with none of the additional permissions of - this License applicable to that copy. - - 3. Object Code Incorporating Material from Library Header Files. - - The object code form of an Application may incorporate material from -a header file that is part of the Library. You may convey such object -code under terms of your choice, provided that, if the incorporated -material is not limited to numerical parameters, data structure -layouts and accessors, or small macros, inline functions and templates -(ten or fewer lines in length), you do both of the following: - - a) Give prominent notice with each copy of the object code that the - Library is used in it and that the Library and its use are - covered by this License. - - b) Accompany the object code with a copy of the GNU GPL and this license - document. - - 4. Combined Works. - - You may convey a Combined Work under terms of your choice that, -taken together, effectively do not restrict modification of the -portions of the Library contained in the Combined Work and reverse -engineering for debugging such modifications, if you also do each of -the following: - - a) Give prominent notice with each copy of the Combined Work that - the Library is used in it and that the Library and its use are - covered by this License. - - b) Accompany the Combined Work with a copy of the GNU GPL and this license - document. - - c) For a Combined Work that displays copyright notices during - execution, include the copyright notice for the Library among - these notices, as well as a reference directing the user to the - copies of the GNU GPL and this license document. - - d) Do one of the following: - - 0) Convey the Minimal Corresponding Source under the terms of this - License, and the Corresponding Application Code in a form - suitable for, and under terms that permit, the user to - recombine or relink the Application with a modified version of - the Linked Version to produce a modified Combined Work, in the - manner specified by section 6 of the GNU GPL for conveying - Corresponding Source. - - 1) Use a suitable shared library mechanism for linking with the - Library. A suitable mechanism is one that (a) uses at run time - a copy of the Library already present on the user's computer - system, and (b) will operate properly with a modified version - of the Library that is interface-compatible with the Linked - Version. - - e) Provide Installation Information, but only if you would otherwise - be required to provide such information under section 6 of the - GNU GPL, and only to the extent that such information is - necessary to install and execute a modified version of the - Combined Work produced by recombining or relinking the - Application with a modified version of the Linked Version. (If - you use option 4d0, the Installation Information must accompany - the Minimal Corresponding Source and Corresponding Application - Code. If you use option 4d1, you must provide the Installation - Information in the manner specified by section 6 of the GNU GPL - for conveying Corresponding Source.) - - 5. Combined Libraries. - - You may place library facilities that are a work based on the -Library side by side in a single library together with other library -facilities that are not Applications and are not covered by this -License, and convey such a combined library under terms of your -choice, if you do both of the following: - - a) Accompany the combined library with a copy of the same work based - on the Library, uncombined with any other library facilities, - conveyed under the terms of this License. - - b) Give prominent notice with the combined library that part of it - is a work based on the Library, and explaining where to find the - accompanying uncombined form of the same work. - - 6. Revised Versions of the GNU Lesser General Public License. - - The Free Software Foundation may publish revised and/or new versions -of the GNU Lesser General Public License from time to time. Such new -versions will be similar in spirit to the present version, but may -differ in detail to address new problems or concerns. - - Each version is given a distinguishing version number. If the -Library as you received it specifies that a certain numbered version -of the GNU Lesser General Public License "or any later version" -applies to it, you have the option of following the terms and -conditions either of that published version or of any later version -published by the Free Software Foundation. If the Library as you -received it does not specify a version number of the GNU Lesser -General Public License, you may choose any version of the GNU Lesser -General Public License ever published by the Free Software Foundation. - - If the Library as you received it specifies that a proxy can decide -whether future versions of the GNU Lesser General Public License shall -apply, that proxy's public statement of acceptance of any version is -permanent authorization for you to choose that version for the -Library.