Skip to content
Merged
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
8 changes: 7 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,14 @@ jobs:
exit 1
fi

- name: Install kubectl
run: |
curl -LO "https://dl.k8s.io/release/$(curl -Ls https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
chmod +x kubectl
sudo mv kubectl /usr/local/bin/kubectl

- name: Run tests
run: go test -v -cover ./...
run: go test -v -cover -tags integration ./...

- name: Build
run: go build -v .
Expand Down
133 changes: 133 additions & 0 deletions cmd/integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
//go:build integration

package cmd

import (
"encoding/json"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func podListResponse(names ...string) map[string]interface{} {
items := make([]interface{}, 0, len(names))
for _, name := range names {
items = append(items, map[string]interface{}{
"kind": "Pod",
"apiVersion": "v1",
"metadata": map[string]interface{}{"name": name, "namespace": "default"},
"status": map[string]interface{}{"phase": "Running"},
})
}
return map[string]interface{}{
"kind": "PodList",
"apiVersion": "v1",
"metadata": map[string]interface{}{"resourceVersion": "1"},
"items": items,
}
}

// TestGetPodsJSON verifies that pod lists from multiple contexts are merged into
// a single JSON List and that each item carries a metadata.context annotation.
func TestGetPodsJSON(t *testing.T) {
h := NewHarness(t)
s1 := h.AddContext("ctx1")
s2 := h.AddContext("ctx2")

s1.HandleJSON("/api/v1/namespaces/default/pods", podListResponse("pod-a", "pod-b"))
s2.HandleJSON("/api/v1/namespaces/default/pods", podListResponse("pod-c"))

out, err := h.Run("get", "pods", "-o", "json")
require.NoError(t, err)

var result map[string]interface{}
require.NoError(t, json.Unmarshal([]byte(out), &result))

items, ok := result["items"].([]interface{})
require.True(t, ok)
assert.Len(t, items, 3)

contextsSeen := make(map[string]bool)
for _, item := range items {
m, ok := item.(map[string]interface{})
require.True(t, ok)
meta, ok := m["metadata"].(map[string]interface{})
require.True(t, ok)
ctx, _ := meta["context"].(string)
contextsSeen[ctx] = true
}
assert.True(t, contextsSeen["ctx1"], "expected items from ctx1")
assert.True(t, contextsSeen["ctx2"], "expected items from ctx2")
}

// TestVersion verifies that version output includes context names and the
// server version returned by the fake API.
func TestVersion(t *testing.T) {
h := NewHarness(t)
h.AddContext("ctx1")
h.AddContext("ctx2")

out, err := h.Run("version")
require.NoError(t, err)

assert.Contains(t, out, "CONTEXT")
assert.Contains(t, out, "SERVER VERSION")
assert.Contains(t, out, "ctx1")
assert.Contains(t, out, "ctx2")
assert.Contains(t, out, "v1.28.0")
}

// TestExcludeContext verifies that --exclude filters out matching contexts.
func TestExcludeContext(t *testing.T) {
h := NewHarness(t)
s1 := h.AddContext("prod-east")
s2 := h.AddContext("dev-west")

s1.HandleJSON("/api/v1/namespaces/default/pods", podListResponse("prod-pod"))
s2.HandleJSON("/api/v1/namespaces/default/pods", podListResponse("dev-pod"))

excludePatterns = []string{"dev"}
t.Cleanup(func() { excludePatterns = []string{} })

out, err := h.Run("get", "pods", "-o", "json")
require.NoError(t, err)

var result map[string]interface{}
require.NoError(t, json.Unmarshal([]byte(out), &result))

items, ok := result["items"].([]interface{})
require.True(t, ok)
require.Len(t, items, 1)

m := items[0].(map[string]interface{})
meta := m["metadata"].(map[string]interface{})
assert.Equal(t, "prod-east", meta["context"])
}

// TestIncludeContext verifies that --include limits results to matching contexts.
func TestIncludeContext(t *testing.T) {
h := NewHarness(t)
s1 := h.AddContext("prod-east")
s2 := h.AddContext("dev-west")

s1.HandleJSON("/api/v1/namespaces/default/pods", podListResponse("prod-pod"))
s2.HandleJSON("/api/v1/namespaces/default/pods", podListResponse("dev-pod"))

filterPatterns = []string{"prod"}
t.Cleanup(func() { filterPatterns = []string{} })

out, err := h.Run("get", "pods", "-o", "json")
require.NoError(t, err)

var result map[string]interface{}
require.NoError(t, json.Unmarshal([]byte(out), &result))

items, ok := result["items"].([]interface{})
require.True(t, ok)
require.Len(t, items, 1)

m := items[0].(map[string]interface{})
meta := m["metadata"].(map[string]interface{})
assert.Equal(t, "prod-east", meta["context"])
}
192 changes: 192 additions & 0 deletions cmd/testharness_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
package cmd

import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)

// FakeServer is a minimal fake Kubernetes API server for use in integration tests.
// It registers sensible defaults for API discovery endpoints and lets tests
// register additional handlers for specific paths via HandleJSON.
type FakeServer struct {
Server *httptest.Server
mux *http.ServeMux
}

func newFakeServer(t *testing.T) *FakeServer {
t.Helper()
mux := http.NewServeMux()
fs := &FakeServer{mux: mux}
fs.registerDefaults()
fs.Server = httptest.NewServer(mux)
t.Cleanup(fs.Server.Close)
return fs
}

// HandleJSON registers a handler for path that encodes v as a JSON response.
// Calling HandleJSON again for the same path replaces the previous handler.
func (fs *FakeServer) HandleJSON(path string, v interface{}) {
fs.mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(v); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
})
}

func (fs *FakeServer) registerDefaults() {
fs.HandleJSON("/api", map[string]interface{}{
"kind": "APIVersions",
"apiVersion": "v1",
"versions": []string{"v1"},
"serverAddressByClientCIDRs": []map[string]interface{}{
{"clientCIDR": "0.0.0.0/0", "serverAddress": ""},
},
})
fs.HandleJSON("/apis", map[string]interface{}{
"kind": "APIGroupList",
"apiVersion": "v1",
"groups": []interface{}{},
})
fs.HandleJSON("/api/v1", map[string]interface{}{
"kind": "APIResourceList",
"groupVersion": "v1",
"resources": []map[string]interface{}{
{
"name": "pods",
"namespaced": true,
"kind": "Pod",
"verbs": []string{"create", "delete", "get", "list", "patch", "update", "watch"},
},
{
"name": "nodes",
"namespaced": false,
"kind": "Node",
"verbs": []string{"create", "delete", "get", "list", "patch", "update", "watch"},
},
},
})
fs.HandleJSON("/version", map[string]interface{}{
"major": "1",
"minor": "28",
"gitVersion": "v1.28.0",
"gitCommit": "abc1234",
"gitTreeState": "clean",
"buildDate": "2023-08-15T10:20:12Z",
"goVersion": "go1.20.7",
"compiler": "gc",
"platform": "linux/amd64",
})
}

// Harness manages a set of fake servers and a temporary kubeconfig for integration testing.
type Harness struct {
t *testing.T
servers map[string]*FakeServer
contextOrder []string
kubeconfigPath string
tmpDir string
}

// NewHarness creates a new Harness backed by a temporary directory that is
// cleaned up when the test ends.
func NewHarness(t *testing.T) *Harness {
t.Helper()
tmpDir := t.TempDir()
return &Harness{
t: t,
servers: make(map[string]*FakeServer),
kubeconfigPath: filepath.Join(tmpDir, "kubeconfig"),
tmpDir: tmpDir,
}
}

// AddContext creates a fake server, registers it as a kubeconfig context with
// the given name, and returns the server so the test can register handlers.
func (h *Harness) AddContext(name string) *FakeServer {
h.t.Helper()
fs := newFakeServer(h.t)
h.servers[name] = fs
h.contextOrder = append(h.contextOrder, name)
h.writeKubeconfig()
return fs
}

func (h *Harness) writeKubeconfig() {
h.t.Helper()

var clusters, contexts, users []interface{}
for _, name := range h.contextOrder {
fs := h.servers[name]
clusters = append(clusters, map[string]interface{}{
"name": name,
"cluster": map[string]interface{}{
"server": fs.Server.URL,
},
})
contexts = append(contexts, map[string]interface{}{
"name": name,
"context": map[string]interface{}{
"cluster": name,
"user": name,
},
})
users = append(users, map[string]interface{}{
"name": name,
"user": map[string]interface{}{},
})
}

kubeconfig := map[string]interface{}{
"apiVersion": "v1",
"kind": "Config",
"clusters": clusters,
"contexts": contexts,
"users": users,
"current-context": h.contextOrder[0],
}

data, err := yaml.Marshal(kubeconfig)
require.NoError(h.t, err)
require.NoError(h.t, os.WriteFile(h.kubeconfigPath, data, 0600))
}

// Run executes a kubectl-x command and returns the captured stdout.
// KUBECONFIG is pointed at the harness kubeconfig and HOME is set to a temp
// dir so kubectl's discovery cache is isolated from the real home directory.
func (h *Harness) Run(subcommand string, args ...string) (string, error) {
h.t.Helper()

h.t.Setenv("KUBECONFIG", h.kubeconfigPath)
h.t.Setenv("HOME", h.tmpDir) // isolate kubectl's cache

oldStdout := os.Stdout
r, w, err := os.Pipe()
require.NoError(h.t, err)
os.Stdout = w

var buf bytes.Buffer
readerDone := make(chan struct{})
go func() {
io.Copy(&buf, r)
close(readerDone)
}()

runErr := runCommand(subcommand, args)

w.Close()
<-readerDone
os.Stdout = oldStdout
r.Close()

return buf.String(), runErr
}
Loading