From b17c13a16917534b042b76b137911b4e7e096059 Mon Sep 17 00:00:00 2001 From: Ryan VanGundy Date: Thu, 19 Jun 2025 16:37:08 -0400 Subject: [PATCH 1/2] feat: add node health check subcommand with Talos cluster support Add `windsor check node-health` subcommand to verify cluster node health status. - Implement ClusterClient interface with base and Talos-specific implementations - Add node health checking with configurable timeout and version validation - Support multiple node addresses and custom timeout via CLI flags - Integrate cluster client resolution into controller dependency injection - Add Talos gRPC client with proper resource management and connection handling - Update existing commands to use 'Cluster' requirement instead of 'Kubernetes' The subcommand requires --nodes flag and optionally accepts --timeout and --version for comprehensive cluster health validation. --- cmd/check.go | 80 ++- cmd/check_test.go | 740 ++++++++++++++++++----- cmd/down.go | 2 +- cmd/init.go | 2 +- cmd/install.go | 2 +- cmd/up.go | 2 +- go.mod | 19 + go.sum | 96 ++- pkg/cluster/cluster_client.go | 68 +++ pkg/cluster/cluster_client_test.go | 133 ++++ pkg/cluster/mock_cluster_client.go | 52 ++ pkg/cluster/mock_cluster_client_test.go | 71 +++ pkg/cluster/shims.go | 54 ++ pkg/cluster/talos_cluster_client.go | 264 ++++++++ pkg/cluster/talos_cluster_client_test.go | 653 ++++++++++++++++++++ pkg/constants/constants.go | 7 + pkg/controller/controller.go | 58 +- pkg/controller/controller_test.go | 28 +- pkg/controller/mock_controller.go | 15 + 19 files changed, 2172 insertions(+), 174 deletions(-) create mode 100644 pkg/cluster/cluster_client.go create mode 100644 pkg/cluster/cluster_client_test.go create mode 100644 pkg/cluster/mock_cluster_client.go create mode 100644 pkg/cluster/mock_cluster_client_test.go create mode 100644 pkg/cluster/shims.go create mode 100644 pkg/cluster/talos_cluster_client.go create mode 100644 pkg/cluster/talos_cluster_client_test.go diff --git a/cmd/check.go b/cmd/check.go index c36b68eae..f78732135 100644 --- a/cmd/check.go +++ b/cmd/check.go @@ -1,12 +1,21 @@ package cmd import ( + "context" "fmt" + "time" "github.com/spf13/cobra" + "github.com/windsorcli/cli/pkg/constants" ctrl "github.com/windsorcli/cli/pkg/controller" ) +var ( + nodeHealthTimeout time.Duration + nodeHealthNodes []string + nodeHealthVersion string +) + var checkCmd = &cobra.Command{ Use: "check", Short: "Check the tool versions", @@ -32,7 +41,7 @@ var checkCmd = &cobra.Command{ return fmt.Errorf("Nothing to check. Have you run \033[1mwindsor init\033[0m?") } - // Resolve the tools manager and check the tools + // Check tools toolsManager := controller.ResolveToolsManager() if toolsManager == nil { return fmt.Errorf("No tools manager found") @@ -45,6 +54,75 @@ var checkCmd = &cobra.Command{ }, } +var checkNodeHealthCmd = &cobra.Command{ + Use: "node-health", + Short: "Check the health of cluster nodes", + Long: "Check the health status of specified cluster nodes", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + controller := cmd.Context().Value(controllerKey).(ctrl.Controller) + + if err := controller.InitializeWithRequirements(ctrl.Requirements{ + ConfigLoaded: true, + Cluster: true, + CommandName: cmd.Name(), + Flags: map[string]bool{ + "verbose": cmd.Flags().Changed("verbose"), + }, + }); err != nil { + return fmt.Errorf("Error initializing: %w", err) + } + + // Check if projectName is set in the configuration + configHandler := controller.ResolveConfigHandler() + if !configHandler.IsLoaded() { + return fmt.Errorf("Nothing to check. Have you run \033[1mwindsor init\033[0m?") + } + + // Get the cluster client + clusterClient := controller.ResolveClusterClient() + if clusterClient == nil { + return fmt.Errorf("No cluster client found") + } + defer clusterClient.Close() + + // Require nodes to be specified + if len(nodeHealthNodes) == 0 { + return fmt.Errorf("No nodes specified. Use --nodes flag to specify nodes to check") + } + + // If timeout is not set via flag, use default + if !cmd.Flags().Changed("timeout") { + nodeHealthTimeout = constants.DEFAULT_NODE_HEALTH_CHECK_TIMEOUT + } + + // Create context with timeout + ctx, cancel := context.WithTimeout(cmd.Context(), nodeHealthTimeout) + defer cancel() + + // Wait for nodes to be healthy (and correct version if specified) + if err := clusterClient.WaitForNodesHealthy(ctx, nodeHealthNodes, nodeHealthVersion); err != nil { + return fmt.Errorf("nodes failed health check: %w", err) + } + + // Success message + fmt.Fprintf(cmd.OutOrStdout(), "All %d nodes are healthy", len(nodeHealthNodes)) + if nodeHealthVersion != "" { + fmt.Fprintf(cmd.OutOrStdout(), " and running version %s", nodeHealthVersion) + } + fmt.Fprintln(cmd.OutOrStdout()) + + return nil + }, +} + func init() { rootCmd.AddCommand(checkCmd) + checkCmd.AddCommand(checkNodeHealthCmd) + + // 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().StringVar(&nodeHealthVersion, "version", "", "Expected version to check against (optional)") + checkNodeHealthCmd.MarkFlagRequired("nodes") } diff --git a/cmd/check_test.go b/cmd/check_test.go index 79ea9a141..4aca1464d 100644 --- a/cmd/check_test.go +++ b/cmd/check_test.go @@ -1,170 +1,626 @@ package cmd import ( - "bytes" - "fmt" - "testing" + "bytes" + "context" + "fmt" + "testing" + "time" - "github.com/windsorcli/cli/pkg/controller" - "github.com/windsorcli/cli/pkg/tools" + "github.com/windsorcli/cli/pkg/cluster" + "github.com/windsorcli/cli/pkg/controller" + "github.com/windsorcli/cli/pkg/tools" ) func TestCheckCmd(t *testing.T) { - setup := func(t *testing.T, opts ...*SetupOptions) (*Mocks, *bytes.Buffer, *bytes.Buffer) { - t.Helper() + setup := func(t *testing.T, opts ...*SetupOptions) (*Mocks, *bytes.Buffer, *bytes.Buffer) { + t.Helper() - // Setup mocks with default options - mocks := setupMocks(t, opts...) + // Setup mocks with default options + mocks := setupMocks(t, opts...) - // Setup command args and output - rootCmd.SetArgs([]string{"check"}) - stdout, stderr := captureOutput(t) - rootCmd.SetOut(stdout) - rootCmd.SetErr(stderr) + // Setup command args and output + rootCmd.SetArgs([]string{"check"}) + stdout, stderr := captureOutput(t) + rootCmd.SetOut(stdout) + rootCmd.SetErr(stderr) - return mocks, stdout, stderr - } + return mocks, stdout, stderr + } - t.Run("Success", func(t *testing.T) { - // Given a set of mocks with proper configuration - mocks, stdout, stderr := setup(t, &SetupOptions{ - ConfigStr: ` + t.Run("Success", func(t *testing.T) { + // Given a set of mocks with proper configuration + mocks, stdout, stderr := setup(t, &SetupOptions{ + ConfigStr: ` contexts: default: tools: enabled: true`, - }) - - // And mock tools manager that returns success - mockToolsManager := tools.NewMockToolsManager() - mockToolsManager.CheckFunc = func() error { - return nil - } - mocks.Injector.Register("toolsManager", mockToolsManager) - - // When executing the command - err := Execute(mocks.Controller) - - // Then no error should occur - if err != nil { - t.Errorf("Expected success, got error: %v", err) - } - - // And output should contain success message - output := stdout.String() - if output != "All tools are up to date.\n" { - t.Errorf("Expected 'All tools are up to date.', got: %q", output) - } - if stderr.String() != "" { - t.Error("Expected empty stderr") - } - }) - - t.Run("ConfigNotLoaded", func(t *testing.T) { - // Given a set of mocks with no configuration - mocks, _, _ := setup(t) - - // When executing the command - err := Execute(mocks.Controller) - - // Then an error should occur - if err == nil { - t.Error("Expected error, got nil") - } - - // And error should contain init message - expectedError := "Nothing to check. Have you run \033[1mwindsor init\033[0m?" - if err.Error() != expectedError { - t.Errorf("Expected error about init, got: %v", err) - } - }) - - t.Run("ToolsManagerNotFound", func(t *testing.T) { - // Given a set of mocks with proper configuration - mocks, _, _ := setup(t, &SetupOptions{ - ConfigStr: ` + }) + + // And mock tools manager that returns success + mockToolsManager := tools.NewMockToolsManager() + mockToolsManager.CheckFunc = func() error { + return nil + } + mocks.Injector.Register("toolsManager", mockToolsManager) + + // When executing the command + err := Execute(mocks.Controller) + + // Then no error should occur + if err != nil { + t.Errorf("Expected success, got error: %v", err) + } + + // And output should contain success message + output := stdout.String() + if output != "All tools are up to date.\n" { + t.Errorf("Expected 'All tools are up to date.', got: %q", output) + } + if stderr.String() != "" { + t.Error("Expected empty stderr") + } + }) + + t.Run("ConfigNotLoaded", func(t *testing.T) { + // Given a set of mocks with no configuration + mocks, _, _ := setup(t) + + // When executing the command + err := Execute(mocks.Controller) + + // Then an error should occur + if err == nil { + t.Error("Expected error, got nil") + } + + // And error should contain init message + expectedError := "Nothing to check. Have you run \033[1mwindsor init\033[0m?" + if err.Error() != expectedError { + t.Errorf("Expected error about init, got: %v", err) + } + }) + + t.Run("ToolsManagerNotFound", func(t *testing.T) { + // Given a set of mocks with proper configuration + mocks, _, _ := setup(t, &SetupOptions{ + ConfigStr: ` contexts: default: tools: enabled: true`, - }) - - // And mock controller that returns nil tools manager - mocks.Controller.ResolveToolsManagerFunc = func() tools.ToolsManager { - return nil - } - - // When executing the command - err := Execute(mocks.Controller) - - // Then an error should occur - if err == nil { - t.Error("Expected error, got nil") - } - - // And error should contain tools manager message - if err.Error() != "No tools manager found" { - t.Errorf("Expected error about tools manager, got: %v", err) - } - }) - - t.Run("ToolsCheckError", func(t *testing.T) { - // Given a set of mocks with proper configuration - mocks, _, _ := setup(t, &SetupOptions{ - ConfigStr: ` + }) + + // And mock controller that returns nil tools manager + mocks.Controller.ResolveToolsManagerFunc = func() tools.ToolsManager { + return nil + } + + // When executing the command + err := Execute(mocks.Controller) + + // Then an error should occur + if err == nil { + t.Error("Expected error, got nil") + } + + // And error should contain tools manager message + if err.Error() != "No tools manager found" { + t.Errorf("Expected error about tools manager, got: %v", err) + } + }) + + t.Run("ToolsCheckError", func(t *testing.T) { + // Given a set of mocks with proper configuration + mocks, _, _ := setup(t, &SetupOptions{ + ConfigStr: ` contexts: default: tools: enabled: true`, - }) - - // And mock tools manager that returns error - mockToolsManager := tools.NewMockToolsManager() - mockToolsManager.CheckFunc = func() error { - return fmt.Errorf("tools check failed") - } - mocks.Injector.Register("toolsManager", mockToolsManager) - - // When executing the command - err := Execute(mocks.Controller) - - // Then an error should occur - if err == nil { - t.Error("Expected error, got nil") - } - - // And error should contain tools check message - if err.Error() != "Error checking tools: tools check failed" { - t.Errorf("Expected error about tools check, got: %v", err) - } - }) - - t.Run("InitializeWithRequirementsError", func(t *testing.T) { - // Given a set of mocks with proper configuration - mocks, _, _ := setup(t, &SetupOptions{ - ConfigStr: ` + }) + + // And mock tools manager that returns error + mockToolsManager := tools.NewMockToolsManager() + mockToolsManager.CheckFunc = func() error { + return fmt.Errorf("tools check failed") + } + mocks.Injector.Register("toolsManager", mockToolsManager) + + // When executing the command + err := Execute(mocks.Controller) + + // Then an error should occur + if err == nil { + t.Error("Expected error, got nil") + } + + // And error should contain tools check message + if err.Error() != "Error checking tools: tools check failed" { + t.Errorf("Expected error about tools check, got: %v", err) + } + }) + + t.Run("InitializeWithRequirementsError", func(t *testing.T) { + // Given a set of mocks with proper configuration + mocks, _, _ := setup(t, &SetupOptions{ + ConfigStr: ` contexts: default: tools: enabled: true`, - }) - - // And mock controller that returns error on initialization - mocks.Controller.InitializeWithRequirementsFunc = func(req controller.Requirements) error { - return fmt.Errorf("initialization failed") - } - - // When executing the command - err := Execute(mocks.Controller) - - // Then an error should occur - if err == nil { - t.Error("Expected error, got nil") - } - - // And error should contain initialization message - if err.Error() != "Error initializing: initialization failed" { - t.Errorf("Expected error about initialization, got: %v", err) - } - }) + }) + + // And mock controller that returns error on initialization + mocks.Controller.InitializeWithRequirementsFunc = func(req controller.Requirements) error { + return fmt.Errorf("initialization failed") + } + + // When executing the command + err := Execute(mocks.Controller) + + // Then an error should occur + if err == nil { + t.Error("Expected error, got nil") + } + + // And error should contain initialization message + if err.Error() != "Error initializing: initialization failed" { + t.Errorf("Expected error about initialization, got: %v", err) + } + }) +} + +func TestCheckNodeHealthCmd(t *testing.T) { + setup := func(t *testing.T, opts ...*SetupOptions) (*Mocks, *bytes.Buffer, *bytes.Buffer) { + t.Helper() + + // Setup mocks with default options + mocks := setupMocks(t, opts...) + + // Setup command args and output + stdout, stderr := captureOutput(t) + rootCmd.SetOut(stdout) + rootCmd.SetErr(stderr) + + // Reset global command flags to avoid state leakage + nodeHealthTimeout = 0 + nodeHealthNodes = []string{} + nodeHealthVersion = "" + + // Reset command flags + checkNodeHealthCmd.ResetFlags() + 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().StringVar(&nodeHealthVersion, "version", "", "Expected version to check against (optional)") + + return mocks, stdout, stderr + } + + t.Run("Success", func(t *testing.T) { + // Given a set of mocks with proper configuration + mocks, stdout, stderr := setup(t, &SetupOptions{ + ConfigStr: ` +contexts: + default: + cluster: + enabled: true`, + }) + + // And mock cluster client that returns success + mockClusterClient := cluster.NewMockClusterClient() + mockClusterClient.WaitForNodesHealthyFunc = func(ctx context.Context, nodeAddresses []string, expectedVersion string) error { + return nil + } + mocks.Controller.ResolveClusterClientFunc = func() cluster.ClusterClient { + return mockClusterClient + } + + // Setup command args + rootCmd.SetArgs([]string{"check", "node-health", "--nodes", "10.0.0.1,10.0.0.2"}) + + // When executing the command + err := Execute(mocks.Controller) + + // Then no error should occur + if err != nil { + t.Errorf("Expected success, got error: %v", err) + } + + // And output should contain success message + output := stdout.String() + expectedOutput := "All 2 nodes are healthy\n" + if output != expectedOutput { + t.Errorf("Expected %q, got: %q", expectedOutput, output) + } + if stderr.String() != "" { + t.Error("Expected empty stderr") + } + }) + + t.Run("SuccessWithVersion", func(t *testing.T) { + // Given a set of mocks with proper configuration + mocks, stdout, stderr := setup(t, &SetupOptions{ + ConfigStr: ` +contexts: + default: + cluster: + enabled: true`, + }) + + // And mock cluster client that returns success + mockClusterClient := cluster.NewMockClusterClient() + mockClusterClient.WaitForNodesHealthyFunc = func(ctx context.Context, nodeAddresses []string, expectedVersion string) error { + // Verify expected version is passed correctly + if expectedVersion != "v1.0.0" { + return fmt.Errorf("unexpected version: %s", expectedVersion) + } + return nil + } + mocks.Controller.ResolveClusterClientFunc = func() cluster.ClusterClient { + return mockClusterClient + } + + // Setup command args with version + rootCmd.SetArgs([]string{"check", "node-health", "--nodes", "10.0.0.1", "--version", "v1.0.0"}) + + // When executing the command + err := Execute(mocks.Controller) + + // Then no error should occur + if err != nil { + t.Errorf("Expected success, got error: %v", err) + } + + // And output should contain success message with version + output := stdout.String() + expectedOutput := "All 1 nodes are healthy and running version v1.0.0\n" + if output != expectedOutput { + t.Errorf("Expected %q, got: %q", expectedOutput, output) + } + if stderr.String() != "" { + t.Error("Expected empty stderr") + } + }) + + t.Run("SuccessWithCustomTimeout", func(t *testing.T) { + // Given a set of mocks with proper configuration + mocks, stdout, stderr := setup(t, &SetupOptions{ + ConfigStr: ` +contexts: + default: + cluster: + enabled: true`, + }) + + // And mock cluster client that tracks timeout + mockClusterClient := cluster.NewMockClusterClient() + timeoutReceived := time.Duration(0) + mockClusterClient.WaitForNodesHealthyFunc = func(ctx context.Context, nodeAddresses []string, expectedVersion string) error { + // Extract timeout from context + deadline, ok := ctx.Deadline() + if ok { + timeoutReceived = time.Until(deadline) + } + return nil + } + mocks.Controller.ResolveClusterClientFunc = func() cluster.ClusterClient { + return mockClusterClient + } + + // Setup command args with custom timeout + rootCmd.SetArgs([]string{"check", "node-health", "--nodes", "10.0.0.1", "--timeout", "2m"}) + + // When executing the command + err := Execute(mocks.Controller) + + // Then no error should occur + if err != nil { + t.Errorf("Expected success, got error: %v", err) + } + + // And timeout should be approximately 2 minutes (allow some variance) + expectedTimeout := 2 * time.Minute + if timeoutReceived < expectedTimeout-10*time.Second || timeoutReceived > expectedTimeout+10*time.Second { + t.Errorf("Expected timeout around %v, got %v", expectedTimeout, timeoutReceived) + } + + // And output should contain success message + output := stdout.String() + if output != "All 1 nodes are healthy\n" { + t.Errorf("Expected success message, got: %q", output) + } + if stderr.String() != "" { + t.Error("Expected empty stderr") + } + }) + + t.Run("ConfigNotLoaded", func(t *testing.T) { + // Given a set of mocks with no configuration + mocks, _, _ := setup(t) + + // Setup command args + rootCmd.SetArgs([]string{"check", "node-health", "--nodes", "10.0.0.1"}) + + // When executing the command + err := Execute(mocks.Controller) + + // Then an error should occur + if err == nil { + t.Error("Expected error, got nil") + } + + // And error should contain init message + expectedError := "Nothing to check. Have you run \033[1mwindsor init\033[0m?" + if err.Error() != expectedError { + t.Errorf("Expected error about init, got: %v", err) + } + }) + + t.Run("ClusterClientNotFound", func(t *testing.T) { + // Given a set of mocks with proper configuration + mocks, _, _ := setup(t, &SetupOptions{ + ConfigStr: ` +contexts: + default: + cluster: + enabled: true`, + }) + + // And mock controller that returns nil cluster client + mocks.Controller.ResolveClusterClientFunc = func() cluster.ClusterClient { + return nil + } + + // Setup command args + rootCmd.SetArgs([]string{"check", "node-health", "--nodes", "10.0.0.1"}) + + // When executing the command + err := Execute(mocks.Controller) + + // Then an error should occur + if err == nil { + t.Error("Expected error, got nil") + } + + // And error should contain cluster client message + if err.Error() != "No cluster client found" { + t.Errorf("Expected error about cluster client, got: %v", err) + } + }) + + t.Run("NoNodesSpecified", func(t *testing.T) { + // Given a set of mocks with proper configuration + mocks, _, _ := setup(t, &SetupOptions{ + ConfigStr: ` +contexts: + default: + cluster: + enabled: true`, + }) + + // And mock cluster client + mockClusterClient := cluster.NewMockClusterClient() + mocks.Controller.ResolveClusterClientFunc = func() cluster.ClusterClient { + return mockClusterClient + } + + // Setup command args without nodes + rootCmd.SetArgs([]string{"check", "node-health"}) + + // When executing the command + err := Execute(mocks.Controller) + + // Then an error should occur + if err == nil { + t.Error("Expected error, got nil") + } + + // And error should contain nodes requirement message + expectedError := "No nodes specified. Use --nodes flag to specify nodes to check" + if err.Error() != expectedError { + t.Errorf("Expected error about nodes requirement, got: %v", err) + } + }) + + t.Run("HealthCheckError", func(t *testing.T) { + // Given a set of mocks with proper configuration + mocks, _, _ := setup(t, &SetupOptions{ + ConfigStr: ` +contexts: + default: + cluster: + enabled: true`, + }) + + // And mock cluster client that returns error + mockClusterClient := cluster.NewMockClusterClient() + mockClusterClient.WaitForNodesHealthyFunc = func(ctx context.Context, nodeAddresses []string, expectedVersion string) error { + return fmt.Errorf("health check failed") + } + mocks.Controller.ResolveClusterClientFunc = func() cluster.ClusterClient { + return mockClusterClient + } + + // Setup command args + rootCmd.SetArgs([]string{"check", "node-health", "--nodes", "10.0.0.1"}) + + // When executing the command + err := Execute(mocks.Controller) + + // Then an error should occur + if err == nil { + t.Error("Expected error, got nil") + } + + // And error should contain health check message + expectedError := "nodes failed health check: health check failed" + if err.Error() != expectedError { + t.Errorf("Expected error about health check, got: %v", err) + } + }) + + t.Run("InitializeWithRequirementsError", func(t *testing.T) { + // Given a set of mocks with proper configuration + mocks, _, _ := setup(t, &SetupOptions{ + ConfigStr: ` +contexts: + default: + cluster: + enabled: true`, + }) + + // And mock controller that returns error on initialization + mocks.Controller.InitializeWithRequirementsFunc = func(req controller.Requirements) error { + return fmt.Errorf("initialization failed") + } + + // Setup command args + rootCmd.SetArgs([]string{"check", "node-health", "--nodes", "10.0.0.1"}) + + // When executing the command + err := Execute(mocks.Controller) + + // Then an error should occur + if err == nil { + t.Error("Expected error, got nil") + } + + // And error should contain initialization message + if err.Error() != "Error initializing: initialization failed" { + t.Errorf("Expected error about initialization, got: %v", err) + } + }) + + t.Run("MultipleNodes", func(t *testing.T) { + // Given a set of mocks with proper configuration + mocks, stdout, stderr := setup(t, &SetupOptions{ + ConfigStr: ` +contexts: + default: + cluster: + enabled: true`, + }) + + // And mock cluster client that validates node addresses + mockClusterClient := cluster.NewMockClusterClient() + mockClusterClient.WaitForNodesHealthyFunc = func(ctx context.Context, nodeAddresses []string, expectedVersion string) error { + // Verify correct node addresses are passed + expectedNodes := []string{"10.0.0.1", "10.0.0.2", "10.0.0.3"} + if len(nodeAddresses) != len(expectedNodes) { + return fmt.Errorf("expected %d nodes, got %d", len(expectedNodes), len(nodeAddresses)) + } + for i, expected := range expectedNodes { + if nodeAddresses[i] != expected { + return fmt.Errorf("expected node %s at index %d, got %s", expected, i, nodeAddresses[i]) + } + } + return nil + } + mocks.Controller.ResolveClusterClientFunc = func() cluster.ClusterClient { + return mockClusterClient + } + + // Setup command args with multiple nodes + rootCmd.SetArgs([]string{"check", "node-health", "--nodes", "10.0.0.1,10.0.0.2,10.0.0.3"}) + + // When executing the command + err := Execute(mocks.Controller) + + // Then no error should occur + if err != nil { + t.Errorf("Expected success, got error: %v", err) + } + + // And output should contain success message for multiple nodes + output := stdout.String() + expectedOutput := "All 3 nodes are healthy\n" + if output != expectedOutput { + t.Errorf("Expected %q, got: %q", expectedOutput, output) + } + if stderr.String() != "" { + t.Error("Expected empty stderr") + } + }) + + t.Run("ClusterClientCloseCalledOnSuccess", func(t *testing.T) { + // Given a set of mocks with proper configuration + mocks, _, _ := setup(t, &SetupOptions{ + ConfigStr: ` +contexts: + default: + cluster: + enabled: true`, + }) + + // And mock cluster client that tracks close calls + closeCalled := false + mockClusterClient := cluster.NewMockClusterClient() + mockClusterClient.WaitForNodesHealthyFunc = func(ctx context.Context, nodeAddresses []string, expectedVersion string) error { + return nil + } + mockClusterClient.CloseFunc = func() { + closeCalled = true + } + mocks.Controller.ResolveClusterClientFunc = func() cluster.ClusterClient { + return mockClusterClient + } + + // Setup command args + rootCmd.SetArgs([]string{"check", "node-health", "--nodes", "10.0.0.1"}) + + // When executing the command + err := Execute(mocks.Controller) + + // Then no error should occur + if err != nil { + t.Errorf("Expected success, got error: %v", err) + } + + // And Close should be called + if !closeCalled { + t.Error("Expected Close to be called on cluster client") + } + }) + + t.Run("ClusterClientCloseCalledOnError", func(t *testing.T) { + // Given a set of mocks with proper configuration + mocks, _, _ := setup(t, &SetupOptions{ + ConfigStr: ` +contexts: + default: + cluster: + enabled: true`, + }) + + // And mock cluster client that tracks close calls and returns error + closeCalled := false + mockClusterClient := cluster.NewMockClusterClient() + mockClusterClient.WaitForNodesHealthyFunc = func(ctx context.Context, nodeAddresses []string, expectedVersion string) error { + return fmt.Errorf("health check failed") + } + mockClusterClient.CloseFunc = func() { + closeCalled = true + } + mocks.Controller.ResolveClusterClientFunc = func() cluster.ClusterClient { + return mockClusterClient + } + + // Setup command args + rootCmd.SetArgs([]string{"check", "node-health", "--nodes", "10.0.0.1"}) + + // When executing the command + err := Execute(mocks.Controller) + + // Then an error should occur + if err == nil { + t.Error("Expected error, got nil") + } + + // And Close should still be called + if !closeCalled { + t.Error("Expected Close to be called on cluster client even on error") + } + }) } diff --git a/cmd/down.go b/cmd/down.go index 0773e8801..8c9a011f9 100644 --- a/cmd/down.go +++ b/cmd/down.go @@ -31,7 +31,7 @@ var downCmd = &cobra.Command{ Containers: true, Network: true, Blueprint: true, - Kubernetes: true, + Cluster: true, Stack: true, CommandName: cmd.Name(), Flags: map[string]bool{ diff --git a/cmd/init.go b/cmd/init.go index b18f67b48..86db52433 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -211,7 +211,7 @@ var initCmd = &cobra.Command{ Services: true, Network: true, Blueprint: true, - Kubernetes: true, + Cluster: true, Generators: true, Stack: true, Reset: reset, diff --git a/cmd/install.go b/cmd/install.go index 727e04d33..744b0db83 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -24,7 +24,7 @@ var installCmd = &cobra.Command{ Services: true, Network: true, Blueprint: true, - Kubernetes: true, + Cluster: true, Generators: true, Stack: true, CommandName: cmd.Name(), diff --git a/cmd/up.go b/cmd/up.go index eeded400d..eb34946c3 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -36,7 +36,7 @@ var upCmd = &cobra.Command{ Services: true, Network: true, Blueprint: true, - Kubernetes: true, + Cluster: true, Generators: true, Stack: true, CommandName: cmd.Name(), diff --git a/go.mod b/go.mod index 038f089c2..a01d95296 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/google/go-jsonnet v0.21.0 github.com/hashicorp/hcl/v2 v2.23.0 github.com/shirou/gopsutil v3.21.11+incompatible + github.com/siderolabs/talos/pkg/machinery v1.10.4 github.com/spf13/cobra v1.9.1 github.com/zclconf/go-cty v1.16.3 golang.org/x/crypto v0.39.0 @@ -50,6 +51,9 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 // indirect github.com/ProtonMail/go-crypto v1.3.0 // indirect + github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f // indirect + github.com/ProtonMail/gopenpgp/v2 v2.8.3 // indirect + github.com/adrg/xdg v0.5.3 // indirect github.com/agext/levenshtein v1.2.3 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/aws/aws-sdk-go-v2 v1.36.3 // indirect @@ -77,7 +81,10 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudflare/circl v1.6.1 // indirect github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect + github.com/containerd/go-cni v1.1.12 // indirect + github.com/containernetworking/cni v1.2.3 // indirect github.com/coreos/go-semver v0.3.1 // indirect + github.com/cosi-project/runtime v0.10.2 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/distribution/reference v0.6.0 // indirect @@ -92,6 +99,7 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fluxcd/pkg/apis/acl v0.7.0 // indirect github.com/fxamacker/cbor/v2 v2.8.0 // indirect + github.com/gertd/go-pluralize v0.2.1 // indirect github.com/getsops/gopgagent v0.0.0-20241224165529-7044f28e491e // indirect github.com/go-jose/go-jose/v4 v4.0.5 // indirect github.com/go-logr/logr v1.4.2 // indirect @@ -112,6 +120,7 @@ require ( github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/gax-go/v2 v2.14.2 // indirect github.com/goware/prefixer v0.0.0-20160118172347-395022866408 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect @@ -139,11 +148,19 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/runtime-spec v1.2.1 // indirect + github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20250313105119-ba97887b0a25 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect + github.com/sasha-s/go-deadlock v0.3.5 // indirect + github.com/siderolabs/crypto v0.6.0 // indirect + github.com/siderolabs/gen v0.8.0 // indirect + github.com/siderolabs/go-api-signature v0.3.6 // indirect + github.com/siderolabs/go-pointer v1.0.1 // indirect + github.com/siderolabs/protoenc v0.2.2 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect @@ -164,6 +181,8 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.36.0 // indirect go.opentelemetry.io/otel/trace v1.36.0 // indirect go.opentelemetry.io/proto/otlp v1.6.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect golang.org/x/mod v0.25.0 // indirect golang.org/x/net v0.40.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect diff --git a/go.sum b/go.sum index 754030002..f48e9c191 100644 --- a/go.sum +++ b/go.sum @@ -65,10 +65,18 @@ github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEV github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= +github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ekTTXpdwKYF8eBlsYsDVoggDAuAjoK66k= +github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw= +github.com/ProtonMail/gopenpgp/v2 v2.8.3 h1:1jHlELwCR00qovx2B50DkL/FjYwt/P91RnlsqeOp2Hs= +github.com/ProtonMail/gopenpgp/v2 v2.8.3/go.mod h1:LiuOTbnJit8w9ZzOoLscj0kmdALY7hfoCVh5Qlb0bcg= github.com/abiosoft/colima v0.8.1 h1:0wDFRy3ei4YW2++V/kzIyeOGBaAmWiM0c6gujHkypXE= github.com/abiosoft/colima v0.8.1/go.mod h1:g1pEL32cEIjukm6Siehgm7SrR+zdtYpKODv2hbF9wLs= +github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= +github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM= @@ -115,6 +123,8 @@ github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdn github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= +github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= +github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -127,8 +137,14 @@ github.com/compose-spec/compose-go/v2 v2.6.4 h1:Gjv6x8eAhqwwWvoXIo0oZ4bDQBh0OMwd github.com/compose-spec/compose-go/v2 v2.6.4/go.mod h1:vPlkN0i+0LjLf9rv52lodNMUTJF5YHVfHVGLLIP67NA= github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4= github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= +github.com/containerd/go-cni v1.1.12 h1:wm/5VD/i255hjM4uIZjBRiEQ7y98W9ACy/mHeLi4+94= +github.com/containerd/go-cni v1.1.12/go.mod h1:+jaqRBdtW5faJxj2Qwg1Of7GsV66xcvnCx4mSJtUlxU= +github.com/containernetworking/cni v1.2.3 h1:hhOcjNVUQTnzdRJ6alC5XF+wd9mfGIUaj8FuJbEslXM= +github.com/containernetworking/cni v1.2.3/go.mod h1:DuLgF+aPd3DzcTQTtp/Nvl1Kim23oFKdm2okJzBQA5M= github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= +github.com/cosi-project/runtime v0.10.2 h1:pKdMdmiZcMc2rCsykaNQAR4QqXVKmJf+n6+f0YjEeZM= +github.com/cosi-project/runtime v0.10.2/go.mod h1:aK3oljZUJG6+ewkJRwY+VI9B40JmDp5++Ixri447TjE= github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= @@ -149,6 +165,8 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 h1:idfl8M8rPW93NehFw5H1qqH8yG158t5POr+LX9avbJY= github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q= github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= @@ -181,6 +199,8 @@ github.com/fluxcd/source-controller/api v1.6.1 h1:ZPTA9lNzBYHmwHfFX978qb8xVkdnQZ github.com/fluxcd/source-controller/api v1.6.1/go.mod h1:ZJcAi0nemsnBxjVgmJl0WQzNvB0rMETxQMTdoFosmMw= github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU= github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gertd/go-pluralize v0.2.1 h1:M3uASbVjMnTsPb0PNqg+E/24Vwigyo/tvyMTtAlLgiA= +github.com/gertd/go-pluralize v0.2.1/go.mod h1:rbYaKDbsXxmRfr8uygAEKhOWsjyrrqrkHVpZvoOp8zk= github.com/getsops/gopgagent v0.0.0-20241224165529-7044f28e491e h1:y/1nzrdF+RPds4lfoEpNhjfmzlgZtPqyO3jMzrqDQws= github.com/getsops/gopgagent v0.0.0-20241224165529-7044f28e491e/go.mod h1:awFzISqLJoZLm+i9QQ4SgMNHDqljH6jWV0B36V5MrUM= github.com/getsops/sops/v3 v3.10.2 h1:7t7lBXFcXJPsDMrpYoI36r8xIhjWUmEc8Qdjuwyo+WY= @@ -217,6 +237,8 @@ github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeD github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/cel-go v0.24.1 h1:jsBCtxG8mM5wiUJDSGUqU0K7Mtr3w7Eyv00rw4DiZxI= +github.com/google/cel-go v0.24.1/go.mod h1:Hdf9TqOaTNSFQA1ybQaRqATVoK7m/zcf7IMhGXP5zI8= github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -241,6 +263,8 @@ github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3 github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w= github.com/goware/prefixer v0.0.0-20160118172347-395022866408 h1:Y9iQJfEqnN3/Nce9cOegemcy/9Ai5k3huT6E80F3zaw= github.com/goware/prefixer v0.0.0-20160118172347-395022866408/go.mod h1:PE1ycukgRPJ7bJ9a1fdfQ9j8i/cEcRAoLZzbxYpNB/s= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -272,6 +296,10 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA= +github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= +github.com/jsimonetti/rtnetlink/v2 v2.0.3 h1:Jcp7GTnTPepoUAJ9+LhTa7ZiebvNS56T1GtlEUaPNFE= +github.com/jsimonetti/rtnetlink/v2 v2.0.3/go.mod h1:atIkksp/9fqtf6rpAw45JnttnP2gtuH9X88WPfWfS9A= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= @@ -294,6 +322,14 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= +github.com/mdlayher/ethtool v0.4.0 h1:jjMGNSQfqauwFCtSzcqpa57R0AJdxKdQgbQ9mAOtM4Q= +github.com/mdlayher/ethtool v0.4.0/go.mod h1:GrljOneAFOTPGazYlf8qpxvYLdu4mo3pdJqXWLZ2Re8= +github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= +github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= +github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g= +github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw= +github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= +github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= @@ -323,8 +359,12 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/opencontainers/runc v1.2.6 h1:P7Hqg40bsMvQGCS4S7DJYhUZOISMLJOB2iGX5COWiPk= github.com/opencontainers/runc v1.2.6/go.mod h1:dOQeFo29xZKBNeRBI0B19mJtfHv68YgCTh1X+YphA+4= +github.com/opencontainers/runtime-spec v1.2.1 h1:S4k4ryNgEpxW1dzyqffOmhI1BHYcjzU8lpJfSlR0xww= +github.com/opencontainers/runtime-spec v1.2.1/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= github.com/ory/dockertest/v3 v3.12.0 h1:3oV9d0sDzlSQfHtIaB5k6ghUCVMVLpAY8hwrqoCyRCw= github.com/ory/dockertest/v3 v3.12.0/go.mod h1:aKNDTva3cp8dwOWwb9cWuX84aH5akkxXRvO7KCwWVjE= +github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 h1:Dx7Ovyv/SFnMFw3fD4oEoeorXc6saIiQ23LrGLth0Gw= +github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -335,16 +375,34 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.8.0 h1:q3nRvjrlge/6UD7eTu/DSg2uYiU2mCL0G/uzBWqhicI= github.com/redis/go-redis/v9 v9.8.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/sasha-s/go-deadlock v0.3.5 h1:tNCOEEDG6tBqrNDOX35j/7hL5FcFViG6awUGROb2NsU= +github.com/sasha-s/go-deadlock v0.3.5/go.mod h1:bugP6EGbdGYObIlx7pUZtWqlvo8k9H6vCBBsiChJQ5U= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= +github.com/siderolabs/crypto v0.6.0 h1:s33hNOneGhlxCI3fLKj2hgopeJkeRO7UYo3KL0HNVu4= +github.com/siderolabs/crypto v0.6.0/go.mod h1:7RHC7eUKBx6RLS2lDaNXrQ83zY9iPH/aQSTxk1I4/j4= +github.com/siderolabs/gen v0.8.0 h1:Pj93+hexkk5hQ7izjJ6YXnEWc8vlzOmDwFz13/VzS7o= +github.com/siderolabs/gen v0.8.0/go.mod h1:an3a2Y53O7kUjnnK8Bfu3gewtvnIOu5RTU6HalFtXQQ= +github.com/siderolabs/go-api-signature v0.3.6 h1:wDIsXbpl7Oa/FXvxB6uz4VL9INA9fmr3EbmjEZYFJrU= +github.com/siderolabs/go-api-signature v0.3.6/go.mod h1:hoH13AfunHflxbXfh+NoploqV13ZTDfQ1mQJWNVSW9U= +github.com/siderolabs/go-pointer v1.0.1 h1:f7Yi4IK1jptS8yrT9GEbwhmGcVxvPQgBUG/weH3V3DM= +github.com/siderolabs/go-pointer v1.0.1/go.mod h1:C8Q/3pNHT4RE9e4rYR9PHeS6KPMlStRBgYrJQJNy/vA= +github.com/siderolabs/go-retry v0.3.3 h1:zKV+S1vumtO72E6sYsLlmIdV/G/GcYSBLiEx/c9oCEg= +github.com/siderolabs/go-retry v0.3.3/go.mod h1:Ff/VGc7v7un4uQg3DybgrmOWHEmJ8BzZds/XNn/BqMI= +github.com/siderolabs/net v0.4.0 h1:1bOgVay/ijPkJz4qct98nHsiB/ysLQU0KLoBC4qLm7I= +github.com/siderolabs/net v0.4.0/go.mod h1:/ibG+Hm9HU27agp5r9Q3eZicEfjquzNzQNux5uEk0kM= +github.com/siderolabs/protoenc v0.2.2 h1:vVQDrTjV+QSOiroWTca6h2Sn5XWYk7VSUPav5J0Qp54= +github.com/siderolabs/protoenc v0.2.2/go.mod h1:gtkHkjSCFEceXUHUzKDpnuvXu1mab9D3pVxTnQN+z+o= +github.com/siderolabs/talos/pkg/machinery v1.10.4 h1:IR/OlItU3sbCFoHqOX4j5swkOHhaGB/4zWCaZK3yOhE= +github.com/siderolabs/talos/pkg/machinery v1.10.4/go.mod h1:GxGnHH6gtX3J9s713+UbKvE9rLnlbYLv+Yn4rqD9Jh0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= @@ -353,6 +411,8 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE= github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g= +github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= +github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -372,6 +432,8 @@ github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZB github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= github.com/urfave/cli v1.22.16 h1:MH0k6uJxdwdeWQTwhSO42Pwr4YLrNLwBtg1MRgTqPdQ= github.com/urfave/cli v1.22.16/go.mod h1:EeJR6BKodywf4zciqrdw6hpCPk68JO9z5LazXZMn5Po= +github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8= +github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= @@ -384,6 +446,7 @@ github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8 github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk= @@ -414,19 +477,33 @@ go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKr go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI= go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c h1:KL/ZBHXgKGVmuZBZ01Lt57yE5ws8ZPSkkihmEyq7FXc= +golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= @@ -434,21 +511,34 @@ golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKl golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= @@ -457,6 +547,8 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/pkg/cluster/cluster_client.go b/pkg/cluster/cluster_client.go new file mode 100644 index 000000000..31b40d341 --- /dev/null +++ b/pkg/cluster/cluster_client.go @@ -0,0 +1,68 @@ +// The ClusterClient is a base interface for cluster node operations. +// It provides a common interface for health checks and management operations, +// serving as the foundation for provider-specific cluster clients, +// and enabling consistent cluster management across different providers. + +package cluster + +import ( + "context" + "fmt" + "time" + + "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 + // Polls until all nodes are healthy (and correct version if specified) or timeout + WaitForNodesHealthy(ctx context.Context, nodeAddresses []string, expectedVersion string) error + + // Close closes any open connections. + Close() +} + +// BaseClusterClient provides a base implementation of ClusterClient. +type BaseClusterClient struct { + // Configurable timeouts + healthCheckTimeout time.Duration + healthCheckPollInterval time.Duration +} + +// ============================================================================= +// Constructor +// ============================================================================= + +// NewBaseClusterClient creates a new BaseClusterClient with default timeouts. +func NewBaseClusterClient() *BaseClusterClient { + return &BaseClusterClient{ + healthCheckTimeout: constants.DEFAULT_NODE_HEALTH_CHECK_TIMEOUT, + healthCheckPollInterval: constants.DEFAULT_NODE_HEALTH_CHECK_POLL_INTERVAL, + } +} + +// ============================================================================= +// Public Methods +// ============================================================================= + +// Close is a no-op in the base implementation. +// Provider-specific implementations should override this to close their connections. +func (c *BaseClusterClient) Close() { + // Base implementation does nothing +} + +// WaitForNodesHealthy implements the default polling behavior for node health and version checks +func (c *BaseClusterClient) WaitForNodesHealthy(ctx context.Context, nodeAddresses []string, expectedVersion string) error { + return fmt.Errorf("WaitForNodesHealthy not implemented") +} diff --git a/pkg/cluster/cluster_client_test.go b/pkg/cluster/cluster_client_test.go new file mode 100644 index 000000000..a41b28ac6 --- /dev/null +++ b/pkg/cluster/cluster_client_test.go @@ -0,0 +1,133 @@ +package cluster + +import ( + "context" + "testing" + + "github.com/windsorcli/cli/pkg/constants" +) + +// ============================================================================= +// Test Setup +// ============================================================================= + +type Mocks struct { + // Add mocks as needed +} + +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 +// ============================================================================= + +func TestNewBaseClusterClient(t *testing.T) { + t.Run("Success", func(t *testing.T) { + // When creating a new base cluster client + client := NewBaseClusterClient() + + // Then it should not be nil + if client == nil { + t.Error("Expected non-nil BaseClusterClient") + } + + // Then it should have default timeout values + if client.healthCheckTimeout != constants.DEFAULT_NODE_HEALTH_CHECK_TIMEOUT { + t.Errorf("Expected healthCheckTimeout %v, got %v", constants.DEFAULT_NODE_HEALTH_CHECK_TIMEOUT, client.healthCheckTimeout) + } + + if client.healthCheckPollInterval != constants.DEFAULT_NODE_HEALTH_CHECK_POLL_INTERVAL { + t.Errorf("Expected healthCheckPollInterval %v, got %v", constants.DEFAULT_NODE_HEALTH_CHECK_POLL_INTERVAL, client.healthCheckPollInterval) + } + }) +} + +// ============================================================================= +// Test Public Methods +// ============================================================================= + +func TestBaseClusterClient_Close(t *testing.T) { + t.Run("Success", func(t *testing.T) { + // Given a base cluster client + client := NewBaseClusterClient() + + // When calling Close + // Then it should not panic + client.Close() + }) +} + +func TestBaseClusterClient_WaitForNodesHealthy(t *testing.T) { + t.Run("NotImplementedError", func(t *testing.T) { + // Given a base cluster client + client := NewBaseClusterClient() + ctx := context.Background() + nodeAddresses := []string{"10.0.0.1", "10.0.0.2"} + expectedVersion := "v1.0.0" + + // When calling WaitForNodesHealthy + err := client.WaitForNodesHealthy(ctx, nodeAddresses, expectedVersion) + + // Then it should return not implemented error + if err == nil { + t.Error("Expected error, got nil") + } + + expectedMsg := "WaitForNodesHealthy not implemented" + if err.Error() != expectedMsg { + t.Errorf("Expected error message '%s', got '%s'", expectedMsg, err.Error()) + } + }) + + t.Run("NotImplementedErrorEmptyNodes", func(t *testing.T) { + // Given a base cluster client + client := NewBaseClusterClient() + ctx := context.Background() + nodeAddresses := []string{} + expectedVersion := "" + + // When calling WaitForNodesHealthy with empty parameters + err := client.WaitForNodesHealthy(ctx, nodeAddresses, expectedVersion) + + // Then it should still return not implemented error + if err == nil { + t.Error("Expected error, got nil") + } + + expectedMsg := "WaitForNodesHealthy not implemented" + if err.Error() != expectedMsg { + t.Errorf("Expected error message '%s', got '%s'", expectedMsg, err.Error()) + } + }) + + t.Run("NotImplementedErrorCancelledContext", func(t *testing.T) { + // Given a base cluster client and cancelled context + client := NewBaseClusterClient() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + nodeAddresses := []string{"10.0.0.1"} + expectedVersion := "v1.0.0" + + // When calling WaitForNodesHealthy with cancelled context + err := client.WaitForNodesHealthy(ctx, nodeAddresses, expectedVersion) + + // Then it should return not implemented error (not context error) + if err == nil { + t.Error("Expected error, got nil") + } + + expectedMsg := "WaitForNodesHealthy not implemented" + if err.Error() != expectedMsg { + t.Errorf("Expected error message '%s', got '%s'", expectedMsg, err.Error()) + } + }) +} diff --git a/pkg/cluster/mock_cluster_client.go b/pkg/cluster/mock_cluster_client.go new file mode 100644 index 000000000..76163686e --- /dev/null +++ b/pkg/cluster/mock_cluster_client.go @@ -0,0 +1,52 @@ +// The MockClusterClient is a mock implementation of the ClusterClient interface. +// It provides configurable function fields for testing cluster operations, +// enabling controlled testing scenarios for health checks and node management, +// and allowing test cases to simulate various cluster states and behaviors. + +package cluster + +import ( + "context" +) + +// ============================================================================= +// Types +// ============================================================================= + +// MockClusterClient is a mock implementation of the ClusterClient interface +type MockClusterClient struct { + BaseClusterClient + WaitForNodesHealthyFunc func(ctx context.Context, nodeAddresses []string, expectedVersion string) error + CloseFunc func() +} + +// ============================================================================= +// Constructor +// ============================================================================= + +// NewMockClusterClient is a constructor for MockClusterClient +func NewMockClusterClient() *MockClusterClient { + return &MockClusterClient{} +} + +// ============================================================================= +// Public Methods +// ============================================================================= + +// WaitForNodesHealthy calls the mock WaitForNodesHealthyFunc if set, otherwise returns nil +func (m *MockClusterClient) WaitForNodesHealthy(ctx context.Context, nodeAddresses []string, expectedVersion string) error { + if m.WaitForNodesHealthyFunc != nil { + return m.WaitForNodesHealthyFunc(ctx, nodeAddresses, expectedVersion) + } + return nil +} + +// Close calls the mock CloseFunc if set +func (m *MockClusterClient) Close() { + if m.CloseFunc != nil { + m.CloseFunc() + } +} + +// Ensure MockClusterClient implements ClusterClient +var _ ClusterClient = (*MockClusterClient)(nil) diff --git a/pkg/cluster/mock_cluster_client_test.go b/pkg/cluster/mock_cluster_client_test.go new file mode 100644 index 000000000..1ef7198c5 --- /dev/null +++ b/pkg/cluster/mock_cluster_client_test.go @@ -0,0 +1,71 @@ +package cluster + +import ( + "context" + "fmt" + "testing" +) + +// ============================================================================= +// Public Methods +// ============================================================================= + +func TestMockClusterClient_WaitForNodesHealthy(t *testing.T) { + t.Run("FuncSet", func(t *testing.T) { + // Given a mock with configured function + client := NewMockClusterClient() + errVal := fmt.Errorf("err") + client.WaitForNodesHealthyFunc = func(ctx context.Context, addresses []string, version string) error { + return errVal + } + + // When calling WaitForNodesHealthy + err := client.WaitForNodesHealthy(context.Background(), []string{"10.0.0.1"}, "v1.0.0") + + // Then it should return the expected error + if err != errVal { + t.Errorf("Expected err, got %v", err) + } + }) + + t.Run("FuncNotSet", func(t *testing.T) { + // Given a mock without configured function + client := NewMockClusterClient() + + // When calling WaitForNodesHealthy + err := client.WaitForNodesHealthy(context.Background(), []string{"10.0.0.1"}, "v1.0.0") + + // Then it should return nil + if err != nil { + t.Errorf("Expected nil, got %v", err) + } + }) +} + +func TestMockClusterClient_Close(t *testing.T) { + t.Run("FuncSet", func(t *testing.T) { + // Given a mock with configured function + client := NewMockClusterClient() + called := false + client.CloseFunc = func() { + called = true + } + + // When calling Close + client.Close() + + // Then the function should be called + if !called { + t.Error("Expected CloseFunc to be called") + } + }) + + t.Run("FuncNotSet", func(t *testing.T) { + // Given a mock without configured function + client := NewMockClusterClient() + + // When calling Close + // Then it should not panic + client.Close() + }) +} diff --git a/pkg/cluster/shims.go b/pkg/cluster/shims.go new file mode 100644 index 000000000..37fa122c4 --- /dev/null +++ b/pkg/cluster/shims.go @@ -0,0 +1,54 @@ +// Package cluster provides shims for gRPC operations and other provider-specific abstractions. +// It provides mockable interfaces for external dependencies and system calls, +// The shims package acts as a testing aid by allowing system calls to be intercepted, +// It enables dependency injection and test isolation for system-level operations. + +package cluster + +import ( + "context" + + "github.com/siderolabs/talos/pkg/machinery/api/machine" + "github.com/siderolabs/talos/pkg/machinery/client" + clientconfig "github.com/siderolabs/talos/pkg/machinery/client/config" +) + +// ============================================================================= +// Types +// ============================================================================= + +// Shims provides testable interfaces for external dependencies +type Shims struct { + // Talos client operations + TalosConfigOpen func(configPath string) (*clientconfig.Config, error) + TalosNewClient func(ctx context.Context, opts ...client.OptionFunc) (*client.Client, error) + TalosVersion func(ctx context.Context, client *client.Client) (*machine.VersionResponse, error) + TalosWithNodes func(ctx context.Context, nodes ...string) context.Context + TalosServiceList func(ctx context.Context, client *client.Client) (*machine.ServiceListResponse, error) + TalosClose func(client *client.Client) +} + +// ============================================================================= +// Constructor +// ============================================================================= + +// NewShims creates a new Shims instance with default implementations +func NewShims() *Shims { + return &Shims{ + // Talos client defaults + TalosConfigOpen: clientconfig.Open, + TalosNewClient: func(ctx context.Context, opts ...client.OptionFunc) (*client.Client, error) { + return client.New(ctx, opts...) + }, + TalosVersion: func(ctx context.Context, c *client.Client) (*machine.VersionResponse, error) { + return c.Version(ctx) + }, + TalosWithNodes: client.WithNodes, + TalosServiceList: func(ctx context.Context, c *client.Client) (*machine.ServiceListResponse, error) { + return c.ServiceList(ctx) + }, + TalosClose: func(c *client.Client) { + c.Close() + }, + } +} diff --git a/pkg/cluster/talos_cluster_client.go b/pkg/cluster/talos_cluster_client.go new file mode 100644 index 000000000..9cf3c4130 --- /dev/null +++ b/pkg/cluster/talos_cluster_client.go @@ -0,0 +1,264 @@ +// The TalosClusterClient is a Talos-specific implementation of the ClusterClient interface. +// It provides cluster operations and health checks using the Talos API and gRPC. +// The TalosClusterClient acts as the primary interface for Talos cluster management. +// It coordinates node health checks, API operations, and connection lifecycle. + +package cluster + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/windsorcli/cli/pkg/di" + + "github.com/siderolabs/talos/pkg/machinery/client" + clientconfig "github.com/siderolabs/talos/pkg/machinery/client/config" +) + +// ============================================================================= +// Types +// ============================================================================= + +// TalosClusterClient implements ClusterClient for Talos clusters +type TalosClusterClient struct { + *BaseClusterClient + injector di.Injector + shims *Shims + config *clientconfig.Config + client *client.Client +} + +// ============================================================================= +// Constructor +// ============================================================================= + +// NewTalosClusterClient creates a new TalosClusterClient instance with default configuration +func NewTalosClusterClient(injector di.Injector) *TalosClusterClient { + return &TalosClusterClient{ + BaseClusterClient: NewBaseClusterClient(), + injector: injector, + shims: NewShims(), + } +} + +// ============================================================================= +// Public Methods +// ============================================================================= + +// WaitForNodesHealthy waits for nodes to be healthy and optionally match a specific version. +// It polls each node continuously, checking service health and version status until all nodes +// meet the criteria or timeout occurs. For each node, it validates that all critical services +// are running and healthy, and if expectedVersion is provided, verifies the node is running +// that specific version. The method provides detailed status output for each node during polling, +// showing healthy/unhealthy services and version information. Returns an error with specific +// details about which nodes failed health checks or version validation if timeout is reached. +func (c *TalosClusterClient) WaitForNodesHealthy(ctx context.Context, nodeAddresses []string, expectedVersion string) error { + if err := c.ensureClient(); err != nil { + return fmt.Errorf("failed to initialize Talos client: %w", err) + } + + deadline, ok := ctx.Deadline() + if !ok { + deadline = time.Now().Add(c.healthCheckTimeout) + } + + var unhealthyNodes []string + var versionMismatchNodes []string + + for time.Now().Before(deadline) { + select { + case <-ctx.Done(): + return fmt.Errorf("timeout waiting for nodes to be ready") + default: + allReady := true + unhealthyNodes = nil + versionMismatchNodes = nil + + for _, nodeAddress := range nodeAddresses { + healthy, healthyServices, unhealthyServices, err := c.getNodeHealthDetails(ctx, nodeAddress) + if err != nil { + fmt.Printf("Node %s: ERROR - %v\n", nodeAddress, err) + allReady = false + continue + } + + var versionStatus string + var versionOK bool = true + if expectedVersion != "" { + version, err := c.getNodeVersion(ctx, nodeAddress) + if err != nil { + versionStatus = fmt.Sprintf("version error: %v", err) + versionOK = false + } else if version != expectedVersion { + versionStatus = fmt.Sprintf("version %s (expected %s)", version, expectedVersion) + versionOK = false + versionMismatchNodes = append(versionMismatchNodes, nodeAddress) + } else { + versionStatus = fmt.Sprintf("version %s", version) + } + } + + var statusParts []string + + if healthy { + statusParts = append(statusParts, "HEALTHY") + } else { + statusParts = append(statusParts, "UNHEALTHY") + unhealthyNodes = append(unhealthyNodes, nodeAddress) + allReady = false + } + + if len(healthyServices) > 0 { + statusParts = append(statusParts, fmt.Sprintf("healthy services: %s", strings.Join(healthyServices, ", "))) + } + + if len(unhealthyServices) > 0 { + statusParts = append(statusParts, fmt.Sprintf("unhealthy services: %s", strings.Join(unhealthyServices, ", "))) + } + + if versionStatus != "" { + statusParts = append(statusParts, versionStatus) + } + + fmt.Printf("Node %s: %s\n", nodeAddress, strings.Join(statusParts, " | ")) + + if !healthy || !versionOK { + allReady = false + } + } + + if allReady { + return nil + } + + time.Sleep(c.healthCheckPollInterval) + } + } + + var errorParts []string + + if len(unhealthyNodes) > 0 { + errorParts = append(errorParts, fmt.Sprintf("unhealthy nodes: %s", strings.Join(unhealthyNodes, ", "))) + } + + if len(versionMismatchNodes) > 0 { + errorParts = append(errorParts, fmt.Sprintf("version mismatch nodes: %s", strings.Join(versionMismatchNodes, ", "))) + } + + if len(errorParts) > 0 { + return fmt.Errorf("timeout waiting for nodes (%s)", strings.Join(errorParts, "; ")) + } + + return fmt.Errorf("timeout waiting for nodes to be ready") +} + +// Close releases resources held by the TalosClusterClient. +// It safely closes the underlying Talos gRPC client connection if one exists and sets +// the client reference to nil to prevent further use. This method is safe to call +// multiple times and handles the case where no client connection was established. +func (c *TalosClusterClient) Close() { + if c.client != nil { + c.shims.TalosClose(c.client) + c.client = nil + } +} + +// ============================================================================= +// Private Methods +// ============================================================================= + +// ensureClient lazily initializes the Talos client if not already set. +// It checks if a client already exists and returns early if so. Otherwise, it reads the +// TALOSCONFIG environment variable to locate the configuration file, loads and parses +// the Talos configuration using the shim layer, then creates a new Talos gRPC client +// with the loaded configuration. Returns an error if the environment variable is not +// set, the configuration file cannot be loaded, or the client creation fails. +func (c *TalosClusterClient) ensureClient() error { + if c.client != nil { + return nil + } + + configPath := os.Getenv("TALOSCONFIG") + if configPath == "" { + return fmt.Errorf("TALOSCONFIG environment variable not set") + } + + var err error + c.config, err = c.shims.TalosConfigOpen(configPath) + if err != nil { + return fmt.Errorf("error loading Talos config: %w", err) + } + + c.client, err = c.shims.TalosNewClient(context.Background(), + client.WithConfig(c.config), + ) + if err != nil { + return fmt.Errorf("error creating Talos client: %w", err) + } + + return nil +} + +// getNodeHealthDetails gets detailed health information for a single node. +// It creates a node-specific context targeting the given node address, then queries +// the Talos ServiceList API to retrieve all services running on that node. For each +// service, it checks both the running state and health status to determine if the +// service is fully operational. Returns the overall node health status, lists of +// healthy and unhealthy service names, and any error encountered during the API call. +func (c *TalosClusterClient) getNodeHealthDetails(ctx context.Context, nodeAddress string) (bool, []string, []string, error) { + nodeCtx := c.shims.TalosWithNodes(ctx, nodeAddress) + + serviceResp, err := c.shims.TalosServiceList(nodeCtx, c.client) + if err != nil { + return false, nil, nil, err + } + + var healthyServices []string + var unhealthyServices []string + overallHealthy := true + + for _, serviceList := range serviceResp.GetMessages() { + for _, service := range serviceList.GetServices() { + serviceName := service.GetId() + + state := service.GetState() + health := service.GetHealth() + + isRunning := state == "Running" + isHealthy := health != nil && health.GetHealthy() + + if isRunning && isHealthy { + healthyServices = append(healthyServices, serviceName) + } else { + unhealthyServices = append(unhealthyServices, serviceName) + overallHealthy = false + } + } + } + + return overallHealthy, healthyServices, unhealthyServices, nil +} + +// getNodeVersion gets the version of a single node. +// It creates a node-specific context targeting the given node address, then calls +// the Talos Version API to retrieve version information from that node. The method +// extracts the version tag from the API response and removes any leading 'v' prefix +// to return a clean version string. Returns an error if the API call fails or if +// the response format is unexpected. +func (c *TalosClusterClient) getNodeVersion(ctx context.Context, nodeAddress string) (string, error) { + nodeCtx := c.shims.TalosWithNodes(ctx, nodeAddress) + + version, err := c.shims.TalosVersion(nodeCtx, c.client) + if err != nil { + return "", err + } + + versionTag := version.Messages[0].Version.Tag + return strings.TrimPrefix(versionTag, "v"), nil +} + +// Ensure TalosClusterClient implements ClusterClient +var _ ClusterClient = (*TalosClusterClient)(nil) diff --git a/pkg/cluster/talos_cluster_client_test.go b/pkg/cluster/talos_cluster_client_test.go new file mode 100644 index 000000000..b33237a5f --- /dev/null +++ b/pkg/cluster/talos_cluster_client_test.go @@ -0,0 +1,653 @@ +package cluster + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/siderolabs/talos/pkg/machinery/api/machine" + talosclient "github.com/siderolabs/talos/pkg/machinery/client" + clientconfig "github.com/siderolabs/talos/pkg/machinery/client/config" + "github.com/windsorcli/cli/pkg/di" +) + +// ============================================================================= +// Test Setup +// ============================================================================= + +// setupShims initializes and returns shims for tests +func setupShims(t *testing.T) *Shims { + t.Helper() + shims := NewShims() + + shims.TalosConfigOpen = func(configPath string) (*clientconfig.Config, error) { + return &clientconfig.Config{}, nil + } + + shims.TalosNewClient = func(ctx context.Context, opts ...talosclient.OptionFunc) (*talosclient.Client, error) { + return &talosclient.Client{}, nil + } + + shims.TalosWithNodes = func(ctx context.Context, nodes ...string) context.Context { + return ctx + } + + shims.TalosServiceList = func(ctx context.Context, client *talosclient.Client) (*machine.ServiceListResponse, error) { + return &machine.ServiceListResponse{ + Messages: []*machine.ServiceList{ + { + Services: []*machine.ServiceInfo{ + { + Id: "apid", + State: "Running", + Health: &machine.ServiceHealth{ + Healthy: true, + }, + }, + { + Id: "machined", + State: "Running", + Health: &machine.ServiceHealth{ + Healthy: true, + }, + }, + }, + }, + }, + }, nil + } + + shims.TalosVersion = func(ctx context.Context, client *talosclient.Client) (*machine.VersionResponse, error) { + return &machine.VersionResponse{ + Messages: []*machine.Version{ + { + Version: &machine.VersionInfo{ + Tag: "v1.0.0", + }, + }, + }, + }, nil + } + + return shims +} + +// ============================================================================= +// Test Constructor +// ============================================================================= + +func TestNewTalosClusterClient(t *testing.T) { + t.Run("Success", func(t *testing.T) { + client := NewTalosClusterClient(di.NewMockInjector()) + + if client == nil { + t.Error("Expected non-nil TalosClusterClient") + } + if client.BaseClusterClient == nil { + t.Error("Expected non-nil BaseClusterClient") + } + if client.injector == nil { + t.Error("Expected injector to be set") + } + if client.shims == nil { + t.Error("Expected shims to be initialized") + } + }) +} + +// ============================================================================= +// Test Public Methods +// ============================================================================= + +func TestTalosClusterClient_WaitForNodesHealthy(t *testing.T) { + setup := func(t *testing.T) *TalosClusterClient { + t.Helper() + client := NewTalosClusterClient(di.NewMockInjector()) + client.shims = setupShims(t) + client.healthCheckTimeout = 100 * time.Millisecond + client.healthCheckPollInterval = 10 * time.Millisecond + return client + } + + t.Run("Success", func(t *testing.T) { + client := setup(t) + os.Setenv("TALOSCONFIG", "/tmp/talosconfig") + defer os.Unsetenv("TALOSCONFIG") + + ctx := context.Background() + nodeAddresses := []string{"10.0.0.1", "10.0.0.2"} + expectedVersion := "1.0.0" + + err := client.WaitForNodesHealthy(ctx, nodeAddresses, expectedVersion) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + t.Run("SuccessWithoutVersion", func(t *testing.T) { + client := setup(t) + os.Setenv("TALOSCONFIG", "/tmp/talosconfig") + defer os.Unsetenv("TALOSCONFIG") + + ctx := context.Background() + nodeAddresses := []string{"10.0.0.1"} + + err := client.WaitForNodesHealthy(ctx, nodeAddresses, "") + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + t.Run("EnsureClientError", func(t *testing.T) { + client := setup(t) + + ctx := context.Background() + nodeAddresses := []string{"10.0.0.1"} + + err := client.WaitForNodesHealthy(ctx, nodeAddresses, "") + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "TALOSCONFIG environment variable not set") { + t.Errorf("Expected TALOSCONFIG error, got %v", err) + } + }) + + t.Run("HealthCheckError", func(t *testing.T) { + client := setup(t) + os.Setenv("TALOSCONFIG", "/tmp/talosconfig") + defer os.Unsetenv("TALOSCONFIG") + + client.shims.TalosServiceList = func(ctx context.Context, client *talosclient.Client) (*machine.ServiceListResponse, error) { + return nil, fmt.Errorf("service list error") + } + + ctx := context.Background() + nodeAddresses := []string{"10.0.0.1"} + + err := client.WaitForNodesHealthy(ctx, nodeAddresses, "") + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "timeout waiting for nodes") { + t.Errorf("Expected timeout error, got %v", err) + } + }) + + t.Run("UnhealthyServices", func(t *testing.T) { + client := setup(t) + os.Setenv("TALOSCONFIG", "/tmp/talosconfig") + defer os.Unsetenv("TALOSCONFIG") + + client.shims.TalosServiceList = func(ctx context.Context, client *talosclient.Client) (*machine.ServiceListResponse, error) { + return &machine.ServiceListResponse{ + Messages: []*machine.ServiceList{ + { + Services: []*machine.ServiceInfo{ + { + Id: "apid", + State: "Stopped", + Health: &machine.ServiceHealth{ + Healthy: false, + }, + }, + }, + }, + }, + }, nil + } + + ctx := context.Background() + nodeAddresses := []string{"10.0.0.1"} + + err := client.WaitForNodesHealthy(ctx, nodeAddresses, "") + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "unhealthy nodes") { + t.Errorf("Expected unhealthy nodes error, got %v", err) + } + }) + + t.Run("VersionMismatch", func(t *testing.T) { + client := setup(t) + os.Setenv("TALOSCONFIG", "/tmp/talosconfig") + defer os.Unsetenv("TALOSCONFIG") + + client.shims.TalosVersion = func(ctx context.Context, client *talosclient.Client) (*machine.VersionResponse, error) { + return &machine.VersionResponse{ + Messages: []*machine.Version{ + { + Version: &machine.VersionInfo{ + Tag: "v1.1.0", + }, + }, + }, + }, nil + } + + ctx := context.Background() + nodeAddresses := []string{"10.0.0.1"} + expectedVersion := "1.0.0" + + err := client.WaitForNodesHealthy(ctx, nodeAddresses, expectedVersion) + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "version mismatch nodes") { + t.Errorf("Expected version mismatch error, got %v", err) + } + }) + + t.Run("VersionError", func(t *testing.T) { + client := setup(t) + os.Setenv("TALOSCONFIG", "/tmp/talosconfig") + defer os.Unsetenv("TALOSCONFIG") + + client.shims.TalosVersion = func(ctx context.Context, client *talosclient.Client) (*machine.VersionResponse, error) { + return nil, fmt.Errorf("version error") + } + + ctx := context.Background() + nodeAddresses := []string{"10.0.0.1"} + expectedVersion := "1.0.0" + + err := client.WaitForNodesHealthy(ctx, nodeAddresses, expectedVersion) + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "timeout waiting for nodes") { + t.Errorf("Expected timeout error, got %v", err) + } + }) + + t.Run("ContextCancelled", func(t *testing.T) { + client := setup(t) + os.Setenv("TALOSCONFIG", "/tmp/talosconfig") + defer os.Unsetenv("TALOSCONFIG") + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + nodeAddresses := []string{"10.0.0.1"} + + err := client.WaitForNodesHealthy(ctx, nodeAddresses, "") + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "timeout waiting for nodes to be ready") { + t.Errorf("Expected timeout error, got %v", err) + } + }) +} + +func TestTalosClusterClient_Close(t *testing.T) { + t.Run("Success", func(t *testing.T) { + client := NewTalosClusterClient(di.NewMockInjector()) + + // Test that Close doesn't panic when client is nil + client.client = nil + + client.Close() + + if client.client != nil { + t.Error("Expected client to remain nil after Close") + } + }) + + t.Run("SuccessWithClient", func(t *testing.T) { + client := NewTalosClusterClient(di.NewMockInjector()) + client.shims = setupShims(t) + + // Set up a mock client (we'll use a non-nil pointer to simulate having a client) + mockClient := &talosclient.Client{} + client.client = mockClient + + // Mock the TalosClose function to track if it was called + closeCalled := false + client.shims.TalosClose = func(c *talosclient.Client) { + closeCalled = true + } + + client.Close() + + // Verify TalosClose was called + if !closeCalled { + t.Error("Expected TalosClose to be called") + } + + // Verify client is set to nil + if client.client != nil { + t.Error("Expected client to be nil after Close") + } + }) + + t.Run("NoClient", func(t *testing.T) { + client := NewTalosClusterClient(di.NewMockInjector()) + + client.Close() + }) +} + +// ============================================================================= +// Test Private Methods +// ============================================================================= + +func TestTalosClusterClient_ensureClient(t *testing.T) { + setup := func(t *testing.T) *TalosClusterClient { + t.Helper() + client := NewTalosClusterClient(di.NewMockInjector()) + client.shims = setupShims(t) + return client + } + + t.Run("Success", func(t *testing.T) { + client := setup(t) + os.Setenv("TALOSCONFIG", "/tmp/talosconfig") + defer os.Unsetenv("TALOSCONFIG") + + err := client.ensureClient() + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if client.client == nil { + t.Error("Expected client to be set") + } + if client.config == nil { + t.Error("Expected config to be set") + } + }) + + t.Run("ClientAlreadyExists", func(t *testing.T) { + client := setup(t) + client.client = &talosclient.Client{} + + err := client.ensureClient() + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + t.Run("MissingTalosConfig", func(t *testing.T) { + client := setup(t) + + err := client.ensureClient() + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "TALOSCONFIG environment variable not set") { + t.Errorf("Expected TALOSCONFIG error, got %v", err) + } + }) + + t.Run("ConfigOpenError", func(t *testing.T) { + client := setup(t) + os.Setenv("TALOSCONFIG", "/tmp/talosconfig") + defer os.Unsetenv("TALOSCONFIG") + + client.shims.TalosConfigOpen = func(configPath string) (*clientconfig.Config, error) { + return nil, fmt.Errorf("config open error") + } + + err := client.ensureClient() + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error loading Talos config") { + t.Errorf("Expected config loading error, got %v", err) + } + }) + + t.Run("ClientCreationError", func(t *testing.T) { + client := setup(t) + os.Setenv("TALOSCONFIG", "/tmp/talosconfig") + defer os.Unsetenv("TALOSCONFIG") + + client.shims.TalosNewClient = func(ctx context.Context, opts ...talosclient.OptionFunc) (*talosclient.Client, error) { + return nil, fmt.Errorf("client creation error") + } + + err := client.ensureClient() + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error creating Talos client") { + t.Errorf("Expected client creation error, got %v", err) + } + }) +} + +func TestTalosClusterClient_getNodeHealthDetails(t *testing.T) { + setup := func(t *testing.T) *TalosClusterClient { + t.Helper() + client := NewTalosClusterClient(di.NewMockInjector()) + client.shims = setupShims(t) + return client + } + + t.Run("Success", func(t *testing.T) { + client := setup(t) + ctx := context.Background() + nodeAddress := "10.0.0.1" + + healthy, healthyServices, unhealthyServices, err := client.getNodeHealthDetails(ctx, nodeAddress) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if !healthy { + t.Error("Expected node to be healthy") + } + if len(healthyServices) != 2 { + t.Errorf("Expected 2 healthy services, got %d", len(healthyServices)) + } + if len(unhealthyServices) != 0 { + t.Errorf("Expected 0 unhealthy services, got %d", len(unhealthyServices)) + } + }) + + t.Run("ServiceListError", func(t *testing.T) { + client := setup(t) + client.shims.TalosServiceList = func(ctx context.Context, client *talosclient.Client) (*machine.ServiceListResponse, error) { + return nil, fmt.Errorf("service list error") + } + + ctx := context.Background() + nodeAddress := "10.0.0.1" + + _, _, _, err := client.getNodeHealthDetails(ctx, nodeAddress) + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "service list error") { + t.Errorf("Expected service list error, got %v", err) + } + }) + + t.Run("UnhealthyServices", func(t *testing.T) { + client := setup(t) + client.shims.TalosServiceList = func(ctx context.Context, client *talosclient.Client) (*machine.ServiceListResponse, error) { + return &machine.ServiceListResponse{ + Messages: []*machine.ServiceList{ + { + Services: []*machine.ServiceInfo{ + { + Id: "apid", + State: "Running", + Health: &machine.ServiceHealth{ + Healthy: true, + }, + }, + { + Id: "machined", + State: "Stopped", + Health: &machine.ServiceHealth{ + Healthy: false, + }, + }, + }, + }, + }, + }, nil + } + + ctx := context.Background() + nodeAddress := "10.0.0.1" + + healthy, healthyServices, unhealthyServices, err := client.getNodeHealthDetails(ctx, nodeAddress) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if healthy { + t.Error("Expected node to be unhealthy") + } + if len(healthyServices) != 1 { + t.Errorf("Expected 1 healthy service, got %d", len(healthyServices)) + } + if len(unhealthyServices) != 1 { + t.Errorf("Expected 1 unhealthy service, got %d", len(unhealthyServices)) + } + }) + + t.Run("ServiceWithNilHealth", func(t *testing.T) { + client := setup(t) + client.shims.TalosServiceList = func(ctx context.Context, client *talosclient.Client) (*machine.ServiceListResponse, error) { + return &machine.ServiceListResponse{ + Messages: []*machine.ServiceList{ + { + Services: []*machine.ServiceInfo{ + { + Id: "service-with-nil-health", + State: "Running", + Health: nil, // nil health should be treated as unhealthy + }, + }, + }, + }, + }, nil + } + + ctx := context.Background() + nodeAddress := "10.0.0.1" + + healthy, healthyServices, unhealthyServices, err := client.getNodeHealthDetails(ctx, nodeAddress) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if healthy { + t.Error("Expected node to be unhealthy") + } + if len(healthyServices) != 0 { + t.Errorf("Expected 0 healthy services, got %d", len(healthyServices)) + } + if len(unhealthyServices) != 1 { + t.Errorf("Expected 1 unhealthy service, got %d", len(unhealthyServices)) + } + }) + + t.Run("ServiceNotRunning", func(t *testing.T) { + client := setup(t) + client.shims.TalosServiceList = func(ctx context.Context, client *talosclient.Client) (*machine.ServiceListResponse, error) { + return &machine.ServiceListResponse{ + Messages: []*machine.ServiceList{ + { + Services: []*machine.ServiceInfo{ + { + Id: "stopped-service", + State: "Stopped", + Health: &machine.ServiceHealth{ + Healthy: true, // healthy but not running should be unhealthy + }, + }, + }, + }, + }, + }, nil + } + + ctx := context.Background() + nodeAddress := "10.0.0.1" + + healthy, healthyServices, unhealthyServices, err := client.getNodeHealthDetails(ctx, nodeAddress) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if healthy { + t.Error("Expected node to be unhealthy") + } + if len(healthyServices) != 0 { + t.Errorf("Expected 0 healthy services, got %d", len(healthyServices)) + } + if len(unhealthyServices) != 1 { + t.Errorf("Expected 1 unhealthy service, got %d", len(unhealthyServices)) + } + }) +} + +func TestTalosClusterClient_getNodeVersion(t *testing.T) { + setup := func(t *testing.T) *TalosClusterClient { + t.Helper() + client := NewTalosClusterClient(di.NewMockInjector()) + client.shims = setupShims(t) + return client + } + + t.Run("Success", func(t *testing.T) { + client := setup(t) + ctx := context.Background() + nodeAddress := "10.0.0.1" + + version, err := client.getNodeVersion(ctx, nodeAddress) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if version != "1.0.0" { + t.Errorf("Expected version '1.0.0', got '%s'", version) + } + }) + + t.Run("VersionError", func(t *testing.T) { + client := setup(t) + client.shims.TalosVersion = func(ctx context.Context, client *talosclient.Client) (*machine.VersionResponse, error) { + return nil, fmt.Errorf("version error") + } + + ctx := context.Background() + nodeAddress := "10.0.0.1" + + _, err := client.getNodeVersion(ctx, nodeAddress) + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "version error") { + t.Errorf("Expected version error, got %v", err) + } + }) + + t.Run("VersionWithoutPrefix", func(t *testing.T) { + client := setup(t) + client.shims.TalosVersion = func(ctx context.Context, client *talosclient.Client) (*machine.VersionResponse, error) { + return &machine.VersionResponse{ + Messages: []*machine.Version{ + { + Version: &machine.VersionInfo{ + Tag: "1.2.3", + }, + }, + }, + }, nil + } + + ctx := context.Background() + nodeAddress := "10.0.0.1" + + version, err := client.getNodeVersion(ctx, nodeAddress) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if version != "1.2.3" { + t.Errorf("Expected version '1.2.3', got '%s'", version) + } + }) +} diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 3701b5648..8fd522345 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -24,6 +24,7 @@ const ( DEFAULT_TALOS_CONTROL_PLANE_CPU = 2 DEFAULT_TALOS_CONTROL_PLANE_RAM = 2 DEFAULT_TALOS_API_PORT = 50000 + GRPCMaxMessageSize = 32 * 1024 * 1024 // 32MB ) const ( @@ -89,3 +90,9 @@ const ( MINIMUM_VERSION_1PASSWORD = "2.15.0" MINIMUM_VERSION_AWS_CLI = "2.15.0" ) + +// Default node health check settings +const ( + DEFAULT_NODE_HEALTH_CHECK_TIMEOUT = 5 * time.Minute + DEFAULT_NODE_HEALTH_CHECK_POLL_INTERVAL = 10 * time.Second +) diff --git a/pkg/controller/controller.go b/pkg/controller/controller.go index 348d0618a..43f582c4e 100644 --- a/pkg/controller/controller.go +++ b/pkg/controller/controller.go @@ -8,6 +8,7 @@ import ( secretsConfigType "github.com/windsorcli/cli/api/v1alpha1/secrets" "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/env" @@ -59,6 +60,7 @@ type Controller interface { WriteConfigurationFiles() error SetEnvironmentVariables() error ResolveKubernetesManager() kubernetes.KubernetesManager + ResolveClusterClient() cluster.ClusterClient } // BaseController implements the Controller interface with default component management @@ -111,6 +113,8 @@ type ComponentConstructors struct { NewOnePasswordCLISecretsProvider func(secretsConfigType.OnePasswordVault, di.Injector) secrets.SecretsProvider NewWindsorStack func(di.Injector) stack.Stack + + NewTalosClusterClient func(di.Injector) *cluster.TalosClusterClient } // Requirements defines the operational requirements for the controller @@ -130,7 +134,7 @@ type Requirements struct { VM bool // Needs virtual machine capabilities Containers bool // Needs container runtime capabilities Network bool // Needs network management - Kubernetes bool // Needs Kubernetes manager + Cluster bool // Needs Kubernetes manager // Service requirements Services bool // Needs service management @@ -195,7 +199,6 @@ func NewDefaultConstructors() ComponentConstructors { NewKubernetesClient: func(injector di.Injector) kubernetes.KubernetesClient { return kubernetes.NewDynamicKubernetesClient() }, - NewAwsEnvPrinter: func(injector di.Injector) env.EnvPrinter { return env.NewAwsEnvPrinter(injector) }, @@ -269,6 +272,10 @@ func NewDefaultConstructors() ComponentConstructors { NewWindsorStack: func(injector di.Injector) stack.Stack { return stack.NewWindsorStack(injector) }, + + NewTalosClusterClient: func(injector di.Injector) *cluster.TalosClusterClient { + return cluster.NewTalosClusterClient(injector) + }, } } @@ -303,7 +310,7 @@ func (c *BaseController) CreateComponents() error { {"env", c.createEnvComponents}, {"secrets", c.createSecretsComponents}, {"generators", c.createGeneratorsComponents}, - {"kubernetes", c.createKubernetesComponents}, + {"kubernetes", c.createClusterComponents}, {"virtualization", c.createVirtualizationComponents}, {"service", c.createServiceComponents}, {"network", c.createNetworkComponents}, @@ -686,6 +693,14 @@ func (c *BaseController) ResolveKubernetesManager() kubernetes.KubernetesManager return manager } +// ResolveClusterClient returns the cluster client component +// It retrieves the cluster client from the dependency injection container +func (c *BaseController) ResolveClusterClient() cluster.ClusterClient { + instance := c.injector.Resolve("clusterClient") + client, _ := instance.(cluster.ClusterClient) + return client +} + // ============================================================================= // Private Methods // ============================================================================= @@ -1142,13 +1157,19 @@ func (c *BaseController) createStackComponent(req Requirements) error { return nil } -// createKubernetesComponents creates and initializes the Kubernetes manager component if required -// It sets up the Kubernetes manager for managing Kubernetes clusters -func (c *BaseController) createKubernetesComponents(req Requirements) error { - if !req.Kubernetes { +// createClusterComponents creates and initializes the cluster components if required +// It sets up the cluster manager for managing cluster operations +func (c *BaseController) createClusterComponents(req Requirements) error { + if !req.Cluster { return nil } + configHandler := c.ResolveConfigHandler() + if configHandler == nil { + return fmt.Errorf("config handler is nil") + } + + // Create Kubernetes components if existingManager := c.ResolveKubernetesManager(); existingManager != nil { return nil } @@ -1168,19 +1189,34 @@ func (c *BaseController) createKubernetesComponents(req Requirements) error { c.injector.Register("kubernetesClient", client) - manager := c.constructors.NewKubernetesManager(c.injector) - if manager == nil { + kubernetesManager := c.constructors.NewKubernetesManager(c.injector) + if kubernetesManager == nil { return fmt.Errorf("failed to create kubernetes components: NewKubernetesManager returned nil") } // Skip initialization during init command if c.requirements.CommandName != "init" { - if err := manager.Initialize(); err != nil { + if err := kubernetesManager.Initialize(); err != nil { return fmt.Errorf("failed to initialize kubernetes manager: %w", err) } } - c.injector.Register("kubernetesManager", manager) + c.injector.Register("kubernetesManager", kubernetesManager) + + // Create Talos components if configured + if configHandler.GetString("cluster.driver") == "talos" { + if c.constructors.NewTalosClusterClient == nil { + return fmt.Errorf("failed to create talos components: NewTalosClusterClient constructor is nil") + } + + talosClient := c.constructors.NewTalosClusterClient(c.injector) + if talosClient == nil { + return fmt.Errorf("failed to create talos components: NewTalosClusterClient returned nil") + } + + c.injector.Register("clusterClient", talosClient) + } + return nil } diff --git a/pkg/controller/controller_test.go b/pkg/controller/controller_test.go index dff351902..72686c585 100644 --- a/pkg/controller/controller_test.go +++ b/pkg/controller/controller_test.go @@ -3952,10 +3952,10 @@ func TestBaseController_createKubernetesComponents(t *testing.T) { t.Run("ReturnsErrorWhenKubernetesNotRequired", func(t *testing.T) { // Given a controller with Kubernetes not required controller, _ := setup(t, "") - req := Requirements{Kubernetes: false} + req := Requirements{Cluster: false} // When createKubernetesComponents is called - err := controller.createKubernetesComponents(req) + err := controller.createClusterComponents(req) // Then no error should be returned if err != nil { @@ -3967,10 +3967,10 @@ func TestBaseController_createKubernetesComponents(t *testing.T) { // Given a controller with nil Kubernetes manager constructor controller, _ := setup(t, "") controller.constructors.NewKubernetesManager = nil - req := Requirements{Kubernetes: true} + req := Requirements{Cluster: true} // When createKubernetesComponents is called - err := controller.createKubernetesComponents(req) + err := controller.createClusterComponents(req) // Then an error should be returned if err == nil { @@ -3985,10 +3985,10 @@ func TestBaseController_createKubernetesComponents(t *testing.T) { // Given a controller with nil Kubernetes client constructor controller, _ := setup(t, "") controller.constructors.NewKubernetesClient = nil - req := Requirements{Kubernetes: true} + req := Requirements{Cluster: true} // When createKubernetesComponents is called - err := controller.createKubernetesComponents(req) + err := controller.createClusterComponents(req) // Then an error should be returned if err == nil { @@ -4005,10 +4005,10 @@ func TestBaseController_createKubernetesComponents(t *testing.T) { controller.constructors.NewKubernetesClient = func(di.Injector) kubernetes.KubernetesClient { return nil } - req := Requirements{Kubernetes: true} + req := Requirements{Cluster: true} // When createKubernetesComponents is called - err := controller.createKubernetesComponents(req) + err := controller.createClusterComponents(req) // Then an error should be returned if err == nil { @@ -4025,10 +4025,10 @@ func TestBaseController_createKubernetesComponents(t *testing.T) { controller.constructors.NewKubernetesManager = func(di.Injector) kubernetes.KubernetesManager { return nil } - req := Requirements{Kubernetes: true} + req := Requirements{Cluster: true} // When createKubernetesComponents is called - err := controller.createKubernetesComponents(req) + err := controller.createClusterComponents(req) // Then an error should be returned if err == nil { @@ -4049,10 +4049,10 @@ func TestBaseController_createKubernetesComponents(t *testing.T) { controller.constructors.NewKubernetesManager = func(di.Injector) kubernetes.KubernetesManager { return mockManager } - req := Requirements{Kubernetes: true} + req := Requirements{Cluster: true} // When createKubernetesComponents is called - err := controller.createKubernetesComponents(req) + err := controller.createClusterComponents(req) // Then an error should be returned if err == nil { @@ -4074,10 +4074,10 @@ func TestBaseController_createKubernetesComponents(t *testing.T) { controller.constructors.NewKubernetesManager = func(di.Injector) kubernetes.KubernetesManager { return mockManager } - req := Requirements{Kubernetes: true} + req := Requirements{Cluster: true} // When createKubernetesComponents is called - err := controller.createKubernetesComponents(req) + err := controller.createClusterComponents(req) // Then no error should be returned if err != nil { diff --git a/pkg/controller/mock_controller.go b/pkg/controller/mock_controller.go index ce76e9ba0..a8d717406 100644 --- a/pkg/controller/mock_controller.go +++ b/pkg/controller/mock_controller.go @@ -5,6 +5,7 @@ import ( secretsConfigType "github.com/windsorcli/cli/api/v1alpha1/secrets" "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/env" @@ -45,6 +46,7 @@ type MockController struct { ResolveAllSecretsProvidersFunc func() []secrets.SecretsProvider ResolveKubernetesManagerFunc func() kubernetes.KubernetesManager ResolveKubernetesClientFunc func() kubernetes.KubernetesClient + ResolveClusterClientFunc func() cluster.ClusterClient WriteConfigurationFilesFunc func() error SetEnvironmentVariablesFunc func() error } @@ -195,6 +197,11 @@ func NewMockConstructors() ComponentConstructors { NewKubernetesClient: func(injector di.Injector) kubernetes.KubernetesClient { return kubernetes.NewMockKubernetesClient() }, + + // Cluster components + NewTalosClusterClient: func(injector di.Injector) *cluster.TalosClusterClient { + return cluster.NewTalosClusterClient(injector) + }, } } @@ -385,5 +392,13 @@ func (m *MockController) ResolveKubernetesManager() kubernetes.KubernetesManager return m.BaseController.ResolveKubernetesManager() } +// ResolveClusterClient implements the Controller interface +func (m *MockController) ResolveClusterClient() cluster.ClusterClient { + if m.ResolveClusterClientFunc != nil { + return m.ResolveClusterClientFunc() + } + return m.BaseController.ResolveClusterClient() +} + // Ensure MockController implements Controller var _ Controller = (*MockController)(nil) From 0a7ae231dc5ef9af39b5188dd6a58ba063e429ec Mon Sep 17 00:00:00 2001 From: Ryan VanGundy Date: Thu, 19 Jun 2025 18:02:21 -0400 Subject: [PATCH 2/2] Fix gosec --- cmd/check.go | 2 +- pkg/cluster/shims.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/check.go b/cmd/check.go index f78732135..020e7d31b 100644 --- a/cmd/check.go +++ b/cmd/check.go @@ -124,5 +124,5 @@ func init() { 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().StringVar(&nodeHealthVersion, "version", "", "Expected version to check against (optional)") - checkNodeHealthCmd.MarkFlagRequired("nodes") + _ = checkNodeHealthCmd.MarkFlagRequired("nodes") } diff --git a/pkg/cluster/shims.go b/pkg/cluster/shims.go index 37fa122c4..a9bb952db 100644 --- a/pkg/cluster/shims.go +++ b/pkg/cluster/shims.go @@ -48,7 +48,7 @@ func NewShims() *Shims { return c.ServiceList(ctx) }, TalosClose: func(c *client.Client) { - c.Close() + _ = c.Close() }, } }