diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 5767a6e..cd6b23f 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -24,6 +24,11 @@ jobs: - name: Download dependencies run: go mod download + - name: Run go lint + uses: golangci/golangci-lint-action@v6 + with: + version: latest + - name: Run tests run: go test -v -race -coverprofile=coverage.out ./... diff --git a/README.md b/README.md index f47f4be..dc3c804 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,30 @@ ctrlc config set api-key your-api-key ctrlc config set workspace your-workspace-id ``` +### Contexts (use-context) + +Define named contexts in your config file and switch between them (similar to +`kubectl config use-context`): + +```yaml +contexts: + wandb: + url: https://ctrlplane.wandb.io + api-key: your-api-key + workspace: wandb + local: + url: http://localhost:5173 + api-key: local-api-key + workspace: test +current-context: wandb +``` + +Switch contexts: + +```bash +ctrlc config use-context wandb +``` + ## Commands ### Agent diff --git a/cmd/ctrlc/root/applyv2/cmd.go b/cmd/ctrlc/root/applyv2/cmd.go index 973608d..e1d9248 100644 --- a/cmd/ctrlc/root/applyv2/cmd.go +++ b/cmd/ctrlc/root/applyv2/cmd.go @@ -19,6 +19,7 @@ import ( // NewApplyV2Cmd creates a new apply-v2 command func NewApplyV2Cmd() *cobra.Command { var filePatterns []string + var selectorRaw string cmd := &cobra.Command{ Use: "apply-v2", @@ -36,17 +37,18 @@ func NewApplyV2Cmd() *cobra.Command { `), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - return runApplyV2(cmd.Context(), filePatterns) + return runApply(cmd.Context(), filePatterns, selectorRaw) }, } cmd.Flags().StringArrayVarP(&filePatterns, "file", "f", nil, "Path or glob pattern to YAML files (can be specified multiple times, prefix with ! to exclude)") + cmd.Flags().StringVar(&selectorRaw, "selector", "", "Metadata selector in key=value format to apply to created resources") cmd.MarkFlagRequired("file") return cmd } -func runApplyV2(ctx context.Context, filePatterns []string) error { +func runApply(ctx context.Context, filePatterns []string, selectorRaw string) error { files, err := expandGlob(filePatterns) if err != nil { return err @@ -87,10 +89,20 @@ func runApplyV2(ctx context.Context, filePatterns []string) error { return nil } + if selectorRaw != "" { + selector, err := providers.ParseSelector(selectorRaw) + if err != nil { + return err + } + applySelectorToSpecs(selector, specs) + } + log.Info("Applying resources", "count", len(specs), "files", len(files)) sortedSpecs := sortSpecsByOrder(specs) - results := providers.DefaultProviderEngine.BatchApply(applyCtx, sortedSpecs, providers.BatchApplyOptions{}) + results := providers. + DefaultProviderEngine. + BatchApply(applyCtx, sortedSpecs, providers.BatchApplyOptions{}) printResults(results) diff --git a/cmd/ctrlc/root/applyv2/selector.go b/cmd/ctrlc/root/applyv2/selector.go new file mode 100644 index 0000000..8b5e5bc --- /dev/null +++ b/cmd/ctrlc/root/applyv2/selector.go @@ -0,0 +1,18 @@ +package applyv2 + +import "github.com/ctrlplanedev/cli/internal/api/providers" + +func applySelectorToSpecs(selector *providers.Selector, specs []providers.TypedSpec) { + if selector == nil { + return + } + + for _, spec := range specs { + switch typed := spec.Spec.(type) { + case *providers.DeploymentSpec: + typed.Metadata = selector.ApplyMetadata(typed.Metadata) + case *providers.PolicySpec: + typed.Metadata = selector.ApplyMetadata(typed.Metadata) + } + } +} diff --git a/cmd/ctrlc/root/config/config.go b/cmd/ctrlc/root/config/config.go index 1e752ed..a8769ff 100644 --- a/cmd/ctrlc/root/config/config.go +++ b/cmd/ctrlc/root/config/config.go @@ -16,6 +16,7 @@ func NewConfigCmd() *cobra.Command { } cmd.AddCommand(set.NewSetCmd()) + cmd.AddCommand(NewUseContextCmd()) return cmd } diff --git a/cmd/ctrlc/root/config/use_context.go b/cmd/ctrlc/root/config/use_context.go new file mode 100644 index 0000000..c5a0f79 --- /dev/null +++ b/cmd/ctrlc/root/config/use_context.go @@ -0,0 +1,146 @@ +package config + +import ( + "fmt" + "sort" + "strings" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/charmbracelet/log" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +const ( + contextsConfigKey = "contexts" + currentContextConfigKey = "current-context" +) + +var contextConfigKeys = []string{ + "url", + "api-key", + "workspace", +} + +func NewUseContextCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "use-context ", + Short: "Set the current configuration context", + Long: heredoc.Doc(` + Set the current configuration context, similar to kubectl config use-context. + Contexts are defined under the "contexts" key in your config file. + `), + Example: heredoc.Doc(` + # Switch to the "wandb" context + $ ctrlc config use-context wandb + `), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + contextName := strings.TrimSpace(args[0]) + if contextName == "" { + return fmt.Errorf("context name cannot be empty") + } + + contextValues, err := loadContextValues(contextName) + if err != nil { + return err + } + + missingKeys := requiredContextKeysMissing(contextValues, contextConfigKeys) + if len(missingKeys) > 0 { + return fmt.Errorf("context %q is missing required keys: %s", contextName, strings.Join(missingKeys, ", ")) + } + + for _, key := range contextConfigKeys { + viper.Set(key, contextValues[key]) + } + viper.Set(currentContextConfigKey, contextName) + + if err := viper.WriteConfig(); err != nil { + return fmt.Errorf("failed to write config: %w", err) + } + + log.Info("Switched context", "context", contextName) + return nil + }, + } + + return cmd +} + +func loadContextValues(contextName string) (map[string]string, error) { + contexts := viper.GetStringMap(contextsConfigKey) + if len(contexts) == 0 { + return nil, fmt.Errorf("no contexts are defined under %q in the config file", contextsConfigKey) + } + + contextRaw, ok := contexts[contextName] + if !ok { + available := contextNames(contexts) + if len(available) == 0 { + return nil, fmt.Errorf("context %q not found", contextName) + } + return nil, fmt.Errorf("context %q not found. Available contexts: %s", contextName, strings.Join(available, ", ")) + } + + contextMap, ok := normalizeStringMap(contextRaw) + if !ok { + return nil, fmt.Errorf("context %q has an invalid format", contextName) + } + + values := make(map[string]string, len(contextMap)) + for _, key := range contextConfigKeys { + rawValue, ok := contextMap[key] + if !ok { + continue + } + stringValue, ok := rawValue.(string) + if !ok { + return nil, fmt.Errorf("context %q key %q must be a string", contextName, key) + } + values[key] = strings.TrimSpace(stringValue) + } + + return values, nil +} + +func requiredContextKeysMissing(values map[string]string, keys []string) []string { + var missing []string + for _, key := range keys { + if strings.TrimSpace(values[key]) == "" { + missing = append(missing, key) + } + } + return missing +} + +func contextNames(contexts map[string]interface{}) []string { + names := make([]string, 0, len(contexts)) + for name := range contexts { + if strings.TrimSpace(name) == "" { + continue + } + names = append(names, name) + } + sort.Strings(names) + return names +} + +func normalizeStringMap(input interface{}) (map[string]interface{}, bool) { + switch typed := input.(type) { + case map[string]interface{}: + return typed, true + case map[interface{}]interface{}: + normalized := make(map[string]interface{}, len(typed)) + for key, value := range typed { + keyString, ok := key.(string) + if !ok { + return nil, false + } + normalized[keyString] = value + } + return normalized, true + default: + return nil, false + } +}