Skip to content
Draft
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
21 changes: 19 additions & 2 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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" ./...
17 changes: 12 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }}.*
Expand All @@ -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
Expand All @@ -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
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` 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

Expand Down Expand Up @@ -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 ./...
```
24 changes: 11 additions & 13 deletions cmd/kuse/main.go
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 {
Expand All @@ -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)
}
}
Expand Down
139 changes: 139 additions & 0 deletions cmd/kuse/main_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
Loading