diff --git a/cmd/check.go b/cmd/check.go index 9e9c2ad43..1d985f82c 100644 --- a/cmd/check.go +++ b/cmd/check.go @@ -15,6 +15,7 @@ var ( nodeHealthTimeout time.Duration nodeHealthNodes []string nodeHealthVersion string + k8sEndpoint string ) var checkCmd = &cobra.Command{ @@ -59,9 +60,9 @@ var checkNodeHealthCmd = &cobra.Command{ // Get shared dependency injector from context injector := cmd.Context().Value(injectorKey).(di.Injector) - // Require nodes to be specified - if len(nodeHealthNodes) == 0 { - return fmt.Errorf("No nodes specified. Use --nodes flag to specify nodes to check") + // Require at least one health check type to be specified + if len(nodeHealthNodes) == 0 && k8sEndpoint == "" { + return fmt.Errorf("No health checks specified. Use --nodes and/or --k8s-endpoint flags to specify health checks to perform") } // If timeout is not set via flag, use default @@ -79,6 +80,8 @@ var checkNodeHealthCmd = &cobra.Command{ ctx = context.WithValue(ctx, "nodes", nodeHealthNodes) ctx = context.WithValue(ctx, "timeout", nodeHealthTimeout) ctx = context.WithValue(ctx, "version", nodeHealthVersion) + ctx = context.WithValue(ctx, "k8s-endpoint", k8sEndpoint) + ctx = context.WithValue(ctx, "k8s-endpoint-provided", k8sEndpoint != "") ctx = context.WithValue(ctx, "output", outputFunc) // Set up the check pipeline @@ -102,7 +105,8 @@ func init() { // Add flags for node health check checkNodeHealthCmd.Flags().DurationVar(&nodeHealthTimeout, "timeout", 0, "Maximum time to wait for nodes to be ready (default 5m)") - checkNodeHealthCmd.Flags().StringSliceVar(&nodeHealthNodes, "nodes", []string{}, "Nodes to check (required)") + checkNodeHealthCmd.Flags().StringSliceVar(&nodeHealthNodes, "nodes", []string{}, "Nodes to check (optional)") checkNodeHealthCmd.Flags().StringVar(&nodeHealthVersion, "version", "", "Expected version to check against (optional)") - _ = checkNodeHealthCmd.MarkFlagRequired("nodes") + checkNodeHealthCmd.Flags().StringVar(&k8sEndpoint, "k8s-endpoint", "", "Perform Kubernetes API health check (use --k8s-endpoint or --k8s-endpoint=https://endpoint:6443)") + checkNodeHealthCmd.Flags().Lookup("k8s-endpoint").NoOptDefVal = "true" } diff --git a/cmd/check_test.go b/cmd/check_test.go index 9c75fdd18..68438e169 100644 --- a/cmd/check_test.go +++ b/cmd/check_test.go @@ -246,10 +246,10 @@ func TestCheckNodeHealthCmd(t *testing.T) { t.Error("Expected error, got nil") } - // And error should contain nodes message - expectedError := "No nodes specified. Use --nodes flag to specify nodes to check" + // And error should contain health checks message + expectedError := "No health checks specified. Use --nodes and/or --k8s-endpoint flags to specify health checks to perform" if err.Error() != expectedError { - t.Errorf("Expected error about nodes, got: %v", err) + t.Errorf("Expected error about health checks, got: %v", err) } }) @@ -268,10 +268,10 @@ func TestCheckNodeHealthCmd(t *testing.T) { t.Error("Expected error, got nil") } - // And error should contain nodes message - expectedError := "No nodes specified. Use --nodes flag to specify nodes to check" + // And error should contain health checks message + expectedError := "No health checks specified. Use --nodes and/or --k8s-endpoint flags to specify health checks to perform" if err.Error() != expectedError { - t.Errorf("Expected error about nodes, got: %v", err) + t.Errorf("Expected error about health checks, got: %v", err) } }) } diff --git a/pkg/cluster/cluster_client.go b/pkg/cluster/cluster_client.go index 31b40d341..442cb5273 100644 --- a/pkg/cluster/cluster_client.go +++ b/pkg/cluster/cluster_client.go @@ -13,16 +13,6 @@ import ( "github.com/windsorcli/cli/pkg/constants" ) -// ============================================================================= -// Types -// ============================================================================= - -// HealthStatus represents the result of a health check. -type HealthStatus struct { - Healthy bool - Details string -} - // ClusterClient defines the interface for cluster operations type ClusterClient interface { // WaitForNodesHealthy waits for nodes to be healthy and optionally match a specific version diff --git a/pkg/cluster/cluster_client_test.go b/pkg/cluster/cluster_client_test.go index a41b28ac6..99187e8c0 100644 --- a/pkg/cluster/cluster_client_test.go +++ b/pkg/cluster/cluster_client_test.go @@ -19,13 +19,6 @@ type SetupOptions struct { // Add setup options as needed } -// setupMocks creates and configures mock objects for testing -func setupMocks(t *testing.T, opts ...*SetupOptions) *Mocks { - t.Helper() - - return &Mocks{} -} - // ============================================================================= // Test Constructor // ============================================================================= diff --git a/pkg/kubernetes/kubernetes_client.go b/pkg/kubernetes/kubernetes_client.go index 2eedbc3b3..cb243504e 100644 --- a/pkg/kubernetes/kubernetes_client.go +++ b/pkg/kubernetes/kubernetes_client.go @@ -6,6 +6,7 @@ package kubernetes import ( "context" + "fmt" "os" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -29,6 +30,7 @@ type KubernetesClient interface { ApplyResource(gvr schema.GroupVersionResource, obj *unstructured.Unstructured, opts metav1.ApplyOptions) (*unstructured.Unstructured, error) DeleteResource(gvr schema.GroupVersionResource, namespace, name string, opts metav1.DeleteOptions) error PatchResource(gvr schema.GroupVersionResource, namespace, name string, pt types.PatchType, data []byte, opts metav1.PatchOptions) (*unstructured.Unstructured, error) + CheckHealth(ctx context.Context, endpoint string) error } // ============================================================================= @@ -37,7 +39,8 @@ type KubernetesClient interface { // DynamicKubernetesClient implements KubernetesClient using dynamic client type DynamicKubernetesClient struct { - client dynamic.Interface + client dynamic.Interface + endpoint string } // NewDynamicKubernetesClient creates a new DynamicKubernetesClient @@ -89,33 +92,66 @@ func (c *DynamicKubernetesClient) PatchResource(gvr schema.GroupVersionResource, return c.client.Resource(gvr).Namespace(namespace).Patch(context.Background(), name, pt, data, opts) } +// CheckHealth verifies Kubernetes API connectivity by listing nodes using the dynamic client. +// If an endpoint is specified, it overrides the default kubeconfig for this check. +// Returns an error if the client cannot be initialized or the API is unreachable. +func (c *DynamicKubernetesClient) CheckHealth(ctx context.Context, endpoint string) error { + c.endpoint = endpoint + + if err := c.ensureClient(); err != nil { + return fmt.Errorf("failed to initialize Kubernetes client: %w", err) + } + + nodeGVR := schema.GroupVersionResource{ + Group: "", + Version: "v1", + Resource: "nodes", + } + + _, err := c.client.Resource(nodeGVR).List(ctx, metav1.ListOptions{Limit: 1}) + if err != nil { + return fmt.Errorf("failed to connect to Kubernetes API: %w", err) + } + + return nil +} + // ============================================================================= // Private Methods // ============================================================================= -// ensureClient lazily initializes the dynamic client if not already set. -// This is a Windsor CLI exception: see comment above. +// ensureClient initializes the dynamic Kubernetes client if unset. Uses endpoint, in-cluster, or kubeconfig as available. +// Returns error if client setup fails at any stage. func (c *DynamicKubernetesClient) ensureClient() error { if c.client != nil { return nil } - // Try in-cluster config first - config, err := rest.InClusterConfig() - if err != nil { - // Fallback to kubeconfig from KUBECONFIG or default location - kubeconfig := os.Getenv("KUBECONFIG") - if kubeconfig == "" { - home, err := os.UserHomeDir() + + var config *rest.Config + var err error + + if c.endpoint != "" { + config = &rest.Config{ + Host: c.endpoint, + } + } else { + config, err = rest.InClusterConfig() + if err != nil { + kubeconfig := os.Getenv("KUBECONFIG") + if kubeconfig == "" { + home, err := os.UserHomeDir() + if err != nil { + return err + } + kubeconfig = home + "/.kube/config" + } + config, err = clientcmd.BuildConfigFromFlags("", kubeconfig) if err != nil { return err } - kubeconfig = home + "/.kube/config" - } - config, err = clientcmd.BuildConfigFromFlags("", kubeconfig) - if err != nil { - return err } } + cli, err := dynamic.NewForConfig(config) if err != nil { return err diff --git a/pkg/kubernetes/kubernetes_manager.go b/pkg/kubernetes/kubernetes_manager.go index 6ba610905..ea9c3ccc7 100644 --- a/pkg/kubernetes/kubernetes_manager.go +++ b/pkg/kubernetes/kubernetes_manager.go @@ -5,7 +5,9 @@ package kubernetes import ( + "context" "fmt" + "maps" "os" "strings" "time" @@ -44,6 +46,7 @@ type KubernetesManager interface { WaitForKustomizationsDeleted(message string, names ...string) error CheckGitRepositoryStatus() error GetKustomizationStatus(names []string) (map[string]bool, error) + WaitForKubernetesHealthy(ctx context.Context, endpoint string) error } // ============================================================================= @@ -545,6 +548,36 @@ func (k *BaseKubernetesManager) GetKustomizationStatus(names []string) (map[stri return status, nil } +// WaitForKubernetesHealthy polls the Kubernetes API endpoint for health until it is reachable or the context deadline is exceeded. +// If the client is not initialized, returns an error. Uses a default timeout of 5 minutes if the context has no deadline. +// Polls every 10 seconds. Returns nil if the API becomes healthy, or an error if the timeout is reached or the context is canceled. +func (k *BaseKubernetesManager) WaitForKubernetesHealthy(ctx context.Context, endpoint string) error { + if k.client == nil { + return fmt.Errorf("kubernetes client not initialized") + } + + deadline, ok := ctx.Deadline() + if !ok { + deadline = time.Now().Add(5 * time.Minute) + } + + pollInterval := 10 * time.Second + + for time.Now().Before(deadline) { + select { + case <-ctx.Done(): + return fmt.Errorf("timeout waiting for Kubernetes API to be healthy") + default: + if err := k.client.CheckHealth(ctx, endpoint); err == nil { + return nil + } + time.Sleep(pollInterval) + } + } + + return fmt.Errorf("timeout waiting for Kubernetes API to be healthy") +} + // applyWithRetry applies a resource using SSA with minimal logic func (k *BaseKubernetesManager) applyWithRetry(gvr schema.GroupVersionResource, obj *unstructured.Unstructured, opts metav1.ApplyOptions) error { existing, err := k.client.GetResource(gvr, obj.GetNamespace(), obj.GetName()) @@ -554,9 +587,7 @@ func (k *BaseKubernetesManager) applyWithRetry(gvr schema.GroupVersionResource, return fmt.Errorf("failed to convert existing object to unstructured: %w", err) } - for k, v := range obj.Object { - applyConfig[k] = v - } + maps.Copy(applyConfig, obj.Object) mergedObj := &unstructured.Unstructured{Object: applyConfig} mergedObj.SetResourceVersion(existing.GetResourceVersion()) diff --git a/pkg/kubernetes/kubernetes_manager_test.go b/pkg/kubernetes/kubernetes_manager_test.go index eb5b481e1..de6050bb5 100644 --- a/pkg/kubernetes/kubernetes_manager_test.go +++ b/pkg/kubernetes/kubernetes_manager_test.go @@ -7,6 +7,8 @@ import ( "testing" "time" + "context" + kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1" sourcev1 "github.com/fluxcd/source-controller/api/v1" "github.com/windsorcli/cli/pkg/di" @@ -1951,3 +1953,88 @@ func TestBaseKubernetesManager_GetKustomizationStatus(t *testing.T) { } }) } + +func TestBaseKubernetesManager_WaitForKubernetesHealthy(t *testing.T) { + setup := func(t *testing.T) *BaseKubernetesManager { + t.Helper() + mocks := setupMocks(t) + manager := NewKubernetesManager(mocks.Injector) + if err := manager.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + return manager + } + + t.Run("Success", func(t *testing.T) { + manager := setup(t) + client := NewMockKubernetesClient() + client.CheckHealthFunc = func(ctx context.Context, endpoint string) error { + return nil + } + manager.client = client + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + err := manager.WaitForKubernetesHealthy(ctx, "https://test-endpoint:6443") + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + t.Run("ClientNotInitialized", func(t *testing.T) { + manager := setup(t) + manager.client = nil + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + err := manager.WaitForKubernetesHealthy(ctx, "https://test-endpoint:6443") + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "kubernetes client not initialized") { + t.Errorf("Expected client not initialized error, got: %v", err) + } + }) + + t.Run("Timeout", func(t *testing.T) { + manager := setup(t) + client := NewMockKubernetesClient() + client.CheckHealthFunc = func(ctx context.Context, endpoint string) error { + return fmt.Errorf("health check failed") + } + manager.client = client + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + err := manager.WaitForKubernetesHealthy(ctx, "https://test-endpoint:6443") + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "timeout waiting for Kubernetes API to be healthy") { + t.Errorf("Expected timeout error, got: %v", err) + } + }) + + t.Run("ContextCancelled", func(t *testing.T) { + manager := setup(t) + client := NewMockKubernetesClient() + client.CheckHealthFunc = func(ctx context.Context, endpoint string) error { + return fmt.Errorf("health check failed") + } + manager.client = client + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + err := manager.WaitForKubernetesHealthy(ctx, "https://test-endpoint:6443") + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "timeout waiting for Kubernetes API to be healthy") { + t.Errorf("Expected timeout error, got: %v", err) + } + }) +} diff --git a/pkg/kubernetes/mock_kubernetes_client.go b/pkg/kubernetes/mock_kubernetes_client.go index d732ea487..ccf80fe3b 100644 --- a/pkg/kubernetes/mock_kubernetes_client.go +++ b/pkg/kubernetes/mock_kubernetes_client.go @@ -5,6 +5,8 @@ package kubernetes import ( + "context" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" @@ -22,6 +24,7 @@ type MockKubernetesClient struct { ApplyResourceFunc func(gvr schema.GroupVersionResource, obj *unstructured.Unstructured, opts metav1.ApplyOptions) (*unstructured.Unstructured, error) DeleteResourceFunc func(gvr schema.GroupVersionResource, namespace, name string, opts metav1.DeleteOptions) error PatchResourceFunc func(gvr schema.GroupVersionResource, namespace, name string, pt types.PatchType, data []byte, opts metav1.PatchOptions) (*unstructured.Unstructured, error) + CheckHealthFunc func(ctx context.Context, endpoint string) error } // ============================================================================= @@ -76,3 +79,11 @@ func (m *MockKubernetesClient) PatchResource(gvr schema.GroupVersionResource, na } return nil, nil } + +// CheckHealth implements KubernetesClient interface +func (m *MockKubernetesClient) CheckHealth(ctx context.Context, endpoint string) error { + if m.CheckHealthFunc != nil { + return m.CheckHealthFunc(ctx, endpoint) + } + return nil +} diff --git a/pkg/kubernetes/mock_kubernetes_client_test.go b/pkg/kubernetes/mock_kubernetes_client_test.go index 1bfe61d1b..0fe4bcda8 100644 --- a/pkg/kubernetes/mock_kubernetes_client_test.go +++ b/pkg/kubernetes/mock_kubernetes_client_test.go @@ -1,6 +1,7 @@ package kubernetes import ( + "context" "fmt" "reflect" "testing" @@ -191,3 +192,38 @@ func TestMockKubernetesClient_PatchResource(t *testing.T) { } }) } + +func TestMockKubernetesClient_CheckHealth(t *testing.T) { + setup := func(t *testing.T) *MockKubernetesClient { + t.Helper() + return NewMockKubernetesClient() + } + ctx := context.Background() + endpoint := "https://kubernetes.example.com:6443" + + t.Run("FuncSet", func(t *testing.T) { + client := setup(t) + errVal := fmt.Errorf("health check failed") + client.CheckHealthFunc = func(c context.Context, e string) error { + if c != ctx { + t.Errorf("Expected context %v, got %v", ctx, c) + } + if e != endpoint { + t.Errorf("Expected endpoint %s, got %s", endpoint, e) + } + return errVal + } + err := client.CheckHealth(ctx, endpoint) + if err != errVal { + t.Errorf("Expected err, got %v", err) + } + }) + + t.Run("FuncNotSet", func(t *testing.T) { + client := setup(t) + err := client.CheckHealth(ctx, endpoint) + if err != nil { + t.Errorf("Expected nil, got %v", err) + } + }) +} diff --git a/pkg/kubernetes/mock_kubernetes_manager.go b/pkg/kubernetes/mock_kubernetes_manager.go index 1808b6a9d..d4497fbd4 100644 --- a/pkg/kubernetes/mock_kubernetes_manager.go +++ b/pkg/kubernetes/mock_kubernetes_manager.go @@ -5,6 +5,8 @@ package kubernetes import ( + "context" + helmv2 "github.com/fluxcd/helm-controller/api/v2" kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1" sourcev1 "github.com/fluxcd/source-controller/api/v1" @@ -32,6 +34,7 @@ type MockKubernetesManager struct { ApplyOCIRepositoryFunc func(repo *sourcev1.OCIRepository) error WaitForKustomizationsDeletedFunc func(message string, names ...string) error CheckGitRepositoryStatusFunc func() error + WaitForKubernetesHealthyFunc func(ctx context.Context, endpoint string) error } // ============================================================================= @@ -166,3 +169,11 @@ func (m *MockKubernetesManager) CheckGitRepositoryStatus() error { } return nil } + +// WaitForKubernetesHealthy waits for the Kubernetes API endpoint to be healthy with polling and timeout +func (m *MockKubernetesManager) WaitForKubernetesHealthy(ctx context.Context, endpoint string) error { + if m.WaitForKubernetesHealthyFunc != nil { + return m.WaitForKubernetesHealthyFunc(ctx, endpoint) + } + return nil +} diff --git a/pkg/kubernetes/mock_kubernetes_manager_test.go b/pkg/kubernetes/mock_kubernetes_manager_test.go index 5e1993546..8c5e92139 100644 --- a/pkg/kubernetes/mock_kubernetes_manager_test.go +++ b/pkg/kubernetes/mock_kubernetes_manager_test.go @@ -5,6 +5,7 @@ package kubernetes import ( + "context" "fmt" "reflect" "testing" @@ -382,3 +383,63 @@ func TestMockKubernetesManager_CheckGitRepositoryStatus(t *testing.T) { } }) } + +func TestMockKubernetesManager_ApplyOCIRepository(t *testing.T) { + setup := func(t *testing.T) *MockKubernetesManager { + t.Helper() + return NewMockKubernetesManager(nil) + } + repo := &sourcev1.OCIRepository{} + + t.Run("FuncSet", func(t *testing.T) { + manager := setup(t) + manager.ApplyOCIRepositoryFunc = func(r *sourcev1.OCIRepository) error { return fmt.Errorf("err") } + err := manager.ApplyOCIRepository(repo) + if err == nil || err.Error() != "err" { + t.Errorf("Expected error 'err', got %v", err) + } + }) + + t.Run("FuncNotSet", func(t *testing.T) { + manager := setup(t) + err := manager.ApplyOCIRepository(repo) + if err != nil { + t.Errorf("Expected nil, got %v", err) + } + }) +} + +func TestMockKubernetesManager_WaitForKubernetesHealthy(t *testing.T) { + setup := func(t *testing.T) *MockKubernetesManager { + t.Helper() + return NewMockKubernetesManager(nil) + } + ctx := context.Background() + endpoint := "https://kubernetes.example.com:6443" + + t.Run("FuncSet", func(t *testing.T) { + manager := setup(t) + errVal := fmt.Errorf("kubernetes health check failed") + manager.WaitForKubernetesHealthyFunc = func(c context.Context, e string) error { + if c != ctx { + t.Errorf("Expected context %v, got %v", ctx, c) + } + if e != endpoint { + t.Errorf("Expected endpoint %s, got %s", endpoint, e) + } + return errVal + } + err := manager.WaitForKubernetesHealthy(ctx, endpoint) + if err != errVal { + t.Errorf("Expected err, got %v", err) + } + }) + + t.Run("FuncNotSet", func(t *testing.T) { + manager := setup(t) + err := manager.WaitForKubernetesHealthy(ctx, endpoint) + if err != nil { + t.Errorf("Expected nil, got %v", err) + } + }) +} diff --git a/pkg/pipelines/check.go b/pkg/pipelines/check.go index 979378b29..d5bc204f7 100644 --- a/pkg/pipelines/check.go +++ b/pkg/pipelines/check.go @@ -7,6 +7,7 @@ import ( "github.com/windsorcli/cli/pkg/cluster" "github.com/windsorcli/cli/pkg/di" + "github.com/windsorcli/cli/pkg/kubernetes" "github.com/windsorcli/cli/pkg/tools" ) @@ -19,12 +20,13 @@ import ( // Types // ============================================================================= -// CheckPipeline provides tool checking and node health checking functionality +// CheckPipeline implements health checking functionality for tools and cluster nodes type CheckPipeline struct { BasePipeline - toolsManager tools.ToolsManager - clusterClient cluster.ClusterClient + toolsManager tools.ToolsManager + clusterClient cluster.ClusterClient + kubernetesManager kubernetes.KubernetesManager } // ============================================================================= @@ -42,9 +44,7 @@ func NewCheckPipeline() *CheckPipeline { // Public Methods // ============================================================================= -// Initialize creates and registers the required components for the check pipeline including -// tools manager and cluster client dependencies. It validates component initialization -// and ensures proper setup for both tool checking and node health monitoring operations. +// Initialize sets up the CheckPipeline by resolving dependencies func (p *CheckPipeline) Initialize(injector di.Injector, ctx context.Context) error { if err := p.BasePipeline.Initialize(injector, ctx); err != nil { return err @@ -52,6 +52,9 @@ func (p *CheckPipeline) Initialize(injector di.Injector, ctx context.Context) er p.toolsManager = p.withToolsManager() p.clusterClient = p.withClusterClient() + p.withKubernetesClient() + + p.kubernetesManager = p.withKubernetesManager() if p.toolsManager != nil { if err := p.toolsManager.Initialize(); err != nil { @@ -59,6 +62,12 @@ func (p *CheckPipeline) Initialize(injector di.Injector, ctx context.Context) er } } + if p.kubernetesManager != nil { + if err := p.kubernetesManager.Initialize(); err != nil { + return fmt.Errorf("failed to initialize kubernetes manager: %w", err) + } + } + return nil } @@ -113,65 +122,126 @@ func (p *CheckPipeline) executeToolsCheck(ctx context.Context) error { } // executeNodeHealthCheck performs cluster node health checking using the cluster client. -// It validates node health status and optionally checks for specific versions, -// requiring nodes to be specified via context parameters and supporting timeout configuration. +// It validates node health status and optionally checks for specific versions. +// Nodes must be specified via context parameters. Supports timeout configuration. +// If the Kubernetes endpoint flag is provided, performs Kubernetes API health check. +// Outputs status via the output function in context if present. func (p *CheckPipeline) executeNodeHealthCheck(ctx context.Context) error { - if p.clusterClient == nil { - return fmt.Errorf("No cluster client found") - } - defer p.clusterClient.Close() - nodes := ctx.Value("nodes") - if nodes == nil { - return fmt.Errorf("No nodes specified. Use --nodes flag to specify nodes to check") + k8sEndpointProvided := ctx.Value("k8s-endpoint-provided") + + var hasNodeCheck bool + var hasK8sCheck bool + + if nodes != nil { + if nodeAddresses, ok := nodes.([]string); ok && len(nodeAddresses) > 0 { + hasNodeCheck = true + } } - nodeAddresses, ok := nodes.([]string) - if !ok { - return fmt.Errorf("Invalid nodes parameter type") + if k8sEndpointProvided != nil { + if provided, ok := k8sEndpointProvided.(bool); ok && provided { + hasK8sCheck = true + } } - if len(nodeAddresses) == 0 { - return fmt.Errorf("No nodes specified. Use --nodes flag to specify nodes to check") + if !hasNodeCheck && !hasK8sCheck { + return fmt.Errorf("No health checks specified. Use --nodes and/or --k8s-endpoint flags to specify health checks to perform") } - timeout := ctx.Value("timeout") - var timeoutDuration time.Duration - if timeout != nil { - if t, ok := timeout.(time.Duration); ok { - timeoutDuration = t + if hasNodeCheck { + if p.clusterClient == nil { + return fmt.Errorf("No cluster client found") } - } + defer p.clusterClient.Close() - version := ctx.Value("version") - var expectedVersion string - if version != nil { - if v, ok := version.(string); ok { - expectedVersion = v + nodeAddresses, ok := nodes.([]string) + if !ok { + return fmt.Errorf("Invalid nodes parameter type") } - } - var checkCtx context.Context - var cancel context.CancelFunc - if timeoutDuration > 0 { - checkCtx, cancel = context.WithTimeout(ctx, timeoutDuration) - } else { - checkCtx, cancel = context.WithCancel(ctx) - } - defer cancel() + timeout := ctx.Value("timeout") + var timeoutDuration time.Duration + if timeout != nil { + if t, ok := timeout.(time.Duration); ok { + timeoutDuration = t + } + } - if err := p.clusterClient.WaitForNodesHealthy(checkCtx, nodeAddresses, expectedVersion); err != nil { - return fmt.Errorf("nodes failed health check: %w", err) - } + version := ctx.Value("version") + var expectedVersion string + if version != nil { + if v, ok := version.(string); ok { + expectedVersion = v + } + } - outputFunc := ctx.Value("output") - if outputFunc != nil { - if fn, ok := outputFunc.(func(string)); ok { - message := fmt.Sprintf("All %d nodes are healthy", len(nodeAddresses)) - if expectedVersion != "" { - message += fmt.Sprintf(" and running version %s", expectedVersion) + k8sEndpoint := ctx.Value("k8s-endpoint") + k8sEndpointProvided := ctx.Value("k8s-endpoint-provided") + + var k8sEndpointStr string + var shouldCheckK8s bool + + if k8sEndpoint != nil { + if e, ok := k8sEndpoint.(string); ok { + if e == "true" { + k8sEndpointStr = "" + } else { + k8sEndpointStr = e + } + } + } + + if k8sEndpointProvided != nil { + if provided, ok := k8sEndpointProvided.(bool); ok { + shouldCheckK8s = provided + } + } + + // Perform node health checks first + var checkCtx context.Context + var cancel context.CancelFunc + if timeoutDuration > 0 { + checkCtx, cancel = context.WithTimeout(ctx, timeoutDuration) + } else { + checkCtx, cancel = context.WithCancel(ctx) + } + defer cancel() + + if err := p.clusterClient.WaitForNodesHealthy(checkCtx, nodeAddresses, expectedVersion); err != nil { + return fmt.Errorf("nodes failed health check: %w", err) + } + + outputFunc := ctx.Value("output") + if outputFunc != nil { + if fn, ok := outputFunc.(func(string)); ok { + message := fmt.Sprintf("All %d nodes are healthy", len(nodeAddresses)) + if expectedVersion != "" { + message += fmt.Sprintf(" and running version %s", expectedVersion) + } + fn(message) + } + } + + if shouldCheckK8s { + if p.kubernetesManager == nil { + return fmt.Errorf("No kubernetes manager found") + } + + if err := p.kubernetesManager.WaitForKubernetesHealthy(ctx, k8sEndpointStr); err != nil { + return fmt.Errorf("Kubernetes health check failed: %w", err) + } + + outputFunc := ctx.Value("output") + if outputFunc != nil { + if fn, ok := outputFunc.(func(string)); ok { + if k8sEndpointStr != "" { + fn(fmt.Sprintf("Kubernetes API endpoint %s is healthy", k8sEndpointStr)) + } else { + fn("Kubernetes API endpoint (kubeconfig default) is healthy") + } + } } - fn(message) } } diff --git a/pkg/pipelines/check_test.go b/pkg/pipelines/check_test.go index 8e039b46e..f8de8e9b4 100644 --- a/pkg/pipelines/check_test.go +++ b/pkg/pipelines/check_test.go @@ -11,6 +11,7 @@ import ( "github.com/windsorcli/cli/pkg/cluster" "github.com/windsorcli/cli/pkg/config" "github.com/windsorcli/cli/pkg/di" + "github.com/windsorcli/cli/pkg/kubernetes" "github.com/windsorcli/cli/pkg/shell" "github.com/windsorcli/cli/pkg/tools" ) @@ -32,14 +33,9 @@ func (m *mockFileInfo) Sys() interface{} { return nil } // CheckMocks extends the base Mocks with check-specific dependencies type CheckMocks struct { *Mocks - ToolsManager *tools.MockToolsManager - ClusterClient *cluster.MockClusterClient -} - -// setupCheckShims creates shims for check pipeline tests -func setupCheckShims(t *testing.T) *Shims { - t.Helper() - return setupShims(t) + ToolsManager *tools.MockToolsManager + ClusterClient *cluster.MockClusterClient + KubernetesManager *kubernetes.MockKubernetesManager } // setupCheckMocks creates mocks for check pipeline tests @@ -88,23 +84,43 @@ func setupCheckMocks(t *testing.T, opts ...*SetupOptions) *CheckMocks { } else { // If existing is not a MockClusterClient, create a new one mockClusterClient = cluster.NewMockClusterClient() - mockClusterClient.WaitForNodesHealthyFunc = func(ctx context.Context, nodes []string, version string) error { + mockClusterClient.WaitForNodesHealthyFunc = func(ctx context.Context, nodeAddresses []string, expectedVersion string) error { return nil } baseMocks.Injector.Register("clusterClient", mockClusterClient) } } else { mockClusterClient = cluster.NewMockClusterClient() - mockClusterClient.WaitForNodesHealthyFunc = func(ctx context.Context, nodes []string, version string) error { + mockClusterClient.WaitForNodesHealthyFunc = func(ctx context.Context, nodeAddresses []string, expectedVersion string) error { return nil } baseMocks.Injector.Register("clusterClient", mockClusterClient) } + // Create kubernetes manager mock + var mockKubernetesManager *kubernetes.MockKubernetesManager + if existing := baseMocks.Injector.Resolve("kubernetesManager"); existing != nil { + if km, ok := existing.(*kubernetes.MockKubernetesManager); ok { + mockKubernetesManager = km + } else { + // If existing is not a MockKubernetesManager, create a new one + mockKubernetesManager = kubernetes.NewMockKubernetesManager(baseMocks.Injector) + mockKubernetesManager.InitializeFunc = func() error { return nil } + mockKubernetesManager.WaitForKubernetesHealthyFunc = func(ctx context.Context, endpoint string) error { return nil } + baseMocks.Injector.Register("kubernetesManager", mockKubernetesManager) + } + } else { + mockKubernetesManager = kubernetes.NewMockKubernetesManager(baseMocks.Injector) + mockKubernetesManager.InitializeFunc = func() error { return nil } + mockKubernetesManager.WaitForKubernetesHealthyFunc = func(ctx context.Context, endpoint string) error { return nil } + baseMocks.Injector.Register("kubernetesManager", mockKubernetesManager) + } + return &CheckMocks{ - Mocks: baseMocks, - ToolsManager: mockToolsManager, - ClusterClient: mockClusterClient, + Mocks: baseMocks, + ToolsManager: mockToolsManager, + ClusterClient: mockClusterClient, + KubernetesManager: mockKubernetesManager, } } @@ -205,7 +221,8 @@ func TestCheckPipeline_Initialize(t *testing.T) { t.Run("ReturnsErrorWhenToolsManagerInitializeFails", func(t *testing.T) { // Given a check pipeline with failing tools manager initialization - pipeline, mocks := setup(t) + pipeline := NewCheckPipeline() + mocks := setupCheckMocks(t) mocks.ToolsManager.InitializeFunc = func() error { return fmt.Errorf("tools manager initialization failed") @@ -365,7 +382,7 @@ func TestCheckPipeline_Execute(t *testing.T) { pipeline, mocks := setup(t) waitCalled := false - mocks.ClusterClient.WaitForNodesHealthyFunc = func(ctx context.Context, nodes []string, version string) error { + mocks.ClusterClient.WaitForNodesHealthyFunc = func(ctx context.Context, nodeAddresses []string, expectedVersion string) error { waitCalled = true return nil } @@ -392,8 +409,8 @@ func TestCheckPipeline_Execute(t *testing.T) { pipeline, mocks := setup(t) var capturedVersion string - mocks.ClusterClient.WaitForNodesHealthyFunc = func(ctx context.Context, nodes []string, version string) error { - capturedVersion = version + mocks.ClusterClient.WaitForNodesHealthyFunc = func(ctx context.Context, nodeAddresses []string, expectedVersion string) error { + capturedVersion = expectedVersion return nil } @@ -498,7 +515,7 @@ func TestCheckPipeline_Execute(t *testing.T) { // Given a check pipeline with failing node health check pipeline, mocks := setup(t) - mocks.ClusterClient.WaitForNodesHealthyFunc = func(ctx context.Context, nodes []string, version string) error { + mocks.ClusterClient.WaitForNodesHealthyFunc = func(ctx context.Context, nodeAddresses []string, expectedVersion string) error { return fmt.Errorf("node health check failed") } @@ -619,7 +636,7 @@ func TestCheckPipeline_executeNodeHealthCheck(t *testing.T) { pipeline, mocks := setup(t) waitCalled := false - mocks.ClusterClient.WaitForNodesHealthyFunc = func(ctx context.Context, nodes []string, version string) error { + mocks.ClusterClient.WaitForNodesHealthyFunc = func(ctx context.Context, nodeAddresses []string, expectedVersion string) error { waitCalled = true return nil } @@ -645,8 +662,8 @@ func TestCheckPipeline_executeNodeHealthCheck(t *testing.T) { pipeline, mocks := setup(t) var capturedVersion string - mocks.ClusterClient.WaitForNodesHealthyFunc = func(ctx context.Context, nodes []string, version string) error { - capturedVersion = version + mocks.ClusterClient.WaitForNodesHealthyFunc = func(ctx context.Context, nodeAddresses []string, expectedVersion string) error { + capturedVersion = expectedVersion return nil } @@ -702,8 +719,8 @@ func TestCheckPipeline_executeNodeHealthCheck(t *testing.T) { } }) - t.Run("ReturnsErrorWhenNoNodesSpecified", func(t *testing.T) { - // Given a check pipeline with no nodes specified + t.Run("ReturnsErrorWhenNoHealthChecksSpecified", func(t *testing.T) { + // Given a check pipeline with no health checks specified pipeline, _ := setup(t) // When executing node health check @@ -713,8 +730,8 @@ func TestCheckPipeline_executeNodeHealthCheck(t *testing.T) { if err == nil { t.Fatal("Expected error, got nil") } - if err.Error() != "No nodes specified. Use --nodes flag to specify nodes to check" { - t.Errorf("Expected nodes required error, got: %v", err) + if err.Error() != "No health checks specified. Use --nodes and/or --k8s-endpoint flags to specify health checks to perform" { + t.Errorf("Expected health checks required error, got: %v", err) } }) @@ -731,26 +748,29 @@ func TestCheckPipeline_executeNodeHealthCheck(t *testing.T) { if err == nil { t.Fatal("Expected error, got nil") } - if err.Error() != "Invalid nodes parameter type" { - t.Errorf("Expected nodes type error, got: %v", err) + if err.Error() != "No health checks specified. Use --nodes and/or --k8s-endpoint flags to specify health checks to perform" { + t.Errorf("Expected health checks required error, got: %v", err) } }) - t.Run("ReturnsErrorWhenNodesSliceIsEmpty", func(t *testing.T) { - // Given a check pipeline with empty nodes slice - pipeline, _ := setup(t) + t.Run("SucceedsWhenOnlyK8sEndpointSpecified", func(t *testing.T) { + // Given a check pipeline with only k8s endpoint specified + pipeline, mocks := setup(t) + + mocks.KubernetesManager.WaitForKubernetesHealthyFunc = func(ctx context.Context, endpoint string) error { + return nil + } - ctx := context.WithValue(context.Background(), "nodes", []string{}) + ctx := context.WithValue(context.Background(), "k8s-endpoint-provided", true) + ctx = context.WithValue(ctx, "k8s-endpoint", "") + ctx = context.WithValue(ctx, "output", func(msg string) {}) // When executing node health check err := pipeline.executeNodeHealthCheck(ctx) - // Then an error should be returned - if err == nil { - t.Fatal("Expected error, got nil") - } - if err.Error() != "No nodes specified. Use --nodes flag to specify nodes to check" { - t.Errorf("Expected nodes empty error, got: %v", err) + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got %v", err) } }) @@ -758,7 +778,7 @@ func TestCheckPipeline_executeNodeHealthCheck(t *testing.T) { // Given a check pipeline with failing cluster client pipeline, mocks := setup(t) - mocks.ClusterClient.WaitForNodesHealthyFunc = func(ctx context.Context, nodes []string, version string) error { + mocks.ClusterClient.WaitForNodesHealthyFunc = func(ctx context.Context, nodeAddresses []string, expectedVersion string) error { return fmt.Errorf("node health check failed") } diff --git a/pkg/pipelines/pipeline_test.go b/pkg/pipelines/pipeline_test.go index 6bdad3b9b..31d293541 100644 --- a/pkg/pipelines/pipeline_test.go +++ b/pkg/pipelines/pipeline_test.go @@ -14,11 +14,13 @@ import ( secretsConfigType "github.com/windsorcli/cli/api/v1alpha1/secrets" bundler "github.com/windsorcli/cli/pkg/artifact" "github.com/windsorcli/cli/pkg/blueprint" + "github.com/windsorcli/cli/pkg/cluster" "github.com/windsorcli/cli/pkg/config" "github.com/windsorcli/cli/pkg/di" "github.com/windsorcli/cli/pkg/kubernetes" "github.com/windsorcli/cli/pkg/shell" "github.com/windsorcli/cli/pkg/stack" + "github.com/windsorcli/cli/pkg/tools" "github.com/windsorcli/cli/pkg/virt" ) @@ -503,16 +505,20 @@ func TestWithPipeline(t *testing.T) { {"ContextPipeline", "contextPipeline"}, {"HookPipeline", "hookPipeline"}, {"CheckPipeline", "checkPipeline"}, + {"UpPipeline", "upPipeline"}, + {"DownPipeline", "downPipeline"}, + {"InstallPipeline", "installPipeline"}, + {"ArtifactPipeline", "artifactPipeline"}, + {"BasePipeline", "basePipeline"}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - // Given an injector and context - injector := di.NewInjector() - ctx := context.Background() + // Given an injector with proper mocks + mocks := setupMocks(t) - // When creating a pipeline with WithPipeline - pipeline, err := WithPipeline(injector, ctx, tc.pipelineType) + // When creating a pipeline with NewPipeline (without initialization) + pipeline, err := WithPipeline(mocks.Injector, context.Background(), tc.pipelineType) // Then no error should be returned if err != nil { @@ -525,34 +531,14 @@ func TestWithPipeline(t *testing.T) { } }) } - - // Special test for upPipeline which requires more complex setup - t.Run("UpPipeline", func(t *testing.T) { - // Given an injector with proper mocks for up pipeline - mocks := setupMocks(t) - - // When creating the up pipeline - pipeline, err := WithPipeline(mocks.Injector, context.Background(), "upPipeline") - - // Then no error should be returned - if err != nil { - t.Errorf("Expected no error, got: %v", err) - } - - // And a pipeline should be returned - if pipeline == nil { - t.Error("Expected non-nil pipeline") - } - }) }) t.Run("UnknownPipelineType", func(t *testing.T) { - // Given an injector and context - injector := di.NewInjector() - ctx := context.Background() + // Given an injector with proper mocks + mocks := setupMocks(t) // When creating a pipeline with unknown type - pipeline, err := WithPipeline(injector, ctx, "unknownPipeline") + pipeline, err := WithPipeline(mocks.Injector, context.Background(), "unknownPipeline") // Then an error should be returned if err == nil { @@ -568,6 +554,30 @@ func TestWithPipeline(t *testing.T) { } }) + t.Run("WithPipelineInitialization", func(t *testing.T) { + // Given an injector with proper mocks + mocks := setupMocks(t) + + // When creating a pipeline with WithPipeline + pipeline, err := WithPipeline(mocks.Injector, context.Background(), "envPipeline") + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + + // And a pipeline should be returned + if pipeline == nil { + t.Error("Expected non-nil pipeline") + } + + // And the pipeline should be registered in the injector + registered := mocks.Injector.Resolve("envPipeline") + if registered == nil { + t.Error("Expected pipeline to be registered in injector") + } + }) + t.Run("ExistingPipelineInInjector", func(t *testing.T) { // Given an injector with existing pipeline injector := di.NewInjector() @@ -704,6 +714,28 @@ func TestWithPipeline(t *testing.T) { injector := di.NewInjector() ctx := context.Background() + // Set up required dependencies for check pipeline + if pipelineType == "checkPipeline" { + // Set up tools manager + mockToolsManager := tools.NewMockToolsManager() + mockToolsManager.InitializeFunc = func() error { return nil } + mockToolsManager.CheckFunc = func() error { return nil } + injector.Register("toolsManager", mockToolsManager) + + // Set up cluster client + mockClusterClient := cluster.NewMockClusterClient() + mockClusterClient.WaitForNodesHealthyFunc = func(ctx context.Context, nodeAddresses []string, expectedVersion string) error { + return nil + } + injector.Register("clusterClient", mockClusterClient) + + // Set up kubernetes manager + mockKubernetesManager := kubernetes.NewMockKubernetesManager(injector) + mockKubernetesManager.InitializeFunc = func() error { return nil } + mockKubernetesManager.WaitForKubernetesHealthyFunc = func(ctx context.Context, endpoint string) error { return nil } + injector.Register("kubernetesManager", mockKubernetesManager) + } + // When creating the pipeline pipeline, err := WithPipeline(injector, ctx, pipelineType)