From 7ed9eba4c48a9149ffe2cde3a50bf293fbeb3630 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Mon, 4 Aug 2025 15:26:34 +0300 Subject: [PATCH 1/2] fixup: Add fluent/builder interface for Helm and Kubernetes operations --- test/helm.go | 858 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 858 insertions(+) create mode 100644 test/helm.go diff --git a/test/helm.go b/test/helm.go new file mode 100644 index 0000000..5f81983 --- /dev/null +++ b/test/helm.go @@ -0,0 +1,858 @@ +package test + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "os/exec" + "strings" + "sync" + "time" + + "sigs.k8s.io/yaml" +) + +// 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" +) + +// 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) +} + +// 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 + 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{}), + } +} + +// 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 + 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.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.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.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.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.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.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 + lastError error +} + +// 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 + lastError error +} + +// 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 + lastError error +} + +// 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.printf(colorYellow, colorBold, "Creating password secret: %s", h.passwordSecret) + + // Create the secret + result := h.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.printf(colorGray, "", "Secret created with password: %s", password) + return nil +} + +func (h *HelmChart) collectDiagnostics() { + if !h.colorOutput { + return + } + + h.printf(colorRed, colorBold, "=== Collecting Diagnostics ===") + + // Get Helm release status + h.printf(colorBlue, "", "● Helm Release Status:") + h.runCommand("helm", "status", h.releaseName, "-n", h.namespace) + + // Get pods + h.printf(colorBlue, "", "● Pods in namespace %s:", h.namespace) + h.runCommand("kubectl", "get", "pods", "-n", h.namespace, "-o", "wide") + + // Get events + h.printf(colorBlue, "", "● Recent Events:") + h.runCommand("kubectl", "get", "events", "-n", h.namespace, + "--sort-by=.lastTimestamp") + + h.printf(colorYellow, colorBold, "=== End of Diagnostics ===") +} + +func (h *HelmChart) printf(color, style, format string, args ...interface{}) { + if h.colorOutput { + fmt.Printf("%s%s%s%s\n", color, style, fmt.Sprintf(format, args...), colorReset) + } else { + fmt.Printf(format+"\n", args...) + } +} + +func (h *HelmChart) runCommand(name string, args ...string) CommandResult { + if h.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 h.streamOutput(stdoutPipe, "stdout", colorGray, &stdout, &wg) + go h.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 h.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 +} + +func (h *HelmChart) 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 h.colorOutput { + fmt.Printf("%s%s%s: %s%s\n", color, prefix, colorReset, color, line+colorReset) + } + } +} + +// Similar runCommand methods for Pod, StatefulSet, etc. +func (p *Pod) runCommand(name string, args ...string) CommandResult { + return p.helm.runCommand(name, args...) +} + +func (s *StatefulSet) runCommand(name string, args ...string) CommandResult { + return s.helm.runCommand(name, args...) +} + +func (sec *Secret) runCommand(name string, args ...string) CommandResult { + return sec.helm.runCommand(name, args...) +} + +func (c *ConfigMap) runCommand(name string, args ...string) CommandResult { + return c.helm.runCommand(name, args...) +} + +func (p *PVC) runCommand(name string, args ...string) CommandResult { + return p.helm.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 + lastResult CommandResult + lastError error +} + +// NewNamespace creates a new Namespace accessor +func NewNamespace(name string) *Namespace { + return &Namespace{ + name: name, + colorOutput: 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 { + if n.colorOutput { + fmt.Printf("%s%s>>> Executing: %s %s%s\n", colorBlue, colorBold, name, strings.Join(args, " "), colorReset) + } + + 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() + } + + result := CommandResult{ + Stdout: stdout.String(), + Stderr: stderr.String(), + ExitCode: exitCode, + Err: err, + } + + if n.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) + } + } + + return result +} \ No newline at end of file From b8d7cf78defbcdaf6d195386eb70fadb47424e4b Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Mon, 4 Aug 2025 16:35:36 +0300 Subject: [PATCH 2/2] feat: extract command execution and implement Kind cluster management - Extract command execution logic to separate command.go file with CommandRunner - Implement Kind.Use() to switch to kind cluster context - Implement Kind.GetOrCreate() to create or use existing kind cluster - Add fluent builder interface for Kind cluster management - Update HelmChart to use CommandRunner for consistent command execution - Add comprehensive tests for Kind functionality --- test/colors.go | 11 +++ test/command.go | 150 ++++++++++++++++++++++++++++++++ test/helm.go | 200 ++++++------------------------------------ test/kind.go | 215 ++++++++++++++++++++++++++++++++++++++++++++++ test/kind_test.go | 134 +++++++++++++++++++++++++++++ 5 files changed, 538 insertions(+), 172 deletions(-) create mode 100644 test/colors.go create mode 100644 test/command.go create mode 100644 test/kind.go create mode 100644 test/kind_test.go 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 index 5f81983..5532278 100644 --- a/test/helm.go +++ b/test/helm.go @@ -1,43 +1,16 @@ package test import ( - "bufio" "bytes" "encoding/json" "fmt" - "io" "os/exec" "strings" - "sync" "time" "sigs.k8s.io/yaml" ) -// 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" -) - -// 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) -} - // HelmChart represents a Helm chart with fluent interface type HelmChart struct { releaseName string @@ -51,6 +24,7 @@ type HelmChart struct { dryRun bool // Command execution state + runner *CommandRunner lastResult CommandResult lastError error } @@ -62,6 +36,7 @@ func NewHelmChart(chartPath string) *HelmChart { colorOutput: true, timeout: 5 * time.Minute, values: make(map[string]interface{}), + runner: NewCommandRunner(true), } } @@ -130,6 +105,7 @@ func (h *HelmChart) DryRun() *HelmChart { // NoColor disables colored output func (h *HelmChart) NoColor() *HelmChart { h.colorOutput = false + h.runner = NewCommandRunner(false) return h } @@ -140,7 +116,7 @@ func (h *HelmChart) Install() *HelmChart { return h } - h.printf(colorYellow, colorBold, "=== Helm Install: %s ===", h.releaseName) + h.runner.Printf(colorYellow, colorBold, "=== Helm Install: %s ===", h.releaseName) // Handle password secret if specified if h.passwordSecret != "" { @@ -157,7 +133,7 @@ func (h *HelmChart) Install() *HelmChart { args = append(args, "--dry-run") } - h.lastResult = h.runCommand("helm", args...) + 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() @@ -172,7 +148,7 @@ func (h *HelmChart) Upgrade() *HelmChart { return h } - h.printf(colorYellow, colorBold, "=== Helm Upgrade: %s ===", h.releaseName) + h.runner.Printf(colorYellow, colorBold, "=== Helm Upgrade: %s ===", h.releaseName) // Handle password secret if specified if h.passwordSecret != "" { @@ -189,7 +165,7 @@ func (h *HelmChart) Upgrade() *HelmChart { args = append(args, "--dry-run") } - h.lastResult = h.runCommand("helm", args...) + 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() @@ -212,7 +188,7 @@ func (h *HelmChart) Delete() *HelmChart { args = append(args, "--wait") } - h.lastResult = h.runCommand("helm", args...) + 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()) } @@ -275,7 +251,7 @@ func (h *HelmChart) Status() (string, error) { if h.namespace != "" { args = append(args, "-n", h.namespace) } - result := h.runCommand("helm", args...) + result := h.runner.RunCommand("helm", args...) return result.Stdout, result.Err } @@ -486,7 +462,6 @@ type Secret struct { helm *HelmChart colorOutput bool lastResult CommandResult - lastError error } // Get retrieves a secret value by key @@ -516,7 +491,6 @@ type ConfigMap struct { helm *HelmChart colorOutput bool lastResult CommandResult - lastError error } // Get retrieves a ConfigMap value by key @@ -535,7 +509,6 @@ type PVC struct { helm *HelmChart colorOutput bool lastResult CommandResult - lastError error } // Status returns the PVC status @@ -594,10 +567,10 @@ func (h *HelmChart) appendCommonArgs(args []string) []string { func (h *HelmChart) createPasswordSecret() error { password := fmt.Sprintf("pass-%d", time.Now().Unix()) - h.printf(colorYellow, colorBold, "Creating password secret: %s", h.passwordSecret) + h.runner.Printf(colorYellow, colorBold, "Creating password secret: %s", h.passwordSecret) // Create the secret - result := h.runCommand("kubectl", "create", "secret", "generic", h.passwordSecret, + result := h.runner.RunCommand("kubectl", "create", "secret", "generic", h.passwordSecret, "--from-literal=password="+password, "-n", h.namespace, "--dry-run=client", "-o", "yaml") @@ -622,7 +595,7 @@ func (h *HelmChart) createPasswordSecret() error { "secretKey": "password", } - h.printf(colorGray, "", "Secret created with password: %s", password) + h.runner.Printf(colorGray, "", "Secret created with password: %s", password) return nil } @@ -631,135 +604,46 @@ func (h *HelmChart) collectDiagnostics() { return } - h.printf(colorRed, colorBold, "=== Collecting Diagnostics ===") + h.runner.Printf(colorRed, colorBold, "=== Collecting Diagnostics ===") // Get Helm release status - h.printf(colorBlue, "", "● Helm Release Status:") - h.runCommand("helm", "status", h.releaseName, "-n", h.namespace) + h.runner.Printf(colorBlue, "", "● Helm Release Status:") + h.runner.RunCommand("helm", "status", h.releaseName, "-n", h.namespace) // Get pods - h.printf(colorBlue, "", "● Pods in namespace %s:", h.namespace) - h.runCommand("kubectl", "get", "pods", "-n", h.namespace, "-o", "wide") + h.runner.Printf(colorBlue, "", "● Pods in namespace %s:", h.namespace) + h.runner.RunCommand("kubectl", "get", "pods", "-n", h.namespace, "-o", "wide") // Get events - h.printf(colorBlue, "", "● Recent Events:") - h.runCommand("kubectl", "get", "events", "-n", h.namespace, + h.runner.Printf(colorBlue, "", "● Recent Events:") + h.runner.RunCommand("kubectl", "get", "events", "-n", h.namespace, "--sort-by=.lastTimestamp") - h.printf(colorYellow, colorBold, "=== End of Diagnostics ===") -} - -func (h *HelmChart) printf(color, style, format string, args ...interface{}) { - if h.colorOutput { - fmt.Printf("%s%s%s%s\n", color, style, fmt.Sprintf(format, args...), colorReset) - } else { - fmt.Printf(format+"\n", args...) - } + h.runner.Printf(colorYellow, colorBold, "=== End of Diagnostics ===") } -func (h *HelmChart) runCommand(name string, args ...string) CommandResult { - if h.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 h.streamOutput(stdoutPipe, "stdout", colorGray, &stdout, &wg) - go h.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 h.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 -} - -func (h *HelmChart) 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 h.colorOutput { - fmt.Printf("%s%s%s: %s%s\n", color, prefix, colorReset, color, line+colorReset) - } - } -} // Similar runCommand methods for Pod, StatefulSet, etc. func (p *Pod) runCommand(name string, args ...string) CommandResult { - return p.helm.runCommand(name, args...) + return p.helm.runner.RunCommand(name, args...) } func (s *StatefulSet) runCommand(name string, args ...string) CommandResult { - return s.helm.runCommand(name, args...) + return s.helm.runner.RunCommand(name, args...) } func (sec *Secret) runCommand(name string, args ...string) CommandResult { - return sec.helm.runCommand(name, args...) + return sec.helm.runner.RunCommand(name, args...) } func (c *ConfigMap) runCommand(name string, args ...string) CommandResult { - return c.helm.runCommand(name, args...) + return c.helm.runner.RunCommand(name, args...) } func (p *PVC) runCommand(name string, args ...string) CommandResult { - return p.helm.runCommand(name, args...) + return p.helm.runner.RunCommand(name, args...) } func (p *Pod) resolvePodName() error { @@ -780,6 +664,7 @@ func (p *Pod) resolvePodName() error { type Namespace struct { name string colorOutput bool + runner *CommandRunner lastResult CommandResult lastError error } @@ -789,6 +674,7 @@ func NewNamespace(name string) *Namespace { return &Namespace{ name: name, colorOutput: true, + runner: NewCommandRunner(true), } } @@ -824,35 +710,5 @@ func (n *Namespace) MustSucceed() *Namespace { } func (n *Namespace) runCommand(name string, args ...string) CommandResult { - if n.colorOutput { - fmt.Printf("%s%s>>> Executing: %s %s%s\n", colorBlue, colorBold, name, strings.Join(args, " "), colorReset) - } - - 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() - } - - result := CommandResult{ - Stdout: stdout.String(), - Stderr: stderr.String(), - ExitCode: exitCode, - Err: err, - } - - if n.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) - } - } - - return result + 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