Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ./...

Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 15 additions & 3 deletions cmd/ctrlc/root/applyv2/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
Expand Down Expand Up @@ -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)

Expand Down
18 changes: 18 additions & 0 deletions cmd/ctrlc/root/applyv2/selector.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
1 change: 1 addition & 0 deletions cmd/ctrlc/root/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ func NewConfigCmd() *cobra.Command {
}

cmd.AddCommand(set.NewSetCmd())
cmd.AddCommand(NewUseContextCmd())

return cmd
}
146 changes: 146 additions & 0 deletions cmd/ctrlc/root/config/use_context.go
Original file line number Diff line number Diff line change
@@ -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 <name>",
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
}
}