diff --git a/test/colors.go b/test/colors.go new file mode 100644 index 0000000..625e440 --- /dev/null +++ b/test/colors.go @@ -0,0 +1,11 @@ +package test + +// ANSI color codes for terminal output +const ( + colorReset = "\033[0m" + colorGray = "\033[90m" + colorRed = "\033[91m" + colorYellow = "\033[93m" + colorBlue = "\033[94m" + colorBold = "\033[1m" +) \ No newline at end of file diff --git a/test/command.go b/test/command.go new file mode 100644 index 0000000..8b81e91 --- /dev/null +++ b/test/command.go @@ -0,0 +1,150 @@ +package test + +import ( + "bufio" + "bytes" + "fmt" + "io" + "os/exec" + "strings" + "sync" +) + +// CommandResult holds the result of a command execution +type CommandResult struct { + Stdout string + Stderr string + ExitCode int + Err error +} + +// String returns a formatted string of the command result +func (r CommandResult) String() string { + return fmt.Sprintf("ExitCode: %d\nStdout:\n%s\nStderr:\n%s\nError: %v", + r.ExitCode, r.Stdout, r.Stderr, r.Err) +} + +// CommandRunner provides command execution with optional colored output +type CommandRunner struct { + ColorOutput bool +} + +// NewCommandRunner creates a new CommandRunner +func NewCommandRunner(colorOutput bool) *CommandRunner { + return &CommandRunner{ColorOutput: colorOutput} +} + +// RunCommand executes a command and returns the result +func (c *CommandRunner) RunCommand(name string, args ...string) CommandResult { + if c.ColorOutput { + fmt.Printf("%s%s>>> Executing: %s %s%s\n", colorBlue, colorBold, name, strings.Join(args, " "), colorReset) + } + + cmd := exec.Command(name, args...) + + // Create pipes for stdout and stderr + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + return CommandResult{ + Err: fmt.Errorf("failed to create stdout pipe: %w", err), + ExitCode: -1, + } + } + + stderrPipe, err := cmd.StderrPipe() + if err != nil { + return CommandResult{ + Err: fmt.Errorf("failed to create stderr pipe: %w", err), + ExitCode: -1, + } + } + + // Buffers to capture output + var stdout, stderr bytes.Buffer + + // Start the command + if err := cmd.Start(); err != nil { + return CommandResult{ + Err: fmt.Errorf("failed to start command: %w", err), + ExitCode: -1, + } + } + + // Stream output in real-time with colors + var wg sync.WaitGroup + wg.Add(2) + + go c.streamOutput(stdoutPipe, "stdout", colorGray, &stdout, &wg) + go c.streamOutput(stderrPipe, "stderr", colorRed, &stderr, &wg) + + // Wait for output streaming to complete + wg.Wait() + + // Wait for command to complete + err = cmd.Wait() + exitCode := 0 + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } + + result := CommandResult{ + Stdout: stdout.String(), + Stderr: stderr.String(), + ExitCode: exitCode, + Err: err, + } + + // Print exit status + if c.ColorOutput { + if result.Err != nil { + fmt.Printf("%s%s<<< Command failed with exit code %d%s\n", colorRed, colorBold, result.ExitCode, colorReset) + } else { + fmt.Printf("%s<<< Command completed successfully%s\n", colorGray, colorReset) + } + fmt.Println() // Add blank line for readability + } + + return result +} + +// RunCommandQuiet executes a command without output streaming +func (c *CommandRunner) RunCommandQuiet(name string, args ...string) CommandResult { + cmd := exec.Command(name, args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + exitCode := 0 + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } + + return CommandResult{ + Stdout: stdout.String(), + Stderr: stderr.String(), + ExitCode: exitCode, + Err: err, + } +} + +func (c *CommandRunner) streamOutput(reader io.Reader, prefix string, color string, buffer *bytes.Buffer, wg *sync.WaitGroup) { + defer wg.Done() + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + line := scanner.Text() + buffer.WriteString(line + "\n") + if c.ColorOutput { + fmt.Printf("%s%s%s: %s%s\n", color, prefix, colorReset, color, line+colorReset) + } + } +} + +// Printf prints a formatted colored message +func (c *CommandRunner) Printf(color, style, format string, args ...interface{}) { + if c.ColorOutput { + fmt.Printf("%s%s%s%s\n", color, style, fmt.Sprintf(format, args...), colorReset) + } else { + fmt.Printf(format+"\n", args...) + } +} \ No newline at end of file diff --git a/test/helm.go b/test/helm.go new file mode 100644 index 0000000..5532278 --- /dev/null +++ b/test/helm.go @@ -0,0 +1,714 @@ +package test + +import ( + "bytes" + "encoding/json" + "fmt" + "os/exec" + "strings" + "time" + + "sigs.k8s.io/yaml" +) + +// HelmChart represents a Helm chart with fluent interface +type HelmChart struct { + releaseName string + namespace string + chartPath string + values map[string]interface{} + wait bool + timeout time.Duration + passwordSecret string + colorOutput bool + dryRun bool + + // Command execution state + runner *CommandRunner + lastResult CommandResult + lastError error +} + +// NewHelmChart creates a new HelmChart builder +func NewHelmChart(chartPath string) *HelmChart { + return &HelmChart{ + chartPath: chartPath, + colorOutput: true, + timeout: 5 * time.Minute, + values: make(map[string]interface{}), + runner: NewCommandRunner(true), + } +} + +// Release sets the release name +func (h *HelmChart) Release(name string) *HelmChart { + h.releaseName = name + return h +} + +// Namespace sets the namespace +func (h *HelmChart) Namespace(ns string) *HelmChart { + h.namespace = ns + return h +} + +// Values sets or merges Helm values +func (h *HelmChart) Values(values map[string]interface{}) *HelmChart { + for k, v := range values { + h.values[k] = v + } + return h +} + +// SetValue sets a single value using dot notation +func (h *HelmChart) SetValue(key string, value interface{}) *HelmChart { + parts := strings.Split(key, ".") + m := h.values + for i, part := range parts { + if i == len(parts)-1 { + m[part] = value + } else { + if _, ok := m[part]; !ok { + m[part] = make(map[string]interface{}) + } + m = m[part].(map[string]interface{}) + } + } + return h +} + +// Wait enables waiting for resources to be ready +func (h *HelmChart) Wait() *HelmChart { + h.wait = true + return h +} + +// WaitFor sets the wait timeout +func (h *HelmChart) WaitFor(timeout time.Duration) *HelmChart { + h.wait = true + h.timeout = timeout + return h +} + +// WithPassword creates a password secret and uses it +func (h *HelmChart) WithPassword(secretName string) *HelmChart { + h.passwordSecret = secretName + return h +} + +// DryRun enables dry-run mode +func (h *HelmChart) DryRun() *HelmChart { + h.dryRun = true + return h +} + +// NoColor disables colored output +func (h *HelmChart) NoColor() *HelmChart { + h.colorOutput = false + h.runner = NewCommandRunner(false) + return h +} + +// Install installs the Helm chart +func (h *HelmChart) Install() *HelmChart { + if h.releaseName == "" { + h.lastError = fmt.Errorf("release name is required") + return h + } + + h.runner.Printf(colorYellow, colorBold, "=== Helm Install: %s ===", h.releaseName) + + // Handle password secret if specified + if h.passwordSecret != "" { + if err := h.createPasswordSecret(); err != nil { + h.lastError = err + return h + } + } + + args := []string{"install", h.releaseName, h.chartPath} + args = h.appendCommonArgs(args) + + if h.dryRun { + args = append(args, "--dry-run") + } + + h.lastResult = h.runner.RunCommand("helm", args...) + if h.lastResult.Err != nil { + h.lastError = fmt.Errorf("helm install failed: %s", h.lastResult.String()) + h.collectDiagnostics() + } + return h +} + +// Upgrade upgrades the Helm release +func (h *HelmChart) Upgrade() *HelmChart { + if h.releaseName == "" { + h.lastError = fmt.Errorf("release name is required") + return h + } + + h.runner.Printf(colorYellow, colorBold, "=== Helm Upgrade: %s ===", h.releaseName) + + // Handle password secret if specified + if h.passwordSecret != "" { + if err := h.createPasswordSecret(); err != nil { + h.lastError = err + return h + } + } + + args := []string{"upgrade", h.releaseName, h.chartPath} + args = h.appendCommonArgs(args) + + if h.dryRun { + args = append(args, "--dry-run") + } + + h.lastResult = h.runner.RunCommand("helm", args...) + if h.lastResult.Err != nil { + h.lastError = fmt.Errorf("helm upgrade failed: %s", h.lastResult.String()) + h.collectDiagnostics() + } + return h +} + +// Delete deletes the Helm release +func (h *HelmChart) Delete() *HelmChart { + if h.releaseName == "" { + h.lastError = fmt.Errorf("release name is required") + return h + } + + args := []string{"delete", h.releaseName} + if h.namespace != "" { + args = append(args, "--namespace", h.namespace) + } + if h.wait { + args = append(args, "--wait") + } + + h.lastResult = h.runner.RunCommand("helm", args...) + if h.lastResult.Err != nil && !strings.Contains(h.lastResult.Stderr, "not found") { + h.lastError = fmt.Errorf("helm delete failed: %s", h.lastResult.String()) + } + return h +} + +// GetPod returns a Pod accessor for the current release +func (h *HelmChart) GetPod(selector string) *Pod { + return &Pod{ + namespace: h.namespace, + selector: selector, + helm: h, + colorOutput: h.colorOutput, + } +} + +// GetStatefulSet returns a StatefulSet accessor +func (h *HelmChart) GetStatefulSet(name string) *StatefulSet { + return &StatefulSet{ + name: name, + namespace: h.namespace, + helm: h, + colorOutput: h.colorOutput, + } +} + +// GetSecret returns a Secret accessor +func (h *HelmChart) GetSecret(name string) *Secret { + return &Secret{ + name: name, + namespace: h.namespace, + helm: h, + colorOutput: h.colorOutput, + } +} + +// GetConfigMap returns a ConfigMap accessor +func (h *HelmChart) GetConfigMap(name string) *ConfigMap { + return &ConfigMap{ + name: name, + namespace: h.namespace, + helm: h, + colorOutput: h.colorOutput, + } +} + +// GetPVC returns a PersistentVolumeClaim accessor +func (h *HelmChart) GetPVC(name string) *PVC { + return &PVC{ + name: name, + namespace: h.namespace, + helm: h, + colorOutput: h.colorOutput, + } +} + +// Status returns the Helm release status +func (h *HelmChart) Status() (string, error) { + args := []string{"status", h.releaseName} + if h.namespace != "" { + args = append(args, "-n", h.namespace) + } + result := h.runner.RunCommand("helm", args...) + return result.Stdout, result.Err +} + +// Error returns the last error +func (h *HelmChart) Error() error { + return h.lastError +} + +// Result returns the last command result +func (h *HelmChart) Result() CommandResult { + return h.lastResult +} + +// MustSucceed panics if there was an error +func (h *HelmChart) MustSucceed() *HelmChart { + if h.lastError != nil { + panic(h.lastError) + } + return h +} + +// Pod represents a Kubernetes pod with fluent interface +type Pod struct { + namespace string + selector string + name string + container string + helm *HelmChart + colorOutput bool + lastResult CommandResult + lastError error +} + +// Container sets the container name +func (p *Pod) Container(name string) *Pod { + p.container = name + return p +} + +// WaitReady waits for the pod to be ready +func (p *Pod) WaitReady() *Pod { + return p.WaitFor("condition=Ready", 2*time.Minute) +} + +// WaitFor waits for a specific condition +func (p *Pod) WaitFor(condition string, timeout time.Duration) *Pod { + args := []string{"wait", "pod"} + if p.namespace != "" { + args = append(args, "-n", p.namespace) + } + if p.selector != "" { + args = append(args, "-l", p.selector) + } + args = append(args, "--for="+condition, "--timeout="+timeout.String()) + + p.lastResult = p.runCommand("kubectl", args...) + if p.lastResult.Err != nil { + p.lastError = fmt.Errorf("wait failed: %s", p.lastResult.String()) + } + return p +} + +// Exec executes a command in the pod +func (p *Pod) Exec(command string) *Pod { + // Get pod name if not set + if p.name == "" && p.selector != "" { + if err := p.resolvePodName(); err != nil { + p.lastError = err + return p + } + } + + args := []string{"exec", "-n", p.namespace, p.name} + if p.container != "" { + args = append(args, "-c", p.container) + } + args = append(args, "--", "bash", "-c", command) + + p.lastResult = p.runCommand("kubectl", args...) + if p.lastResult.Err != nil { + p.lastError = fmt.Errorf("exec failed: %s", p.lastResult.String()) + } + return p +} + +// GetLogs retrieves pod logs +func (p *Pod) GetLogs(lines ...int) string { + // Get pod name if not set + if p.name == "" && p.selector != "" { + if err := p.resolvePodName(); err != nil { + p.lastError = err + return "" + } + } + + args := []string{"logs", "-n", p.namespace, p.name} + if p.container != "" { + args = append(args, "-c", p.container) + } + if len(lines) > 0 { + args = append(args, "--tail", fmt.Sprintf("%d", lines[0])) + } + + p.lastResult = p.runCommand("kubectl", args...) + return p.lastResult.Stdout +} + +// Status returns the pod status +func (p *Pod) Status() (string, error) { + // Get pod name if not set + if p.name == "" && p.selector != "" { + if err := p.resolvePodName(); err != nil { + return "", err + } + } + + args := []string{"get", "pod", p.name, "-n", p.namespace, + "-o", "jsonpath={.status.phase}"} + p.lastResult = p.runCommand("kubectl", args...) + return strings.TrimSpace(p.lastResult.Stdout), p.lastResult.Err +} + +// Result returns the last command result +func (p *Pod) Result() string { + return p.lastResult.Stdout +} + +// Error returns the last error +func (p *Pod) Error() error { + return p.lastError +} + +// MustSucceed panics if there was an error +func (p *Pod) MustSucceed() *Pod { + if p.lastError != nil { + panic(p.lastError) + } + return p +} + +// StatefulSet represents a Kubernetes StatefulSet +type StatefulSet struct { + name string + namespace string + helm *HelmChart + colorOutput bool + lastResult CommandResult + lastError error +} + +// WaitReady waits for the StatefulSet to be ready +func (s *StatefulSet) WaitReady() *StatefulSet { + return s.WaitFor(2 * time.Minute) +} + +// WaitFor waits for the StatefulSet rollout to complete +func (s *StatefulSet) WaitFor(timeout time.Duration) *StatefulSet { + args := []string{"rollout", "status", "statefulset", s.name, + "-n", s.namespace, "--timeout=" + timeout.String()} + + s.lastResult = s.runCommand("kubectl", args...) + if s.lastResult.Err != nil { + s.lastError = fmt.Errorf("rollout wait failed: %s", s.lastResult.String()) + } + return s +} + +// GetReplicas returns the number of ready replicas +func (s *StatefulSet) GetReplicas() (int, error) { + args := []string{"get", "statefulset", s.name, "-n", s.namespace, + "-o", "jsonpath={.status.readyReplicas}"} + s.lastResult = s.runCommand("kubectl", args...) + if s.lastResult.Err != nil { + return 0, s.lastResult.Err + } + + if s.lastResult.Stdout == "" { + return 0, nil + } + + var replicas int + if _, err := fmt.Sscanf(s.lastResult.Stdout, "%d", &replicas); err != nil { + return 0, fmt.Errorf("failed to parse replicas: %w", err) + } + return replicas, nil +} + +// GetGeneration returns the current generation +func (s *StatefulSet) GetGeneration() (int64, error) { + args := []string{"get", "statefulset", s.name, "-n", s.namespace, + "-o", "jsonpath={.metadata.generation}"} + s.lastResult = s.runCommand("kubectl", args...) + if s.lastResult.Err != nil { + return 0, s.lastResult.Err + } + + var gen int64 + if _, err := fmt.Sscanf(strings.TrimSpace(s.lastResult.Stdout), "%d", &gen); err != nil { + return 0, fmt.Errorf("failed to parse generation: %w", err) + } + return gen, nil +} + +// Secret represents a Kubernetes Secret +type Secret struct { + name string + namespace string + helm *HelmChart + colorOutput bool + lastResult CommandResult +} + +// Get retrieves a secret value by key +func (s *Secret) Get(key string) (string, error) { + args := []string{"get", "secret", s.name, "-n", s.namespace, + "-o", fmt.Sprintf("jsonpath={.data.%s}", key)} + s.lastResult = s.runCommand("kubectl", args...) + if s.lastResult.Err != nil { + return "", s.lastResult.Err + } + + // Decode base64 + cmd := exec.Command("base64", "-d") + cmd.Stdin = strings.NewReader(s.lastResult.Stdout) + decoded, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("failed to decode base64: %w", err) + } + + return string(decoded), nil +} + +// ConfigMap represents a Kubernetes ConfigMap +type ConfigMap struct { + name string + namespace string + helm *HelmChart + colorOutput bool + lastResult CommandResult +} + +// Get retrieves a ConfigMap value by key +func (c *ConfigMap) Get(key string) (string, error) { + escapedKey := strings.ReplaceAll(key, ".", "\\.") + args := []string{"get", "configmap", c.name, "-n", c.namespace, + "-o", fmt.Sprintf("jsonpath={.data['%s']}", escapedKey)} + c.lastResult = c.runCommand("kubectl", args...) + return c.lastResult.Stdout, c.lastResult.Err +} + +// PVC represents a PersistentVolumeClaim +type PVC struct { + name string + namespace string + helm *HelmChart + colorOutput bool + lastResult CommandResult +} + +// Status returns the PVC status +func (p *PVC) Status() (map[string]interface{}, error) { + args := []string{"get", "pvc", p.name, "-n", p.namespace, "-o", "json"} + p.lastResult = p.runCommand("kubectl", args...) + if p.lastResult.Err != nil { + return nil, p.lastResult.Err + } + + var pvc map[string]interface{} + if err := json.Unmarshal([]byte(p.lastResult.Stdout), &pvc); err != nil { + return nil, fmt.Errorf("failed to unmarshal PVC: %w", err) + } + + return pvc, nil +} + +// Helper methods + +func (h *HelmChart) appendCommonArgs(args []string) []string { + if h.namespace != "" { + args = append(args, "--namespace", h.namespace) + } + if h.wait { + args = append(args, "--wait") + } + if h.timeout > 0 { + args = append(args, "--timeout", h.timeout.String()) + } + + // Add values if any + if len(h.values) > 0 { + valuesYaml, err := yaml.Marshal(h.values) + if err != nil { + h.lastError = fmt.Errorf("failed to marshal values: %w", err) + return args + } + + // Write values to temp file + tempFile := fmt.Sprintf("/tmp/helm-values-%d.yaml", time.Now().UnixNano()) + cmd := exec.Command("sh", "-c", fmt.Sprintf("cat > %s", tempFile)) + cmd.Stdin = bytes.NewReader(valuesYaml) + if err := cmd.Run(); err != nil { + h.lastError = fmt.Errorf("failed to write values file: %w", err) + return args + } + + args = append(args, "--values", tempFile) + // Note: In production, should defer cleanup of temp file + } + + return args +} + +func (h *HelmChart) createPasswordSecret() error { + password := fmt.Sprintf("pass-%d", time.Now().Unix()) + + h.runner.Printf(colorYellow, colorBold, "Creating password secret: %s", h.passwordSecret) + + // Create the secret + result := h.runner.RunCommand("kubectl", "create", "secret", "generic", h.passwordSecret, + "--from-literal=password="+password, + "-n", h.namespace, + "--dry-run=client", "-o", "yaml") + + if result.Err != nil { + return fmt.Errorf("failed to generate secret yaml: %s", result.String()) + } + + // Apply the secret + cmd := exec.Command("kubectl", "apply", "-f", "-") + cmd.Stdin = strings.NewReader(result.Stdout) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to create secret: %w", err) + } + + // Add the secret reference to values + if h.values == nil { + h.values = make(map[string]interface{}) + } + h.values["database"] = map[string]interface{}{ + "existingSecret": h.passwordSecret, + "secretKey": "password", + } + + h.runner.Printf(colorGray, "", "Secret created with password: %s", password) + return nil +} + +func (h *HelmChart) collectDiagnostics() { + if !h.colorOutput { + return + } + + h.runner.Printf(colorRed, colorBold, "=== Collecting Diagnostics ===") + + // Get Helm release status + h.runner.Printf(colorBlue, "", "● Helm Release Status:") + h.runner.RunCommand("helm", "status", h.releaseName, "-n", h.namespace) + + // Get pods + h.runner.Printf(colorBlue, "", "● Pods in namespace %s:", h.namespace) + h.runner.RunCommand("kubectl", "get", "pods", "-n", h.namespace, "-o", "wide") + + // Get events + h.runner.Printf(colorBlue, "", "● Recent Events:") + h.runner.RunCommand("kubectl", "get", "events", "-n", h.namespace, + "--sort-by=.lastTimestamp") + + h.runner.Printf(colorYellow, colorBold, "=== End of Diagnostics ===") +} + + + + +// Similar runCommand methods for Pod, StatefulSet, etc. +func (p *Pod) runCommand(name string, args ...string) CommandResult { + return p.helm.runner.RunCommand(name, args...) +} + +func (s *StatefulSet) runCommand(name string, args ...string) CommandResult { + return s.helm.runner.RunCommand(name, args...) +} + +func (sec *Secret) runCommand(name string, args ...string) CommandResult { + return sec.helm.runner.RunCommand(name, args...) +} + +func (c *ConfigMap) runCommand(name string, args ...string) CommandResult { + return c.helm.runner.RunCommand(name, args...) +} + +func (p *PVC) runCommand(name string, args ...string) CommandResult { + return p.helm.runner.RunCommand(name, args...) +} + +func (p *Pod) resolvePodName() error { + args := []string{"get", "pods", "-n", p.namespace, "-l", p.selector, + "-o", "jsonpath={.items[0].metadata.name}"} + p.lastResult = p.runCommand("kubectl", args...) + if p.lastResult.Err != nil { + return fmt.Errorf("failed to get pod name: %w", p.lastResult.Err) + } + p.name = strings.TrimSpace(p.lastResult.Stdout) + if p.name == "" { + return fmt.Errorf("no pod found with selector: %s", p.selector) + } + return nil +} + +// Namespace represents a Kubernetes namespace with fluent interface +type Namespace struct { + name string + colorOutput bool + runner *CommandRunner + lastResult CommandResult + lastError error +} + +// NewNamespace creates a new Namespace accessor +func NewNamespace(name string) *Namespace { + return &Namespace{ + name: name, + colorOutput: true, + runner: NewCommandRunner(true), + } +} + +// Create creates the namespace +func (n *Namespace) Create() *Namespace { + result := n.runCommand("kubectl", "create", "namespace", n.name) + if result.Err != nil && strings.Contains(result.Stderr, "already exists") { + // Namespace already exists, that's ok + n.lastError = nil + } else if result.Err != nil { + n.lastError = fmt.Errorf("failed to create namespace: %s", result.String()) + } + n.lastResult = result + return n +} + +// Delete deletes the namespace +func (n *Namespace) Delete() *Namespace { + result := n.runCommand("kubectl", "delete", "namespace", n.name, "--wait=false") + if result.Err != nil { + n.lastError = fmt.Errorf("failed to delete namespace: %s", result.String()) + } + n.lastResult = result + return n +} + +// MustSucceed panics if there was an error +func (n *Namespace) MustSucceed() *Namespace { + if n.lastError != nil { + panic(n.lastError) + } + return n +} + +func (n *Namespace) runCommand(name string, args ...string) CommandResult { + return n.runner.RunCommand(name, args...) +} \ No newline at end of file diff --git a/test/kind.go b/test/kind.go new file mode 100644 index 0000000..649068f --- /dev/null +++ b/test/kind.go @@ -0,0 +1,215 @@ +package test + +import ( + "fmt" + "os" + "strings" + "time" +) + +type Kind struct { + Version string `yaml:"version"` + Name string `yaml:"name"` + UseExisting bool `yaml:"use_existing"` + ColorOutput bool `yaml:"color_output"` + + runner *CommandRunner + lastResult CommandResult + lastError error +} + +// NewKind creates a new Kind cluster manager +func NewKind(name string) *Kind { + if name == "" { + name = "kind" + } + return &Kind{ + Name: name, + Version: "latest", + ColorOutput: true, + runner: NewCommandRunner(true), + } +} + +// WithVersion sets the kind version to use +func (k *Kind) WithVersion(version string) *Kind { + k.Version = version + return k +} + +// NoColor disables colored output +func (k *Kind) NoColor() *Kind { + k.ColorOutput = false + k.runner = NewCommandRunner(false) + return k +} + +// GetOrCreate gets an existing kind cluster or creates a new one +func (k *Kind) GetOrCreate() *Kind { + k.runner.Printf(colorYellow, colorBold, "=== Kind Cluster: %s ===", k.Name) + + // Check if cluster already exists + result := k.runner.RunCommandQuiet("kind", "get", "clusters") + if result.Err == nil { + clusters := strings.Split(strings.TrimSpace(result.Stdout), "\n") + for _, cluster := range clusters { + if cluster == k.Name { + k.runner.Printf(colorGray, "", "Using existing cluster: %s", k.Name) + k.Use() + return k + } + } + } + + // Create new cluster + k.runner.Printf(colorBlue, "", "Creating new cluster: %s", k.Name) + + args := []string{"create", "cluster", "--name", k.Name} + if k.Version != "" && k.Version != "latest" { + args = append(args, "--image", fmt.Sprintf("kindest/node:%s", k.Version)) + } + + k.lastResult = k.runner.RunCommand("kind", args...) + if k.lastResult.Err != nil { + k.lastError = fmt.Errorf("failed to create kind cluster: %s", k.lastResult.String()) + return k + } + + // Wait for cluster to be ready + k.runner.Printf(colorGray, "", "Waiting for cluster to be ready...") + k.waitForCluster() + + k.Use() + return k +} + +// Use updates KUBECONFIG to use the kind cluster +func (k *Kind) Use() *Kind { + k.runner.Printf(colorBlue, "", "Switching to cluster context: kind-%s", k.Name) + + // Export kubeconfig for the kind cluster + result := k.runner.RunCommandQuiet("kind", "export", "kubeconfig", "--name", k.Name) + if result.Err != nil { + k.lastError = fmt.Errorf("failed to export kubeconfig: %s", result.String()) + return k + } + + // Set the current context + contextName := fmt.Sprintf("kind-%s", k.Name) + k.lastResult = k.runner.RunCommand("kubectl", "config", "use-context", contextName) + if k.lastResult.Err != nil { + k.lastError = fmt.Errorf("failed to switch context: %s", k.lastResult.String()) + return k + } + + // Verify connection + k.runner.Printf(colorGray, "", "Verifying cluster connection...") + result = k.runner.RunCommandQuiet("kubectl", "cluster-info", "--context", contextName) + if result.Err != nil { + k.lastError = fmt.Errorf("failed to verify cluster connection: %s", result.String()) + return k + } + + k.runner.Printf(colorGray, "", "Successfully connected to cluster: %s", k.Name) + return k +} + +// Delete deletes the kind cluster +func (k *Kind) Delete() *Kind { + k.runner.Printf(colorYellow, colorBold, "=== Deleting Kind Cluster: %s ===", k.Name) + + k.lastResult = k.runner.RunCommand("kind", "delete", "cluster", "--name", k.Name) + if k.lastResult.Err != nil { + k.lastError = fmt.Errorf("failed to delete kind cluster: %s", k.lastResult.String()) + } + return k +} + +// LoadImage loads a docker image into the kind cluster +func (k *Kind) LoadImage(image string) *Kind { + k.runner.Printf(colorBlue, "", "Loading image into cluster: %s", image) + + k.lastResult = k.runner.RunCommand("kind", "load", "docker-image", image, "--name", k.Name) + if k.lastResult.Err != nil { + k.lastError = fmt.Errorf("failed to load image: %s", k.lastResult.String()) + } + return k +} + +// GetKubeconfig returns the kubeconfig for the kind cluster +func (k *Kind) GetKubeconfig() (string, error) { + result := k.runner.RunCommandQuiet("kind", "get", "kubeconfig", "--name", k.Name) + if result.Err != nil { + return "", fmt.Errorf("failed to get kubeconfig: %s", result.String()) + } + return result.Stdout, nil +} + +// Exists checks if the kind cluster exists +func (k *Kind) Exists() bool { + result := k.runner.RunCommandQuiet("kind", "get", "clusters") + if result.Err != nil { + return false + } + + clusters := strings.Split(strings.TrimSpace(result.Stdout), "\n") + for _, cluster := range clusters { + if cluster == k.Name { + return true + } + } + return false +} + +// Error returns the last error +func (k *Kind) Error() error { + return k.lastError +} + +// Result returns the last command result +func (k *Kind) Result() CommandResult { + return k.lastResult +} + +// MustSucceed panics if there was an error +func (k *Kind) MustSucceed() *Kind { + if k.lastError != nil { + panic(k.lastError) + } + return k +} + +// waitForCluster waits for the cluster to be ready +func (k *Kind) waitForCluster() { + maxRetries := 30 + for i := 0; i < maxRetries; i++ { + result := k.runner.RunCommandQuiet("kubectl", "get", "nodes") + if result.Err == nil && strings.Contains(result.Stdout, "Ready") { + return + } + time.Sleep(2 * time.Second) + } +} + +// SetKubeconfig sets the KUBECONFIG environment variable to use the kind cluster +func (k *Kind) SetKubeconfig() *Kind { + kubeconfig, err := k.GetKubeconfig() + if err != nil { + k.lastError = err + return k + } + + // Write kubeconfig to temp file + tempFile := fmt.Sprintf("/tmp/kind-%s-kubeconfig-%d", k.Name, time.Now().UnixNano()) + cmd := k.runner.RunCommandQuiet("sh", "-c", fmt.Sprintf("cat > %s << 'EOF'\n%s\nEOF", tempFile, kubeconfig)) + if cmd.Err != nil { + k.lastError = fmt.Errorf("failed to write kubeconfig: %w", cmd.Err) + return k + } + + // Set KUBECONFIG environment variable + os.Setenv("KUBECONFIG", tempFile) + k.runner.Printf(colorGray, "", "KUBECONFIG set to: %s", tempFile) + + return k +} diff --git a/test/kind_test.go b/test/kind_test.go new file mode 100644 index 0000000..2878140 --- /dev/null +++ b/test/kind_test.go @@ -0,0 +1,134 @@ +package test + +import ( + "os" + "testing" +) + +func TestKindCluster(t *testing.T) { + // Skip if not running integration tests + if os.Getenv("INTEGRATION_TEST") != "true" { + t.Skip("Skipping integration test") + } + + t.Run("GetOrCreate", func(t *testing.T) { + kind := NewKind("test-cluster").NoColor() + + // Create or get cluster + kind.GetOrCreate() + if kind.Error() != nil { + t.Fatalf("Failed to get or create cluster: %v", kind.Error()) + } + + // Verify cluster exists + if !kind.Exists() { + t.Error("Cluster should exist after GetOrCreate") + } + }) + + t.Run("Use", func(t *testing.T) { + kind := NewKind("test-cluster").NoColor() + + // Use the cluster + kind.Use() + if kind.Error() != nil { + t.Fatalf("Failed to use cluster: %v", kind.Error()) + } + + // Verify we can get kubeconfig + kubeconfig, err := kind.GetKubeconfig() + if err != nil { + t.Fatalf("Failed to get kubeconfig: %v", err) + } + if kubeconfig == "" { + t.Error("Kubeconfig should not be empty") + } + }) + + t.Run("LoadImage", func(t *testing.T) { + kind := NewKind("test-cluster").NoColor() + + // Skip if docker image doesn't exist + runner := NewCommandRunner(false) + result := runner.RunCommandQuiet("docker", "images", "-q", "nginx:latest") + if result.Err != nil || result.Stdout == "" { + t.Skip("nginx:latest image not available") + } + + // Load image into cluster + kind.LoadImage("nginx:latest") + if kind.Error() != nil { + t.Fatalf("Failed to load image: %v", kind.Error()) + } + }) + + t.Run("Delete", func(t *testing.T) { + kind := NewKind("test-cluster").NoColor() + + // Delete the cluster + kind.Delete() + if kind.Error() != nil { + t.Fatalf("Failed to delete cluster: %v", kind.Error()) + } + + // Verify cluster no longer exists + if kind.Exists() { + t.Error("Cluster should not exist after Delete") + } + }) +} + +func TestKindBuilderPattern(t *testing.T) { + kind := NewKind("builder-test"). + WithVersion("v1.27.0"). + NoColor() + + if kind.Name != "builder-test" { + t.Errorf("Expected name to be 'builder-test', got %s", kind.Name) + } + + if kind.Version != "v1.27.0" { + t.Errorf("Expected version to be 'v1.27.0', got %s", kind.Version) + } + + if kind.ColorOutput { + t.Error("Expected ColorOutput to be false") + } +} + +func TestCommandRunner(t *testing.T) { + runner := NewCommandRunner(false) + + t.Run("RunCommand", func(t *testing.T) { + result := runner.RunCommand("echo", "hello") + if result.Err != nil { + t.Fatalf("Command failed: %v", result.Err) + } + if result.ExitCode != 0 { + t.Errorf("Expected exit code 0, got %d", result.ExitCode) + } + if result.Stdout != "hello\n" { + t.Errorf("Expected 'hello\\n', got %q", result.Stdout) + } + }) + + t.Run("RunCommandQuiet", func(t *testing.T) { + result := runner.RunCommandQuiet("echo", "quiet") + if result.Err != nil { + t.Fatalf("Command failed: %v", result.Err) + } + if result.Stdout != "quiet\n" { + t.Errorf("Expected 'quiet\\n', got %q", result.Stdout) + } + }) + + t.Run("CommandFailure", func(t *testing.T) { + result := runner.RunCommand("false") + if result.Err == nil { + t.Error("Expected command to fail") + } + if result.ExitCode == 0 { + t.Error("Expected non-zero exit code") + } + }) +} \ No newline at end of file