diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 72173a8..ad56384 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -6,15 +6,18 @@ on: pull_request: branches: [ "main" ] +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version-file: go.mod cache: true @@ -24,3 +27,17 @@ jobs: - name: Test run: go test -v ./... + + - name: Install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@latest + + - name: Vulnerability scan + continue-on-error: true + run: "$(go env GOPATH)/bin/govulncheck" ./... + + - name: Install gosec + run: go install github.com/securego/gosec/v2/cmd/gosec@latest + + - name: Security static analysis (non-blocking) + continue-on-error: true + run: "$(go env GOPATH)/bin/gosec" ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3e8c26f..4d57b63 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,10 +29,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version-file: go.mod cache: true @@ -68,7 +68,7 @@ jobs: rm -rf "${package_dir}" - name: Upload archive - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: release-${{ matrix.goos }}-${{ matrix.goarch }} path: dist/kuse_${{ github.ref_name }}_${{ matrix.goos }}_${{ matrix.goarch }}.* @@ -80,10 +80,12 @@ jobs: needs: build permissions: contents: write + attestations: write + id-token: write steps: - name: Download build artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: pattern: release-* path: dist @@ -95,8 +97,13 @@ jobs: cd dist sha256sum kuse_* > "kuse_${GITHUB_REF_NAME}_checksums.txt" + - name: Generate artifact attestation + uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0 + with: + subject-path: "dist/kuse_*" + - name: Publish GitHub release assets - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 with: files: dist/kuse_* generate_release_notes: true diff --git a/README.md b/README.md index b827157..7e99d1a 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,10 @@ Options: - just run `kuse`, it will create a configuration file - in the configuration file `XDG_CONFIG_HOME/kuse/kuseconfig.yaml` you can set the location of your kubeconfig (defaults to `~/.kube/config`) and your source kubeconfig directory (defaults to `~/kubeconfigs`) - you can also use `--kubeconfig` or `--sources` at any time to set those values + - passing only one of those flags updates just that key and preserves the other existing value in config - run `kuse` to show the current kubeconfig in use - run `kuse ` to pick a different one + - `kuse --short` prints only the current target token to stdout (for prompts/scripts); any warnings/errors go to stderr ### Example @@ -62,3 +64,26 @@ production% ### But can't I use kubectl's built in context management? Sure, go for it. + +### Safety notes + +- `kuse` expects the configured kubeconfig path to be a symlink. +- If a regular file exists at that location, `kuse` will prompt before removing it and replacing it with a symlink. +- Keep backups of important kubeconfig files if you are unsure about your local setup. + +### Development + +```shell +make +go test ./... +go vet ./... +go install golang.org/x/vuln/cmd/govulncheck@latest +$(go env GOPATH)/bin/govulncheck ./... +``` + +Optional static security scan: + +```shell +go install github.com/securego/gosec/v2/cmd/gosec@latest +$(go env GOPATH)/bin/gosec ./... +``` diff --git a/cmd/kuse/main.go b/cmd/kuse/main.go index 6faa581..bb0a706 100644 --- a/cmd/kuse/main.go +++ b/cmd/kuse/main.go @@ -1,20 +1,13 @@ package main import ( + "errors" "fmt" "github.com/alexflint/go-arg" "github.com/sofuture/kuse/pkg/common" "os" ) -func getArgument() string { - args := os.Args[1:] - if len(args) == 0 { - return "" - } - return args[0] -} - var args struct { Name string `arg:"positional"` Kubeconfig string @@ -27,14 +20,19 @@ func main() { c, err := common.InitConfig(args.Kubeconfig, args.Sources) if err != nil { - fmt.Println("error:", err) + fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) } s, err := common.LoadState(c) if err != nil { - fmt.Println("error:", err) - os.Exit(1) + var stateWarning *common.StateWarning + if errors.As(err, &stateWarning) { + fmt.Fprintln(os.Stderr, "warning:", stateWarning) + } else { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } } if args.Short { @@ -45,13 +43,13 @@ func main() { if args.Name == "" { err := s.PrintStatusCommand() if err != nil { - fmt.Println("error:", err) + fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) } } else { err := s.SetTarget(args.Name) if err != nil { - fmt.Println("error:", err) + fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) } } diff --git a/cmd/kuse/main_test.go b/cmd/kuse/main_test.go new file mode 100644 index 0000000..4ed9252 --- /dev/null +++ b/cmd/kuse/main_test.go @@ -0,0 +1,139 @@ +package main + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func buildKuseBinary(t *testing.T) string { + t.Helper() + + _, filename, _, ok := runtime.Caller(0) + if !ok { + t.Fatalf("unable to determine test file location") + } + + packageDir := filepath.Dir(filename) + binPath := filepath.Join(t.TempDir(), "kuse") + + build := exec.Command("go", "build", "-o", binPath, ".") + build.Dir = packageDir + build.Env = os.Environ() + + output, err := build.CombinedOutput() + if err != nil { + t.Fatalf("failed to build kuse binary: %v\n%s", err, string(output)) + } + + return binPath +} + +func testEnvForHome(homeDir string) []string { + return append(os.Environ(), + "HOME="+homeDir, + "XDG_CONFIG_HOME="+filepath.Join(homeDir, ".config"), + ) +} + +func writeConfig(t *testing.T, homeDir string, kubeconfig string, sources string) { + t.Helper() + + configDir := filepath.Join(homeDir, ".config", "kuse") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatalf("failed to create config directory: %v", err) + } + configPath := filepath.Join(configDir, "kuseconfig.yaml") + + content := strings.Join([]string{ + "kubeconfig: " + kubeconfig, + "sources: " + sources, + "", + }, "\n") + if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil { + t.Fatalf("failed to write config file: %v", err) + } +} + +func TestShortModeOutputsOnlyCurrentTokenOnStdout(t *testing.T) { + binary := buildKuseBinary(t) + homeDir := t.TempDir() + + sourcesDir := filepath.Join(homeDir, "kubeconfigs") + if err := os.MkdirAll(sourcesDir, 0o755); err != nil { + t.Fatalf("failed to create source directory: %v", err) + } + if err := os.WriteFile(filepath.Join(sourcesDir, "dev.yaml"), []byte("apiVersion: v1\n"), 0o600); err != nil { + t.Fatalf("failed to create source file: %v", err) + } + + kubeconfigPath := filepath.Join(homeDir, ".kube", "config") + writeConfig(t, homeDir, kubeconfigPath, sourcesDir) + + cmd := exec.Command(binary, "--short") + cmd.Env = testEnvForHome(homeDir) + + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + t.Fatalf("short mode command failed: %v\nstderr: %s", err, stderr.String()) + } + + if stdout.String() != "~none~" { + t.Fatalf("expected stdout token ~none~, got %q", stdout.String()) + } + if !strings.Contains(stderr.String(), "warning: kubeconfig does not exist") { + t.Fatalf("expected warning on stderr, got %q", stderr.String()) + } +} + +func TestErrorsArePrintedToStderr(t *testing.T) { + binary := buildKuseBinary(t) + homeDir := t.TempDir() + + sourcesDir := filepath.Join(homeDir, "kubeconfigs") + if err := os.MkdirAll(sourcesDir, 0o755); err != nil { + t.Fatalf("failed to create source directory: %v", err) + } + target := filepath.Join(sourcesDir, "dev.yaml") + if err := os.WriteFile(target, []byte("apiVersion: v1\n"), 0o600); err != nil { + t.Fatalf("failed to create source file: %v", err) + } + + kubeconfigPath := filepath.Join(homeDir, ".kube", "config") + if err := os.MkdirAll(filepath.Dir(kubeconfigPath), 0o755); err != nil { + t.Fatalf("failed to create kube directory: %v", err) + } + if err := os.Symlink(target, kubeconfigPath); err != nil { + t.Fatalf("failed to create kubeconfig symlink: %v", err) + } + + writeConfig(t, homeDir, kubeconfigPath, sourcesDir) + + cmd := exec.Command(binary, "prod") + cmd.Env = testEnvForHome(homeDir) + + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + if err == nil { + t.Fatalf("expected command to fail for invalid target") + } + + if stdout.String() != "" { + t.Fatalf("expected no stdout on error, got %q", stdout.String()) + } + if !strings.Contains(stderr.String(), "error: invalid target: prod") { + t.Fatalf("expected invalid target error on stderr, got %q", stderr.String()) + } +} diff --git a/pkg/common/config.go b/pkg/common/config.go index d7f8a8b..b496a15 100644 --- a/pkg/common/config.go +++ b/pkg/common/config.go @@ -6,7 +6,7 @@ import ( "github.com/adrg/xdg" "github.com/mitchellh/go-homedir" "github.com/spf13/viper" - "path" + "path/filepath" ) const ( @@ -33,47 +33,43 @@ func InitConfig(kubeconfig string, sources string) (*Config, error) { return nil, err } - viper.SetDefault(keyKubeconfig, defaultKubeconfig) - viper.SetDefault(keySources, defaultSources) + config := viper.New() + config.SetDefault(keyKubeconfig, defaultKubeconfig) + config.SetDefault(keySources, defaultSources) + config.SetConfigName(configFileName) + config.SetConfigType(configFileExtension) + config.AddConfigPath(filepath.Dir(cfgLocation)) - viper.SetConfigName(configFileName) - viper.SetConfigType(configFileExtension) - viper.AddConfigPath(path.Dir(cfgLocation)) + configBootstrapNeeded := false + if err := config.ReadInConfig(); err != nil { + var configFileNotFoundError viper.ConfigFileNotFoundError + if errors.As(err, &configFileNotFoundError) { + configBootstrapNeeded = true + } else { + return nil, err + } + } if kubeconfig != "" { - viper.Set(keyKubeconfig, kubeconfig) + config.Set(keyKubeconfig, kubeconfig) } if sources != "" { - viper.Set(keySources, sources) + config.Set(keySources, sources) } - if kubeconfig != "" || sources != "" { - err := viper.WriteConfigAs(cfgLocation) - if err != nil { + if configBootstrapNeeded || kubeconfig != "" || sources != "" { + if err := config.WriteConfigAs(cfgLocation); err != nil { return nil, err } } - err = viper.ReadInConfig() - if err != nil { - var configFileNotFoundError viper.ConfigFileNotFoundError - if errors.As(err, &configFileNotFoundError) { - fmt.Println("No kuse configuration found, no sweat, I'll create one with defaults at", cfgLocation) - err := viper.WriteConfigAs(cfgLocation) - if err != nil { - fmt.Println(err) - return nil, err - } - } - } - - expandedKubeconfig, err := homedir.Expand(viper.GetString(keyKubeconfig)) + expandedKubeconfig, err := homedir.Expand(config.GetString(keyKubeconfig)) if err != nil { return nil, err } - expandedSources, err := homedir.Expand(viper.GetString(keySources)) + expandedSources, err := homedir.Expand(config.GetString(keySources)) if err != nil { return nil, err } diff --git a/pkg/common/config_test.go b/pkg/common/config_test.go new file mode 100644 index 0000000..ce58a93 --- /dev/null +++ b/pkg/common/config_test.go @@ -0,0 +1,184 @@ +package common + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/adrg/xdg" + "github.com/spf13/viper" +) + +func configureTestEnvironment(t *testing.T) string { + t.Helper() + + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(homeDir, ".config")) + xdg.Reload() + + return homeDir +} + +func readConfigFile(t *testing.T, configPath string) *viper.Viper { + t.Helper() + + config := viper.New() + config.SetConfigFile(configPath) + config.SetConfigType("yaml") + if err := config.ReadInConfig(); err != nil { + t.Fatalf("failed to read config file: %v", err) + } + + return config +} + +func TestInitConfigBootstrapsDefaults(t *testing.T) { + homeDir := configureTestEnvironment(t) + + config, err := InitConfig("", "") + if err != nil { + t.Fatalf("InitConfig returned error: %v", err) + } + + expectedKubeconfig := filepath.Join(homeDir, ".kube", "config") + if config.Kubeconfig != expectedKubeconfig { + t.Fatalf("expected kubeconfig %q, got %q", expectedKubeconfig, config.Kubeconfig) + } + + expectedSources := filepath.Join(homeDir, "kubeconfigs") + if config.Sources != expectedSources { + t.Fatalf("expected sources %q, got %q", expectedSources, config.Sources) + } + + configPath := filepath.Join(homeDir, ".config", "kuse", "kuseconfig.yaml") + fileConfig := readConfigFile(t, configPath) + + if fileConfig.GetString(keyKubeconfig) != defaultKubeconfig { + t.Fatalf("expected persisted kubeconfig %q, got %q", defaultKubeconfig, fileConfig.GetString(keyKubeconfig)) + } + if fileConfig.GetString(keySources) != defaultSources { + t.Fatalf("expected persisted sources %q, got %q", defaultSources, fileConfig.GetString(keySources)) + } +} + +func TestInitConfigPreservesExistingSourcesWhenOverridingKubeconfig(t *testing.T) { + homeDir := configureTestEnvironment(t) + configDir := filepath.Join(homeDir, ".config", "kuse") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatalf("failed to create config directory: %v", err) + } + + existingSources := filepath.Join(homeDir, "sources-a") + if err := os.MkdirAll(existingSources, 0o755); err != nil { + t.Fatalf("failed to create source directory: %v", err) + } + + configPath := filepath.Join(configDir, "kuseconfig.yaml") + if err := os.WriteFile(configPath, []byte( + "kubeconfig: "+filepath.Join(homeDir, ".kube", "old-config")+"\n"+ + "sources: "+existingSources+"\n", + ), 0o600); err != nil { + t.Fatalf("failed to write initial config: %v", err) + } + + newKubeconfig := filepath.Join(homeDir, ".kube", "new-config") + config, err := InitConfig(newKubeconfig, "") + if err != nil { + t.Fatalf("InitConfig returned error: %v", err) + } + + if config.Kubeconfig != newKubeconfig { + t.Fatalf("expected kubeconfig %q, got %q", newKubeconfig, config.Kubeconfig) + } + if config.Sources != existingSources { + t.Fatalf("expected sources %q, got %q", existingSources, config.Sources) + } + + fileConfig := readConfigFile(t, configPath) + if fileConfig.GetString(keyKubeconfig) != newKubeconfig { + t.Fatalf("expected persisted kubeconfig %q, got %q", newKubeconfig, fileConfig.GetString(keyKubeconfig)) + } + if fileConfig.GetString(keySources) != existingSources { + t.Fatalf("expected persisted sources %q, got %q", existingSources, fileConfig.GetString(keySources)) + } +} + +func TestInitConfigPreservesExistingKubeconfigWhenOverridingSources(t *testing.T) { + homeDir := configureTestEnvironment(t) + configDir := filepath.Join(homeDir, ".config", "kuse") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatalf("failed to create config directory: %v", err) + } + + existingKubeconfig := filepath.Join(homeDir, ".kube", "current-config") + configPath := filepath.Join(configDir, "kuseconfig.yaml") + if err := os.WriteFile(configPath, []byte( + "kubeconfig: "+existingKubeconfig+"\n"+ + "sources: "+filepath.Join(homeDir, "old-sources")+"\n", + ), 0o600); err != nil { + t.Fatalf("failed to write initial config: %v", err) + } + + newSources := filepath.Join(homeDir, "new-sources") + if err := os.MkdirAll(newSources, 0o755); err != nil { + t.Fatalf("failed to create source directory: %v", err) + } + + config, err := InitConfig("", newSources) + if err != nil { + t.Fatalf("InitConfig returned error: %v", err) + } + + if config.Kubeconfig != existingKubeconfig { + t.Fatalf("expected kubeconfig %q, got %q", existingKubeconfig, config.Kubeconfig) + } + if config.Sources != newSources { + t.Fatalf("expected sources %q, got %q", newSources, config.Sources) + } + + fileConfig := readConfigFile(t, configPath) + if fileConfig.GetString(keyKubeconfig) != existingKubeconfig { + t.Fatalf("expected persisted kubeconfig %q, got %q", existingKubeconfig, fileConfig.GetString(keyKubeconfig)) + } + if fileConfig.GetString(keySources) != newSources { + t.Fatalf("expected persisted sources %q, got %q", newSources, fileConfig.GetString(keySources)) + } +} + +func TestInitConfigExpandsTildePaths(t *testing.T) { + homeDir := configureTestEnvironment(t) + configDir := filepath.Join(homeDir, ".config", "kuse") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatalf("failed to create config directory: %v", err) + } + + configPath := filepath.Join(configDir, "kuseconfig.yaml") + contents := strings.Join([]string{ + "kubeconfig: ~/.kube/custom-config", + "sources: ~/custom-sources", + "", + }, "\n") + if err := os.WriteFile(configPath, []byte(contents), 0o600); err != nil { + t.Fatalf("failed to write initial config: %v", err) + } + + config, err := InitConfig("", "") + if err != nil { + t.Fatalf("InitConfig returned error: %v", err) + } + + if strings.HasPrefix(config.Kubeconfig, "~") { + t.Fatalf("expected kubeconfig to be expanded, got %q", config.Kubeconfig) + } + if strings.HasPrefix(config.Sources, "~") { + t.Fatalf("expected sources to be expanded, got %q", config.Sources) + } + if !strings.HasSuffix(config.Kubeconfig, filepath.Join(".kube", "custom-config")) { + t.Fatalf("expected kubeconfig to end with .kube/custom-config, got %q", config.Kubeconfig) + } + if !strings.HasSuffix(config.Sources, filepath.Join("custom-sources")) { + t.Fatalf("expected sources to end with custom-sources, got %q", config.Sources) + } +} diff --git a/pkg/common/state.go b/pkg/common/state.go index 00d09be..1aaa9ec 100644 --- a/pkg/common/state.go +++ b/pkg/common/state.go @@ -5,27 +5,32 @@ import ( "errors" "fmt" "os" - "path" + "path/filepath" "strings" ) func LoadState(c *Config) (*State, error) { s := &State{config: c} - err := s.loadTargets() - if err != nil { + if err := s.loadTargets(); err != nil { return s, err } - err = s.loadCurrent() - if err != nil { - fmt.Println(err) + if err := s.loadCurrent(); err != nil { s.current.Name = "~none~" - return s, nil + return s, &StateWarning{Err: err} } return s, nil } +type StateWarning struct { + Err error +} + +func (w *StateWarning) Error() string { + return w.Err.Error() +} + type State struct { targets []Link current Link @@ -40,10 +45,20 @@ func (s *State) loadTargets() error { s.targets = make([]Link, 0) for _, file := range files { - if isYaml(file.Name()) { - filepath := path.Join(s.config.Sources, file.Name()) - s.targets = append(s.targets, fileToLink(filepath)) + if !isYaml(file.Name()) { + continue } + + targetPath := filepath.Join(s.config.Sources, file.Name()) + info, err := os.Stat(targetPath) + if err != nil { + return err + } + if info.IsDir() { + continue + } + + s.targets = append(s.targets, fileToLink(targetPath)) } return nil @@ -116,7 +131,7 @@ func (s *State) SetTarget(target string) error { } if !valid { - return errors.New(fmt.Sprintf("invalid target: %s", target)) + return fmt.Errorf("invalid target: %s", target) } return s.switchLink(filename) diff --git a/pkg/common/state_test.go b/pkg/common/state_test.go new file mode 100644 index 0000000..ffad62f --- /dev/null +++ b/pkg/common/state_test.go @@ -0,0 +1,172 @@ +package common + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func TestLoadTargetsExcludesYamlNamedDirectories(t *testing.T) { + sourcesDir := t.TempDir() + if err := os.WriteFile(filepath.Join(sourcesDir, "dev.yaml"), []byte("apiVersion: v1\n"), 0o600); err != nil { + t.Fatalf("failed to create yaml file: %v", err) + } + if err := os.WriteFile(filepath.Join(sourcesDir, "notes.txt"), []byte("ignore"), 0o600); err != nil { + t.Fatalf("failed to create non-yaml file: %v", err) + } + if err := os.Mkdir(filepath.Join(sourcesDir, "bad.yaml"), 0o755); err != nil { + t.Fatalf("failed to create yaml-named directory: %v", err) + } + + state := &State{config: &Config{Sources: sourcesDir}} + if err := state.loadTargets(); err != nil { + t.Fatalf("loadTargets returned error: %v", err) + } + + if len(state.targets) != 1 { + t.Fatalf("expected 1 target, got %d (%v)", len(state.targets), state.targets) + } + if state.targets[0].Name != "dev" { + t.Fatalf("expected dev target, got %q", state.targets[0].Name) + } +} + +func TestLoadCurrentMissingKubeconfig(t *testing.T) { + missingPath := filepath.Join(t.TempDir(), ".kube", "config") + state := &State{config: &Config{Kubeconfig: missingPath}} + + err := state.loadCurrent() + if err == nil { + t.Fatalf("expected error when kubeconfig is missing") + } + if err.Error() != "kubeconfig does not exist" { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestLoadCurrentNonSymlink(t *testing.T) { + tempDir := t.TempDir() + kubeconfigPath := filepath.Join(tempDir, ".kube", "config") + if err := os.MkdirAll(filepath.Dir(kubeconfigPath), 0o755); err != nil { + t.Fatalf("failed to create kube directory: %v", err) + } + if err := os.WriteFile(kubeconfigPath, []byte("plain"), 0o600); err != nil { + t.Fatalf("failed to create kubeconfig file: %v", err) + } + + state := &State{config: &Config{Kubeconfig: kubeconfigPath}} + err := state.loadCurrent() + if err == nil { + t.Fatalf("expected error when kubeconfig is not a symlink") + } + if err.Error() != "kubeconfig is not a symlink" { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestLoadCurrentSymlink(t *testing.T) { + tempDir := t.TempDir() + targetPath := filepath.Join(tempDir, "kubeconfigs", "dev.yaml") + if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { + t.Fatalf("failed to create target directory: %v", err) + } + if err := os.WriteFile(targetPath, []byte("apiVersion: v1\n"), 0o600); err != nil { + t.Fatalf("failed to create target file: %v", err) + } + + kubeconfigPath := filepath.Join(tempDir, ".kube", "config") + if err := os.MkdirAll(filepath.Dir(kubeconfigPath), 0o755); err != nil { + t.Fatalf("failed to create kube directory: %v", err) + } + if err := os.Symlink(targetPath, kubeconfigPath); err != nil { + t.Fatalf("failed to create kubeconfig symlink: %v", err) + } + + state := &State{config: &Config{Kubeconfig: kubeconfigPath}} + if err := state.loadCurrent(); err != nil { + t.Fatalf("loadCurrent returned error: %v", err) + } + + if state.current.Name != "dev" { + t.Fatalf("expected current target name dev, got %q", state.current.Name) + } +} + +func TestSetTargetRejectsInvalidTarget(t *testing.T) { + state := &State{ + config: &Config{Kubeconfig: filepath.Join(t.TempDir(), ".kube", "config")}, + targets: []Link{{Name: "dev", File: "/tmp/dev.yaml"}}, + } + + err := state.SetTarget("prod") + if err == nil { + t.Fatalf("expected error for invalid target") + } + if err.Error() != "invalid target: prod" { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSetTargetCreatesSymlinkForValidTarget(t *testing.T) { + tempDir := t.TempDir() + kubeconfigPath := filepath.Join(tempDir, ".kube", "config") + if err := os.MkdirAll(filepath.Dir(kubeconfigPath), 0o755); err != nil { + t.Fatalf("failed to create kube directory: %v", err) + } + + targetPath := filepath.Join(tempDir, "sources", "dev.yaml") + if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { + t.Fatalf("failed to create source directory: %v", err) + } + if err := os.WriteFile(targetPath, []byte("apiVersion: v1\n"), 0o600); err != nil { + t.Fatalf("failed to create source file: %v", err) + } + + state := &State{ + config: &Config{Kubeconfig: kubeconfigPath}, + targets: []Link{{Name: "dev", File: targetPath}}, + } + + if err := state.SetTarget("dev"); err != nil { + t.Fatalf("SetTarget returned error: %v", err) + } + + resolvedTarget, err := os.Readlink(kubeconfigPath) + if err != nil { + t.Fatalf("failed reading resulting symlink: %v", err) + } + if resolvedTarget != targetPath { + t.Fatalf("expected symlink target %q, got %q", targetPath, resolvedTarget) + } +} + +func TestLoadStateReturnsWarningWhenCurrentMissing(t *testing.T) { + tempDir := t.TempDir() + sourcesDir := filepath.Join(tempDir, "kubeconfigs") + if err := os.MkdirAll(sourcesDir, 0o755); err != nil { + t.Fatalf("failed to create sources directory: %v", err) + } + if err := os.WriteFile(filepath.Join(sourcesDir, "dev.yaml"), []byte("apiVersion: v1\n"), 0o600); err != nil { + t.Fatalf("failed to create source file: %v", err) + } + + state, err := LoadState(&Config{ + Kubeconfig: filepath.Join(tempDir, ".kube", "config"), + Sources: sourcesDir, + }) + if err == nil { + t.Fatalf("expected warning error from LoadState") + } + + var warning *StateWarning + if !errors.As(err, &warning) { + t.Fatalf("expected StateWarning, got %T", err) + } + if state.current.Name != "~none~" { + t.Fatalf("expected fallback current target name, got %q", state.current.Name) + } + if len(state.targets) != 1 || state.targets[0].Name != "dev" { + t.Fatalf("expected targets to load despite warning, got %v", state.targets) + } +} diff --git a/pkg/common/util.go b/pkg/common/util.go index 2eb34f5..0f3b597 100644 --- a/pkg/common/util.go +++ b/pkg/common/util.go @@ -3,7 +3,7 @@ package common import ( "errors" "os" - "path" + "path/filepath" "strings" ) @@ -41,9 +41,17 @@ func exists(filename string) bool { } func fileToLink(filename string) Link { + normalizedPath := filename + if strings.Contains(normalizedPath, "\\") { + normalizedPath = strings.ReplaceAll(normalizedPath, "\\", string(filepath.Separator)) + } + + baseName := filepath.Base(normalizedPath) + extension := filepath.Ext(baseName) + return Link{ - Name: trimYamlSuffix(path.Base(filename)), + Name: trimYamlSuffix(baseName), File: filename, - Extension: path.Ext(filename), + Extension: extension, } } diff --git a/pkg/common/util_test.go b/pkg/common/util_test.go new file mode 100644 index 0000000..283da9c --- /dev/null +++ b/pkg/common/util_test.go @@ -0,0 +1,41 @@ +package common + +import "testing" + +func TestIsYaml(t *testing.T) { + if !isYaml("dev.yaml") { + t.Fatalf("expected .yaml file to be recognized") + } + if !isYaml("dev.yml") { + t.Fatalf("expected .yml file to be recognized") + } + if isYaml("dev.txt") { + t.Fatalf("expected non-yaml file to be rejected") + } +} + +func TestFileToLinkWithUnixPath(t *testing.T) { + link := fileToLink("/tmp/dev.yaml") + if link.Name != "dev" { + t.Fatalf("expected name dev, got %q", link.Name) + } + if link.Extension != ".yaml" { + t.Fatalf("expected extension .yaml, got %q", link.Extension) + } + if link.File != "/tmp/dev.yaml" { + t.Fatalf("expected file path preserved, got %q", link.File) + } +} + +func TestFileToLinkWithWindowsStylePath(t *testing.T) { + link := fileToLink(`C:\Users\alice\kubeconfigs\production.yaml`) + if link.Name != "production" { + t.Fatalf("expected name production, got %q", link.Name) + } + if link.Extension != ".yaml" { + t.Fatalf("expected extension .yaml, got %q", link.Extension) + } + if link.File != `C:\Users\alice\kubeconfigs\production.yaml` { + t.Fatalf("expected file path preserved, got %q", link.File) + } +}