├── main.go ├── types ├── Node.go └── Cluster.go ├── cmd ├── cluster │ ├── plugin │ │ ├── plugin.go │ │ ├── list.go │ │ ├── add.go │ │ ├── deps.go │ │ └── remove.go │ ├── list_test.go │ ├── cluster.go │ ├── list.go │ ├── clean.go │ ├── delete.go │ ├── create_test.go │ └── create.go └── root │ ├── root.go │ └── version.go ├── internal ├── installer │ ├── installer.go │ └── helm.go ├── plugins │ ├── plugin.go │ ├── ingress_test.go │ ├── plugin_test.go │ ├── certmanager.go │ ├── installer_tracker_test.go │ ├── tls_test.go │ ├── nginx.go │ ├── base.go │ ├── utils_test.go │ ├── argocd.go │ ├── nginx_test.go │ ├── installer_tracker.go │ ├── utils.go │ ├── integration_test.go │ ├── loadbalancer.go │ ├── dependency.go │ ├── dependency_test.go │ └── ingress.go ├── multipass │ ├── client_test.go │ └── client.go └── k8s │ └── client.go ├── .gitignore ├── pkg └── logger │ ├── logger_test.go │ └── logger.go ├── .github └── workflows │ ├── pr.yml │ └── release.yml ├── install.sh ├── Makefile ├── go.mod └── CONTRIBUTING.md /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/mrgb7/playground/cmd/root" 5 | ) 6 | 7 | func main() { 8 | root.Execute() 9 | } 10 | -------------------------------------------------------------------------------- /types/Node.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type Node struct { 4 | Name string 5 | Status string 6 | IP string 7 | CPUs int 8 | Memory string 9 | Disk string 10 | } 11 | -------------------------------------------------------------------------------- /cmd/cluster/plugin/plugin.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | var PluginCmd = &cobra.Command{ 8 | Use: "plugin", 9 | Short: "Manage plugins", 10 | Long: `Manage plugins for the cluster`, 11 | } 12 | 13 | func init() { 14 | } 15 | -------------------------------------------------------------------------------- /internal/installer/installer.go: -------------------------------------------------------------------------------- 1 | package installer 2 | 3 | type Installer interface { 4 | Install(options *InstallOptions) error 5 | UnInstall(options *InstallOptions) error 6 | } 7 | 8 | type InstallOptions struct { 9 | ApplicationName string 10 | RepoURL string 11 | Path string 12 | Version string 13 | Namespace string 14 | ChartName *string 15 | Values map[string]interface{} 16 | KubeConfig string 17 | RepoName string 18 | CRDsGroupVersion string 19 | } 20 | -------------------------------------------------------------------------------- /cmd/cluster/list_test.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/mrgb7/playground/internal/multipass" 7 | ) 8 | 9 | func TestListClusters(t *testing.T) { 10 | client := multipass.NewMultipassClient() 11 | 12 | if !client.IsMultipassInstalled() { 13 | t.Skip("Multipass is not installed, skipping test") 14 | } 15 | 16 | clusters, err := client.ListClusters() 17 | if err != nil { 18 | t.Fatalf("ListClusters failed: %v", err) 19 | } 20 | 21 | t.Logf("Found %d clusters: %v", len(clusters), clusters) 22 | } 23 | -------------------------------------------------------------------------------- /cmd/cluster/cluster.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "github.com/mrgb7/playground/cmd/cluster/plugin" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | var ClusterCmd = &cobra.Command{ 9 | Use: "cluster", 10 | Short: "Manage clusters", 11 | Long: `Commands to create, delete, and get information about clusters`, 12 | } 13 | 14 | func init() { 15 | ClusterCmd.AddCommand(plugin.PluginCmd) 16 | ClusterCmd.AddCommand(createCmd) 17 | ClusterCmd.AddCommand(deleteCmd) 18 | ClusterCmd.AddCommand(cleanCmd) 19 | ClusterCmd.AddCommand(listCmd) 20 | } 21 | -------------------------------------------------------------------------------- /cmd/root/root.go: -------------------------------------------------------------------------------- 1 | package root 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/mrgb7/playground/cmd/cluster" 7 | "github.com/mrgb7/playground/pkg/logger" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | var rootCmd = &cobra.Command{ 12 | Use: "playground", 13 | Short: "A brief description of your application", 14 | Long: `A longer description that spans multiple lines and likely contains 15 | examples and usage of using your application.`, 16 | Run: func(cmd *cobra.Command, args []string) { 17 | logger.Infoln("Hello from playground CLI!") 18 | }, 19 | } 20 | 21 | func Execute() { 22 | if err := rootCmd.Execute(); err != nil { 23 | logger.Errorln("Error: %v", err) 24 | os.Exit(1) 25 | } 26 | } 27 | 28 | func init() { 29 | rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") 30 | rootCmd.AddCommand(cluster.ClusterCmd) 31 | } 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exec 4 | *.exe~ 5 | *.dll 6 | *.so 7 | *.dylib 8 | playground 9 | 10 | # Test binary, built with `go test -c` 11 | *.test 12 | 13 | # Output of the go coverage tool 14 | *.out 15 | *.prof 16 | 17 | # Dependency directories 18 | vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | go.work.sum 23 | 24 | # IDE specific files 25 | .idea/ 26 | .vscode/ 27 | *.swp 28 | *.swo 29 | *~ 30 | 31 | # OS specific files 32 | .DS_Store 33 | .DS_Store? 34 | ._* 35 | .Spotlight-V100 36 | .Trashes 37 | Icon? 38 | ehthumbs.db 39 | Thumbs.db 40 | 41 | # Project specific build artifacts 42 | /dist/ 43 | /bin/ 44 | /release/ 45 | 46 | # Environment variables 47 | .env 48 | .envrc 49 | 50 | # Log files 51 | *.log 52 | 53 | # Temp directories 54 | /tmp/ 55 | /temp/ 56 | 57 | # Build artifacts 58 | playground 59 | /playground 60 | chaos.sh 61 | 62 | # Logs 63 | *.log 64 | 65 | # Coverage reports 66 | *.out 67 | *.html 68 | *.htm 69 | -------------------------------------------------------------------------------- /cmd/cluster/list.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "github.com/mrgb7/playground/internal/multipass" 5 | "github.com/mrgb7/playground/pkg/logger" 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | var listCmd = &cobra.Command{ 10 | Use: "list", 11 | Short: "List all existing clusters", 12 | Long: `List all existing clusters by finding multipass instances ending with '-master'`, 13 | Run: func(cmd *cobra.Command, args []string) { 14 | client := multipass.NewMultipassClient() 15 | 16 | if !client.IsMultipassInstalled() { 17 | logger.Errorln("Error: Multipass is not installed or not in PATH. Please install Multipass first.") 18 | return 19 | } 20 | 21 | clusters, err := client.ListClusters() 22 | if err != nil { 23 | logger.Errorln("Failed to list clusters: %v", err) 24 | return 25 | } 26 | 27 | if len(clusters) == 0 { 28 | logger.Infoln("No clusters found.") 29 | return 30 | } 31 | 32 | logger.Infoln("Available clusters:") 33 | for _, cluster := range clusters { 34 | logger.Infoln(" - %s", cluster) 35 | } 36 | }, 37 | } 38 | -------------------------------------------------------------------------------- /cmd/root/version.go: -------------------------------------------------------------------------------- 1 | package root 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | var ( 11 | // Version will be set at build time 12 | Version = "dev" 13 | // GitCommit will be set at build time 14 | GitCommit = "unknown" 15 | // BuildDate will be set at build time 16 | BuildDate = "unknown" 17 | ) 18 | 19 | var versionCmd = &cobra.Command{ 20 | Use: "version", 21 | Short: "Print the version number of playground", 22 | Long: `All software has versions. This is playground's.`, 23 | Run: func(cmd *cobra.Command, args []string) { 24 | fmt.Printf("playground version %s\n", Version) 25 | if verbose, _ := cmd.Flags().GetBool("verbose"); verbose { 26 | fmt.Printf("Git commit: %s\n", GitCommit) 27 | fmt.Printf("Build date: %s\n", BuildDate) 28 | fmt.Printf("Go version: %s\n", runtime.Version()) 29 | fmt.Printf("OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH) 30 | } 31 | }, 32 | } 33 | 34 | func init() { 35 | versionCmd.Flags().BoolP("verbose", "v", false, "Show verbose version information") 36 | rootCmd.AddCommand(versionCmd) 37 | } 38 | -------------------------------------------------------------------------------- /internal/plugins/plugin.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | type Plugin interface { 4 | GetName() string 5 | Install(kubeConfig, clusterName string, ensure ...bool) error 6 | Uninstall(kubeConfig, clusterName string, ensure ...bool) error 7 | Status() string 8 | GetOptions() PluginOptions 9 | } 10 | 11 | type PluginOptions struct { 12 | Version *string 13 | Namespace *string 14 | ChartName *string 15 | RepoName *string 16 | Repository *string 17 | releaseName *string 18 | ChartValues map[string]interface{} 19 | CRDsGroupVersion string 20 | } 21 | 22 | func CreatePluginsList(kubeConfig, masterClusterIP, clusterName string) ([]Plugin, error) { 23 | lb, err := NewLoadBalancer(kubeConfig, masterClusterIP, clusterName) 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | ingress, err := NewIngress(kubeConfig, clusterName) 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | tls, err := NewTLS(kubeConfig, clusterName) 34 | if err != nil { 35 | return nil, err 36 | } 37 | argocd, err := NewArgocd(kubeConfig) 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | return []Plugin{ 43 | argocd, 44 | NewCertManager(kubeConfig), 45 | lb, 46 | NewNginx(kubeConfig), 47 | ingress, 48 | tls, 49 | }, nil 50 | } 51 | -------------------------------------------------------------------------------- /cmd/cluster/plugin/list.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "github.com/mrgb7/playground/internal/plugins" 5 | "github.com/mrgb7/playground/pkg/logger" 6 | "github.com/mrgb7/playground/types" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | var listCmd = &cobra.Command{ 11 | Use: "list", 12 | Short: "List all available plugins", 13 | Long: `List all available plugins for the cluster`, 14 | Run: func(cmd *cobra.Command, args []string) { 15 | clusterName, _ := cmd.Flags().GetString("cluster-name") 16 | 17 | c := types.Cluster{ 18 | Name: clusterName, 19 | } 20 | 21 | if !c.IsExists() { 22 | logger.Errorln("Cluster '%s' does not exist. Please create it first.", clusterName) 23 | return 24 | } 25 | 26 | ip := c.GetMasterIP() 27 | if err := c.SetKubeConfig(); err != nil { 28 | logger.Errorln("Failed to set kubeconfig: %v", err) 29 | return 30 | } 31 | 32 | pluginsList, err := plugins.CreatePluginsList(c.KubeConfig, ip, c.Name) 33 | if err != nil { 34 | logger.Errorln("Failed to create plugins list: %v", err) 35 | return 36 | } 37 | 38 | logger.Infoln("Available plugins for cluster '%s':", clusterName) 39 | 40 | for _, plugin := range pluginsList { 41 | status := plugin.Status() 42 | logger.Infoln(" %s: %s", plugin.GetName(), status) 43 | } 44 | }, 45 | } 46 | 47 | func init() { 48 | listCmd.Flags().StringP("cluster-name", "c", "", "Cluster name to list plugins for") 49 | PluginCmd.AddCommand(listCmd) 50 | } 51 | -------------------------------------------------------------------------------- /cmd/cluster/clean.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/mrgb7/playground/internal/multipass" 7 | "github.com/mrgb7/playground/pkg/logger" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | var ( 12 | cPurge bool 13 | ) 14 | 15 | var cleanCmd = &cobra.Command{ 16 | Use: "clean", 17 | Short: "Clean up cluster resources", 18 | Long: `Clean up cluster resources, including stopping and removing nodes`, 19 | Run: func(cmd *cobra.Command, args []string) { 20 | var wg sync.WaitGroup 21 | client := multipass.NewMultipassClient() 22 | 23 | if !client.IsMultipassInstalled() { 24 | logger.Errorln("Error: Multipass is not installed or not in PATH. Please install Multipass first.") 25 | return 26 | } 27 | 28 | if len(args) > 0 { 29 | clusterName := args[0] 30 | logger.Infoln("Cleaning up resources for cluster '%s'...", clusterName) 31 | 32 | if err := client.DeleteCluster(clusterName, &wg); err != nil { 33 | logger.Errorln("Failed to clean up cluster: %v", err) 34 | return 35 | } 36 | wg.Wait() 37 | 38 | logger.Successln("Successfully cleaned up cluster '%s'", clusterName) 39 | } 40 | 41 | if cPurge || len(args) == 0 { 42 | logger.Infoln("Purging all deleted instances...") 43 | if err := client.PurgeNodes(); err != nil { 44 | logger.Errorln("Failed to purge deleted instances: %v", err) 45 | return 46 | } 47 | logger.Successln("Successfully purged all deleted instances") 48 | } 49 | }, 50 | } 51 | 52 | func init() { 53 | cleanCmd.Flags().BoolVarP(&cPurge, "purge", "p", false, "Purge all resources") 54 | } 55 | -------------------------------------------------------------------------------- /cmd/cluster/delete.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/mrgb7/playground/internal/multipass" 7 | "github.com/mrgb7/playground/pkg/logger" 8 | "github.com/mrgb7/playground/types" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | var deleteCmd = &cobra.Command{ 13 | Use: "delete", 14 | Short: "Delete an existing cluster", 15 | Long: `Delete an existing cluster by specifying its name`, 16 | Run: func(cmd *cobra.Command, args []string) { 17 | var wg sync.WaitGroup 18 | if len(args) < 1 { 19 | logger.Errorln("Error: Cluster name is required") 20 | if err := cmd.Help(); err != nil { 21 | logger.Errorln("Failed to show help: %v", err) 22 | } 23 | return 24 | } 25 | 26 | clusterToDelete := args[0] 27 | 28 | client := multipass.NewMultipassClient() 29 | 30 | if !client.IsMultipassInstalled() { 31 | logger.Errorln("Error: Multipass is not installed or not in PATH. Please install Multipass first.") 32 | return 33 | } 34 | 35 | if clusterToDelete == "" { 36 | logger.Errorln("Error: Please provide a valid cluster name to delete.") 37 | return 38 | } 39 | cl := types.Cluster{ 40 | Name: clusterToDelete, 41 | } 42 | if !cl.IsExists() { 43 | logger.Errorln("Error: Cluster '%s' does not exist.", clusterToDelete) 44 | return 45 | } 46 | if err := client.DeleteCluster(clusterToDelete, &wg); err != nil { 47 | logger.Errorln("Failed to delete cluster: %v", err) 48 | return 49 | } 50 | wg.Wait() 51 | 52 | logger.Infoln("Purging deleted instances...") 53 | if err := client.PurgeNodes(); err != nil { 54 | logger.Errorln("Failed to purge deleted instances: %v", err) 55 | return 56 | } 57 | 58 | logger.Successln("Successfully deleted cluster '%s'", clusterToDelete) 59 | }, 60 | } 61 | -------------------------------------------------------------------------------- /internal/plugins/ingress_test.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestIngressPluginInterface(t *testing.T) { 8 | // Test plugin creation with minimal kubeconfig 9 | plugin, err := NewIngress("dummy-kubeconfig", "test-cluster") 10 | if err != nil { 11 | // If k8s client creation fails (expected in test environment), 12 | // we can still test the interface methods that don't require k8s client 13 | t.Logf("K8s client creation failed (expected in test): %v", err) 14 | return 15 | } 16 | 17 | // Test plugin interface methods 18 | if plugin.GetName() != IngressName { 19 | t.Errorf("Expected plugin name '%s', got '%s'", IngressName, plugin.GetName()) 20 | } 21 | 22 | // Test plugin options 23 | options := plugin.GetOptions() 24 | if options.Namespace != nil && *options.Namespace != IngressNamespace { 25 | t.Errorf("Expected namespace '%s', got '%s'", IngressNamespace, *options.Namespace) 26 | } 27 | 28 | if options.Version != nil && *options.Version != IngressVersion { 29 | t.Errorf("Expected version '%s', got '%s'", IngressVersion, *options.Version) 30 | } 31 | 32 | // Test that chart-related methods return empty values (since this plugin doesn't use Helm) 33 | if options.ChartName != nil && *options.ChartName != "" { 34 | t.Errorf("Expected empty chart name, got '%s'", *options.ChartName) 35 | } 36 | 37 | if options.Repository != nil && *options.Repository != "" { 38 | t.Errorf("Expected empty repository, got '%s'", *options.Repository) 39 | } 40 | 41 | // Test that it implements the Plugin interface 42 | var _ Plugin = plugin 43 | } 44 | 45 | func TestIngressPluginConstants(t *testing.T) { 46 | // Test plugin constants 47 | if IngressNamespace != "ingress-system" { 48 | t.Errorf("Expected IngressNamespace to be 'ingress-system', got '%s'", IngressNamespace) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /internal/multipass/client_test.go: -------------------------------------------------------------------------------- 1 | package multipass 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestNewMultipassClient(t *testing.T) { 8 | client := NewMultipassClient() 9 | 10 | if client == nil { 11 | t.Fatal("NewMultipassClient() returned nil") 12 | } 13 | 14 | if client.BinaryPath != "multipass" { 15 | t.Errorf("Expected BinaryPath to be 'multipass', got: %s", client.BinaryPath) 16 | } 17 | } 18 | 19 | func TestMultipassClient_IsMultipassInstalled(t *testing.T) { 20 | client := NewMultipassClient() 21 | 22 | client.BinaryPath = "nonexistent-binary" 23 | if client.IsMultipassInstalled() { 24 | t.Error("Expected IsMultipassInstalled() to return false for nonexistent binary") 25 | } 26 | 27 | client.BinaryPath = "echo" 28 | if !client.IsMultipassInstalled() { 29 | t.Error("Expected IsMultipassInstalled() to return true for 'echo' command") 30 | } 31 | } 32 | 33 | func TestConstants(t *testing.T) { 34 | if DefaultMasterCPUs <= 0 { 35 | t.Errorf("DefaultMasterCPUs should be positive, got: %d", DefaultMasterCPUs) 36 | } 37 | 38 | if DefaultMasterMemory == "" { 39 | t.Error("DefaultMasterMemory should not be empty") 40 | } 41 | 42 | if DefaultMasterDisk == "" { 43 | t.Error("DefaultMasterDisk should not be empty") 44 | } 45 | 46 | if DefaultWorkerCPUs <= 0 { 47 | t.Errorf("DefaultWorkerCPUs should be positive, got: %d", DefaultWorkerCPUs) 48 | } 49 | 50 | if DefaultWorkerMemory == "" { 51 | t.Error("DefaultWorkerMemory should not be empty") 52 | } 53 | 54 | if DefaultWorkerDisk == "" { 55 | t.Error("DefaultWorkerDisk should not be empty") 56 | } 57 | } 58 | 59 | func TestMultipassClient_CreateNode_ValidatesInput(t *testing.T) { 60 | client := NewMultipassClient() 61 | client.BinaryPath = "nonexistent-binary" // Ensure it fails for the right reason 62 | 63 | err := client.CreateNode("", 1, "1G", "5G") 64 | if err == nil { 65 | t.Error("Expected CreateNode to fail with empty node name") 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /internal/plugins/plugin_test.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestCreatePluginsListIncludesIngress(t *testing.T) { 8 | // Test that CreatePluginsList includes the ingress plugin 9 | plugins, err := CreatePluginsList("dummy-kubeconfig", "192.168.1.100", "test-cluster") 10 | if err != nil { 11 | t.Logf("CreatePluginsList failed (expected in test environment): %v", err) 12 | return 13 | } 14 | 15 | // Check that ingress plugin is included 16 | found := false 17 | for _, plugin := range plugins { 18 | if plugin.GetName() == IngressName { 19 | found = true 20 | break 21 | } 22 | } 23 | 24 | if !found { 25 | t.Error("Ingress plugin not found in CreatePluginsList") 26 | t.Log("Available plugins:") 27 | for _, plugin := range plugins { 28 | t.Logf(" - %s", plugin.GetName()) 29 | } 30 | } 31 | } 32 | 33 | func TestCreatePluginsListIncludesTLS(t *testing.T) { 34 | plugins, err := CreatePluginsList("dummy-kubeconfig", "192.168.1.100", "test-cluster") 35 | if err != nil { 36 | t.Logf("CreatePluginsList failed (expected in test environment): %v", err) 37 | return 38 | } 39 | 40 | found := false 41 | for _, plugin := range plugins { 42 | if plugin.GetName() == TLSName { 43 | found = true 44 | break 45 | } 46 | } 47 | 48 | if !found { 49 | t.Error("TLS plugin not found in CreatePluginsList") 50 | t.Log("Available plugins:") 51 | for _, plugin := range plugins { 52 | t.Logf(" - %s", plugin.GetName()) 53 | } 54 | } 55 | } 56 | 57 | func TestPluginNames(t *testing.T) { 58 | expectedPlugins := []string{ 59 | "argocd", 60 | "cert-manager", 61 | "load-balancer", 62 | "nginx-ingress", 63 | IngressName, 64 | TLSName, 65 | } 66 | 67 | plugins, err := CreatePluginsList("dummy-kubeconfig", "192.168.1.100", "test-cluster") 68 | if err != nil { 69 | t.Logf("CreatePluginsList failed (expected in test environment): %v", err) 70 | return 71 | } 72 | 73 | if len(plugins) != len(expectedPlugins) { 74 | t.Errorf("Expected %d plugins, got %d", len(expectedPlugins), len(plugins)) 75 | } 76 | 77 | pluginNames := make(map[string]bool) 78 | for _, plugin := range plugins { 79 | pluginNames[plugin.GetName()] = true 80 | } 81 | 82 | for _, expected := range expectedPlugins { 83 | if !pluginNames[expected] { 84 | t.Errorf("Expected plugin '%s' not found", expected) 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /cmd/cluster/plugin/add.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "github.com/mrgb7/playground/internal/plugins" 5 | "github.com/mrgb7/playground/pkg/logger" 6 | "github.com/mrgb7/playground/types" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | var ( 11 | pName string 12 | cName string 13 | ) 14 | 15 | var addCmd = &cobra.Command{ 16 | Use: "add", 17 | Short: "Add a new plugin", 18 | Long: `Add a new plugin to the cluster with automatic dependency resolution`, 19 | Run: func(cmd *cobra.Command, args []string) { 20 | c := types.Cluster{ 21 | Name: cName, 22 | } 23 | 24 | ip := c.GetMasterIP() 25 | if err := c.SetKubeConfig(); err != nil { 26 | logger.Errorln("Failed to set kubeconfig: %v", err) 27 | return 28 | } 29 | 30 | installOrder, err := plugins.ValidateAndGetInstallOrder(pName, c.KubeConfig, ip, c.Name) 31 | if err != nil { 32 | logger.Errorln("Dependency validation failed: %v", err) 33 | return 34 | } 35 | 36 | logger.Infoln("Plugin installation order: %v", installOrder) 37 | 38 | pluginsList, err := plugins.CreatePluginsList(c.KubeConfig, ip, c.Name) 39 | if err != nil { 40 | logger.Errorln("Failed to create plugins list: %v", err) 41 | return 42 | } 43 | 44 | pluginMap := make(map[string]plugins.Plugin) 45 | for _, plugin := range pluginsList { 46 | pluginMap[plugin.GetName()] = plugin 47 | } 48 | 49 | for _, pluginName := range installOrder { 50 | plugin, exists := pluginMap[pluginName] 51 | if !exists { 52 | logger.Errorln("Plugin %s not found", pluginName) 53 | return 54 | } 55 | status := plugin.Status() 56 | if plugins.IsPluginInstalled(status) { 57 | continue 58 | } 59 | 60 | logger.Infoln("Installing plugin: %s", pluginName) 61 | err := plugin.Install(c.KubeConfig, c.Name, true) 62 | if err != nil { 63 | logger.Errorln("Error installing plugin %s: %v", pluginName, err) 64 | return 65 | } 66 | logger.Successln("Successfully installed %s", pluginName) 67 | } 68 | 69 | logger.Successln("All plugins installed successfully!") 70 | }, 71 | } 72 | 73 | func init() { 74 | flags := addCmd.Flags() 75 | flags.StringVarP(&pName, "name", "n", "", "Name of the plugin") 76 | flags.StringVarP(&cName, "cluster", "c", "", "Name of the cluster") 77 | if err := addCmd.MarkFlagRequired("name"); err != nil { 78 | logger.Errorln("Failed to mark name flag as required: %v", err) 79 | } 80 | if err := addCmd.MarkFlagRequired("cluster"); err != nil { 81 | logger.Errorln("Failed to mark cluster flag as required: %v", err) 82 | } 83 | PluginCmd.AddCommand(addCmd) 84 | } 85 | -------------------------------------------------------------------------------- /cmd/cluster/plugin/deps.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "github.com/mrgb7/playground/internal/plugins" 5 | "github.com/mrgb7/playground/pkg/logger" 6 | "github.com/mrgb7/playground/types" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | var depsCmd = &cobra.Command{ 11 | Use: "deps", 12 | Short: "Show plugin dependencies", 13 | Long: `Show dependency information for plugins including dependencies and dependents`, 14 | Run: func(cmd *cobra.Command, args []string) { 15 | c := types.Cluster{ 16 | Name: cName, 17 | } 18 | 19 | ip := c.GetMasterIP() 20 | if err := c.SetKubeConfig(); err != nil { 21 | logger.Errorln("Failed to set kubeconfig: %v", err) 22 | return 23 | } 24 | 25 | dependencyPlugins, err := plugins.CreateDependencyPluginsList(c.KubeConfig, ip, c.Name) 26 | if err != nil { 27 | logger.Errorln("Failed to create dependency plugins list: %v", err) 28 | return 29 | } 30 | 31 | validator := plugins.NewDependencyValidator(dependencyPlugins) 32 | 33 | if pName != "" { 34 | dependencies, dependents := validator.GetDependencyInfo(pName) 35 | 36 | logger.Infoln("Plugin: %s", pName) 37 | if len(dependencies) > 0 { 38 | logger.Infoln(" Dependencies: %v", dependencies) 39 | } else { 40 | logger.Infoln(" Dependencies: none") 41 | } 42 | 43 | if len(dependents) > 0 { 44 | logger.Infoln(" Dependents: %v", dependents) 45 | } else { 46 | logger.Infoln(" Dependents: none") 47 | } 48 | } else { 49 | logger.Infoln("Plugin Dependency Information:") 50 | logger.Infoln("=============================") 51 | 52 | for _, plugin := range dependencyPlugins { 53 | name := plugin.GetName() 54 | dependencies, dependents := validator.GetDependencyInfo(name) 55 | 56 | logger.Infoln("") 57 | logger.Infoln("Plugin: %s", name) 58 | if len(dependencies) > 0 { 59 | logger.Infoln(" Dependencies: %v", dependencies) 60 | } else { 61 | logger.Infoln(" Dependencies: none") 62 | } 63 | 64 | if len(dependents) > 0 { 65 | logger.Infoln(" Dependents: %v", dependents) 66 | } else { 67 | logger.Infoln(" Dependents: none") 68 | } 69 | } 70 | } 71 | }, 72 | } 73 | 74 | func init() { 75 | flags := depsCmd.Flags() 76 | flags.StringVarP(&pName, "name", "n", "", "Name of the plugin (optional, shows all if not specified)") 77 | flags.StringVarP(&cName, "cluster", "c", "", "Name of the cluster") 78 | if err := depsCmd.MarkFlagRequired("cluster"); err != nil { 79 | logger.Errorln("Failed to mark cluster flag as required: %v", err) 80 | } 81 | PluginCmd.AddCommand(depsCmd) 82 | } 83 | -------------------------------------------------------------------------------- /cmd/cluster/plugin/remove.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "github.com/mrgb7/playground/internal/plugins" 5 | "github.com/mrgb7/playground/pkg/logger" 6 | "github.com/mrgb7/playground/types" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | var removeCmd = &cobra.Command{ 11 | Use: "remove", 12 | Short: "remove plugin", 13 | Long: `Remove plugin from the cluster with automatic dependency resolution`, 14 | Run: func(cmd *cobra.Command, args []string) { 15 | c := types.Cluster{ 16 | Name: cName, 17 | } 18 | 19 | ip := c.GetMasterIP() 20 | if err := c.SetKubeConfig(); err != nil { 21 | logger.Errorln("Failed to set kubeconfig: %v", err) 22 | return 23 | } 24 | 25 | uninstallOrder, err := plugins.ValidateAndGetUninstallOrder(pName, c.KubeConfig, ip, c.Name) 26 | if err != nil { 27 | logger.Errorln("Dependency validation failed: %v", err) 28 | return 29 | } 30 | 31 | logger.Infoln("Plugin uninstallation order: %v", uninstallOrder) 32 | 33 | pluginsList, err := plugins.CreatePluginsList(c.KubeConfig, ip, c.Name) 34 | if err != nil { 35 | logger.Errorln("Failed to create plugins list: %v", err) 36 | return 37 | } 38 | 39 | pluginMap := make(map[string]plugins.Plugin) 40 | for _, plugin := range pluginsList { 41 | pluginMap[plugin.GetName()] = plugin 42 | } 43 | 44 | for _, pluginName := range uninstallOrder { 45 | plugin, exists := pluginMap[pluginName] 46 | if !exists { 47 | logger.Warnf("Plugin '%s' not found in available plugins", pluginName) 48 | continue 49 | } 50 | 51 | if !plugins.IsPluginInstalled(plugin.Status()) { 52 | logger.Infof("Plugin '%s' is not installed, skipping", pluginName) 53 | continue 54 | } 55 | 56 | logger.Infoln("Uninstalling plugin: %s", pluginName) 57 | err := plugin.Uninstall(c.KubeConfig, c.Name) 58 | if err != nil { 59 | logger.Errorln("Error uninstalling plugin %s: %v", pluginName, err) 60 | return 61 | } 62 | logger.Successln("Successfully uninstalled %s", pluginName) 63 | } 64 | 65 | logger.Successln("All plugins uninstalled successfully!") 66 | }, 67 | } 68 | 69 | func init() { 70 | flags := removeCmd.Flags() 71 | flags.StringVarP(&pName, "name", "n", "", "Name of the plugin") 72 | flags.StringVarP(&cName, "cluster", "c", "", "Name of the cluster") 73 | if err := removeCmd.MarkFlagRequired("name"); err != nil { 74 | logger.Errorln("Failed to mark name flag as required: %v", err) 75 | } 76 | if err := removeCmd.MarkFlagRequired("cluster"); err != nil { 77 | logger.Errorln("Failed to mark cluster flag as required: %v", err) 78 | } 79 | PluginCmd.AddCommand(removeCmd) 80 | } 81 | -------------------------------------------------------------------------------- /internal/plugins/certmanager.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/mrgb7/playground/internal/k8s" 8 | "github.com/mrgb7/playground/pkg/logger" 9 | ) 10 | 11 | type CertManager struct { 12 | KubeConfig string 13 | *BasePlugin 14 | } 15 | 16 | var ( 17 | CertManagerRepoURL = "https://charts.jetstack.io" 18 | CertManagerChartName = "cert-manager" 19 | CertManagerChartVersion = "v1.17.2" 20 | CertManagerReleaseName = "cert-manager" 21 | CertManagerNamespace = "cert-manager" 22 | CertManagerRepoName = "jetstack" 23 | ) 24 | 25 | const ( 26 | DefaultWebhookTimeout = 10 27 | ) 28 | 29 | func NewCertManager(kubeConfig string) *CertManager { 30 | cm := &CertManager{ 31 | KubeConfig: kubeConfig, 32 | } 33 | cm.BasePlugin = NewBasePlugin(kubeConfig, cm) 34 | return cm 35 | } 36 | 37 | func (c *CertManager) GetOptions() PluginOptions { 38 | return PluginOptions{ 39 | Version: &CertManagerChartVersion, 40 | Namespace: &CertManagerNamespace, 41 | ChartName: &CertManagerChartName, 42 | RepoName: &CertManagerRepoName, 43 | Repository: &CertManagerRepoURL, 44 | ChartValues: c.getDefaultValues(), 45 | CRDsGroupVersion: "cert-manager.io", 46 | } 47 | } 48 | 49 | func (c *CertManager) GetName() string { 50 | return "cert-manager" 51 | } 52 | 53 | func (c *CertManager) Install(kubeConfig, clusterName string, ensure ...bool) error { 54 | return c.UnifiedInstall(kubeConfig, clusterName, ensure...) 55 | } 56 | 57 | func (c *CertManager) Uninstall(kubeConfig, clusterName string, ensure ...bool) error { 58 | return c.UnifiedUninstall(kubeConfig, clusterName, ensure...) 59 | } 60 | 61 | func (c *CertManager) getDefaultValues() map[string]interface{} { 62 | return map[string]interface{}{ 63 | "crds": map[string]interface{}{ 64 | "enabled": true, 65 | }, 66 | "prometheus": map[string]interface{}{ 67 | "enabled": true, 68 | }, 69 | "webhook": map[string]interface{}{ 70 | "timeoutSeconds": DefaultWebhookTimeout, 71 | }, 72 | } 73 | } 74 | 75 | func (c *CertManager) Status() string { 76 | client, err := k8s.NewK8sClient(c.KubeConfig) 77 | if err != nil { 78 | logger.Debugf("failed to create k8s client: %v", err) 79 | return StatusUnknown 80 | } 81 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 82 | defer cancel() 83 | 84 | ns, err := client.GetNameSpace(CertManagerNamespace, ctx) 85 | if ns == "" || err != nil { 86 | logger.Debugf("cert-manager namespace not found or error occurred: %v", err) 87 | return StatusNotInstalled 88 | } 89 | return StatusRunning 90 | } 91 | 92 | func (c *CertManager) GetDependencies() []string { 93 | return []string{} // cert-manager has no dependencies 94 | } 95 | -------------------------------------------------------------------------------- /internal/plugins/installer_tracker_test.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestInstallerTrackerConstants(t *testing.T) { 8 | if InstallerTrackerConfigMapName == "" { 9 | t.Error("InstallerTrackerConfigMapName should not be empty") 10 | } 11 | 12 | if InstallerTrackerNamespace == "" { 13 | t.Error("InstallerTrackerNamespace should not be empty") 14 | } 15 | 16 | if InstallerTypeHelm == "" { 17 | t.Error("InstallerTypeHelm should not be empty") 18 | } 19 | 20 | if InstallerTypeArgoCD == "" { 21 | t.Error("InstallerTypeArgoCD should not be empty") 22 | } 23 | 24 | // Verify expected values 25 | if InstallerTrackerConfigMapName != "playground-plugin-installer-tracker" { 26 | t.Errorf("Expected InstallerTrackerConfigMapName to be 'playground-plugin-installer-tracker', got '%s'", InstallerTrackerConfigMapName) 27 | } 28 | 29 | if InstallerTrackerNamespace != "kube-system" { 30 | t.Errorf("Expected InstallerTrackerNamespace to be 'kube-system', got '%s'", InstallerTrackerNamespace) 31 | } 32 | 33 | if InstallerTypeHelm != "helm" { 34 | t.Errorf("Expected InstallerTypeHelm to be 'helm', got '%s'", InstallerTypeHelm) 35 | } 36 | 37 | if InstallerTypeArgoCD != "argocd" { 38 | t.Errorf("Expected InstallerTypeArgoCD to be 'argocd', got '%s'", InstallerTypeArgoCD) 39 | } 40 | } 41 | 42 | func TestNewInstallerTracker(t *testing.T) { 43 | tests := []struct { 44 | name string 45 | kubeConfig string 46 | expectError bool 47 | }{ 48 | { 49 | name: "invalid kubeconfig", 50 | kubeConfig: "invalid-config", 51 | expectError: true, 52 | }, 53 | { 54 | name: "empty kubeconfig", 55 | kubeConfig: "", 56 | expectError: true, 57 | }, 58 | } 59 | 60 | for _, tt := range tests { 61 | t.Run(tt.name, func(t *testing.T) { 62 | tracker, err := NewInstallerTracker(tt.kubeConfig) 63 | 64 | if tt.expectError && err == nil { 65 | t.Errorf("Expected error but got none") 66 | } 67 | 68 | if !tt.expectError && err != nil { 69 | t.Errorf("Unexpected error: %v", err) 70 | } 71 | 72 | if !tt.expectError && tracker == nil { 73 | t.Errorf("Expected tracker but got nil") 74 | } 75 | 76 | if tt.expectError && tracker != nil { 77 | t.Errorf("Expected nil tracker but got one") 78 | } 79 | }) 80 | } 81 | } 82 | 83 | func TestInstallerTrackerStructure(t *testing.T) { 84 | // Test that we can create the tracker struct (even with invalid config for structure test) 85 | tracker := &InstallerTracker{ 86 | kubeConfig: "test-config", 87 | k8sClient: nil, // Will be nil with invalid config, which is fine for structure test 88 | } 89 | 90 | if tracker.kubeConfig != "test-config" { 91 | t.Errorf("Expected kubeConfig to be 'test-config', got '%s'", tracker.kubeConfig) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /internal/plugins/tls_test.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestTLSPluginInterface(t *testing.T) { 10 | plugin, err := NewTLS("dummy-kubeconfig", "test-cluster") 11 | if err != nil { 12 | t.Logf("K8s client creation failed (expected in test): %v", err) 13 | return 14 | } 15 | 16 | if plugin.GetName() != TLSName { 17 | t.Errorf("Expected plugin name '%s', got '%s'", TLSName, plugin.GetName()) 18 | } 19 | 20 | options := plugin.GetOptions() 21 | if options.Namespace != nil && *options.Namespace != CertManagerNamespace { 22 | t.Errorf("Expected namespace '%s', got '%v'", CertManagerNamespace, options.Namespace) 23 | } 24 | 25 | if options.Version != nil && *options.Version != TLSVersion { 26 | t.Errorf("Expected version '%s', got '%v'", TLSVersion, options.Version) 27 | } 28 | 29 | if options.ChartName != nil && *options.ChartName != "" { 30 | t.Errorf("Expected empty chart name, got '%s'", *options.ChartName) 31 | } 32 | 33 | if options.Repository != nil && *options.Repository != "" { 34 | t.Errorf("Expected empty repository, got '%s'", *options.Repository) 35 | } 36 | 37 | var _ Plugin = plugin 38 | } 39 | 40 | func TestTLSPluginConstants(t *testing.T) { 41 | if TLSName != "tls" { 42 | t.Errorf("Expected TLSName to be 'tls', got '%s'", TLSName) 43 | } 44 | 45 | if TLSVersion != "1.0.0" { 46 | t.Errorf("Expected TLSVersion to be '1.0.0', got '%s'", TLSVersion) 47 | } 48 | 49 | if TLSSecretName != "local-ca-secret" { 50 | t.Errorf("Expected TLSSecretName to be 'local-ca-secret', got '%s'", TLSSecretName) 51 | } 52 | 53 | if TLSClusterIssuerName != "local-ca-issuer" { 54 | t.Errorf("Expected TLSClusterIssuerName to be 'local-ca-issuer', got '%s'", TLSClusterIssuerName) 55 | } 56 | 57 | if CertValidityYears != 10 { 58 | t.Errorf("Expected CertValidityYears to be 10, got %d", CertValidityYears) 59 | } 60 | } 61 | 62 | func TestTLSGenerateCACertificate(t *testing.T) { 63 | plugin, err := NewTLS("dummy-kubeconfig", "test-cluster") 64 | if err != nil { 65 | t.Logf("K8s client creation failed (expected in test): %v", err) 66 | return 67 | } 68 | 69 | caCert, caKey, err := plugin.generateCACertificate() 70 | if err != nil { 71 | t.Errorf("Failed to generate CA certificate: %v", err) 72 | return 73 | } 74 | 75 | if len(caCert) == 0 { 76 | t.Error("CA certificate is empty") 77 | } 78 | 79 | if len(caKey) == 0 { 80 | t.Error("CA private key is empty") 81 | } 82 | 83 | if !containsPEMBlock(string(caCert), "CERTIFICATE") { 84 | t.Error("CA certificate does not contain proper PEM block") 85 | } 86 | 87 | if !containsPEMBlock(string(caKey), "RSA PRIVATE KEY") { 88 | t.Error("CA private key does not contain proper PEM block") 89 | } 90 | } 91 | 92 | func containsPEMBlock(content, blockType string) bool { 93 | return strings.Contains(content, fmt.Sprintf("-----BEGIN %s-----", blockType)) && 94 | strings.Contains(content, fmt.Sprintf("-----END %s-----", blockType)) 95 | } 96 | -------------------------------------------------------------------------------- /pkg/logger/logger_test.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/fatih/color" 7 | ) 8 | 9 | func TestLoggerFunctions(t *testing.T) { 10 | // Disable color for consistent testing 11 | color.NoColor = true 12 | defer func() { color.NoColor = false }() 13 | 14 | // Test that functions don't panic when called 15 | t.Run("Info", func(t *testing.T) { 16 | defer func() { 17 | if r := recover(); r != nil { 18 | t.Errorf("Info function panicked: %v", r) 19 | } 20 | }() 21 | Info("test message %s", "arg") 22 | }) 23 | 24 | t.Run("Infoln", func(t *testing.T) { 25 | defer func() { 26 | if r := recover(); r != nil { 27 | t.Errorf("Infoln function panicked: %v", r) 28 | } 29 | }() 30 | Infoln("test message %s", "arg") 31 | }) 32 | 33 | t.Run("Warn", func(t *testing.T) { 34 | defer func() { 35 | if r := recover(); r != nil { 36 | t.Errorf("Warn function panicked: %v", r) 37 | } 38 | }() 39 | Warn("warning: %s", "test") 40 | }) 41 | 42 | t.Run("Warnln", func(t *testing.T) { 43 | defer func() { 44 | if r := recover(); r != nil { 45 | t.Errorf("Warnln function panicked: %v", r) 46 | } 47 | }() 48 | Warnln("warning: %s", "test") 49 | }) 50 | 51 | t.Run("Error", func(t *testing.T) { 52 | defer func() { 53 | if r := recover(); r != nil { 54 | t.Errorf("Error function panicked: %v", r) 55 | } 56 | }() 57 | Error("error: %s", "test") 58 | }) 59 | 60 | t.Run("Errorln", func(t *testing.T) { 61 | defer func() { 62 | if r := recover(); r != nil { 63 | t.Errorf("Errorln function panicked: %v", r) 64 | } 65 | }() 66 | Errorln("error: %s", "test") 67 | }) 68 | 69 | t.Run("Debug", func(t *testing.T) { 70 | defer func() { 71 | if r := recover(); r != nil { 72 | t.Errorf("Debug function panicked: %v", r) 73 | } 74 | }() 75 | Debug("debug: %s", "test") 76 | }) 77 | 78 | t.Run("Debugln", func(t *testing.T) { 79 | defer func() { 80 | if r := recover(); r != nil { 81 | t.Errorf("Debugln function panicked: %v", r) 82 | } 83 | }() 84 | Debugln("debug: %s", "test") 85 | }) 86 | 87 | t.Run("Success", func(t *testing.T) { 88 | defer func() { 89 | if r := recover(); r != nil { 90 | t.Errorf("Success function panicked: %v", r) 91 | } 92 | }() 93 | Success("success: %s", "test") 94 | }) 95 | 96 | t.Run("Successln", func(t *testing.T) { 97 | defer func() { 98 | if r := recover(); r != nil { 99 | t.Errorf("Successln function panicked: %v", r) 100 | } 101 | }() 102 | Successln("success: %s", "test") 103 | }) 104 | 105 | t.Run("Print", func(t *testing.T) { 106 | defer func() { 107 | if r := recover(); r != nil { 108 | t.Errorf("Print function panicked: %v", r) 109 | } 110 | }() 111 | Print("plain: %s", "test") 112 | }) 113 | 114 | t.Run("Println", func(t *testing.T) { 115 | defer func() { 116 | if r := recover(); r != nil { 117 | t.Errorf("Println function panicked: %v", r) 118 | } 119 | }() 120 | Println("plain: %s", "test") 121 | }) 122 | } 123 | -------------------------------------------------------------------------------- /internal/plugins/nginx.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/mrgb7/playground/internal/k8s" 8 | "github.com/mrgb7/playground/pkg/logger" 9 | ) 10 | 11 | var ( 12 | DefaultNginxReplicas = 2 13 | NginxNamespace = "ingress-nginx" 14 | NginxChartVersion = "4.11.3" 15 | NginxChartName = "ingress-nginx" 16 | NginxRepoName = "ingress-nginx" 17 | NginxRepoURL = "https://kubernetes.github.io/ingress-nginx" 18 | ) 19 | 20 | type Nginx struct { 21 | KubeConfig string 22 | *BasePlugin 23 | } 24 | 25 | func NewNginx(kubeConfig string) *Nginx { 26 | nginx := &Nginx{ 27 | KubeConfig: kubeConfig, 28 | } 29 | nginx.BasePlugin = NewBasePlugin(kubeConfig, nginx) 30 | return nginx 31 | } 32 | 33 | func (n *Nginx) GetName() string { 34 | return "nginx-ingress" 35 | } 36 | 37 | func (n *Nginx) GetOptions() PluginOptions { 38 | return PluginOptions{ 39 | Version: &NginxChartVersion, 40 | Namespace: &NginxNamespace, 41 | ChartName: &NginxChartName, 42 | RepoName: &NginxRepoName, 43 | Repository: &NginxRepoURL, 44 | releaseName: &NginxChartName, 45 | ChartValues: n.GetChartValues(), 46 | CRDsGroupVersion: "networking.k8s.io", 47 | } 48 | } 49 | 50 | func (n *Nginx) Install(kubeConfig, clusterName string, ensure ...bool) error { 51 | return n.UnifiedInstall(kubeConfig, clusterName, ensure...) 52 | } 53 | 54 | func (n *Nginx) Uninstall(kubeConfig, clusterName string, ensure ...bool) error { 55 | return n.UnifiedUninstall(kubeConfig, clusterName, ensure...) 56 | } 57 | 58 | func (n *Nginx) Status() string { 59 | if n.KubeConfig == "" { 60 | logger.Errorf("kubeConfig is empty") 61 | return StatusUnknown 62 | } 63 | 64 | c, err := k8s.NewK8sClient(n.KubeConfig) 65 | if err != nil { 66 | logger.Debugf("failed to create k8s client: %v", err) 67 | return StatusUnknown 68 | } 69 | 70 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 71 | defer cancel() 72 | 73 | ns, err := c.GetNameSpace(NginxNamespace, ctx) 74 | if ns == "" || err != nil { 75 | logger.Debugf("nginx namespace not found or error occurred: %v", err) 76 | return StatusNotInstalled 77 | } 78 | return StatusRunning 79 | } 80 | 81 | func (n *Nginx) GetChartValues() map[string]interface{} { 82 | return map[string]interface{}{ 83 | "controller": map[string]interface{}{ 84 | "replicaCount": DefaultNginxReplicas, 85 | "service": map[string]interface{}{ 86 | "type": "LoadBalancer", 87 | }, 88 | "config": map[string]interface{}{ 89 | "enable-vts-status": "true", 90 | "use-forwarded-headers": "true", 91 | "compute-full-forwarded-for": "true", 92 | "use-proxy-protocol": "false", 93 | }, 94 | "metrics": map[string]interface{}{ 95 | "enabled": true, 96 | "serviceMonitor": map[string]interface{}{ 97 | "enabled": false, 98 | }, 99 | }, 100 | "admissionWebhooks": map[string]interface{}{ 101 | "enabled": false, 102 | }, 103 | }, 104 | "defaultBackend": map[string]interface{}{ 105 | "enabled": false, 106 | }, 107 | } 108 | } 109 | 110 | func (n *Nginx) GetDependencies() []string { 111 | return []string{"load-balancer"} // nginx-ingress depends on load-balancer 112 | } 113 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: Pull Request 2 | 3 | on: 4 | pull_request: 5 | branches: [ main ] 6 | 7 | jobs: 8 | test: 9 | name: Test 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v4 14 | 15 | - name: Set up Go 16 | uses: actions/setup-go@v5 17 | with: 18 | go-version: '1.24' 19 | cache: true 20 | 21 | - name: Download dependencies 22 | run: go mod download 23 | 24 | - name: Verify dependencies 25 | run: go mod verify 26 | 27 | - name: Run tests 28 | run: go test -v -race -coverprofile=coverage.out ./... 29 | 30 | - name: Upload coverage to Codecov 31 | uses: codecov/codecov-action@v4 32 | with: 33 | file: ./coverage.out 34 | flags: unittests 35 | name: codecov-umbrella 36 | 37 | format: 38 | name: Code Format 39 | runs-on: ubuntu-latest 40 | steps: 41 | - name: Checkout code 42 | uses: actions/checkout@v4 43 | 44 | - name: Set up Go 45 | uses: actions/setup-go@v5 46 | with: 47 | go-version: '1.24' 48 | cache: true 49 | 50 | - name: Check formatting 51 | run: | 52 | if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then 53 | echo "::error::Code is not formatted properly" 54 | gofmt -s -l . 55 | exit 1 56 | fi 57 | 58 | - name: Run go mod tidy 59 | run: | 60 | go mod tidy 61 | if [ -n "$(git status --porcelain)" ]; then 62 | echo "::error::go mod tidy resulted in changes" 63 | git status --porcelain 64 | exit 1 65 | fi 66 | 67 | lint: 68 | name: Lint 69 | runs-on: ubuntu-latest 70 | steps: 71 | - name: Checkout code 72 | uses: actions/checkout@v4 73 | 74 | - name: Set up Go 75 | uses: actions/setup-go@v5 76 | with: 77 | go-version: '1.24' 78 | cache: true 79 | 80 | - name: Run golangci-lint 81 | uses: golangci/golangci-lint-action@v6 82 | with: 83 | version: latest 84 | args: --timeout=5m 85 | 86 | security: 87 | name: Security Scan 88 | runs-on: ubuntu-latest 89 | steps: 90 | - name: Checkout code 91 | uses: actions/checkout@v4 92 | 93 | - name: Set up Go 94 | uses: actions/setup-go@v5 95 | with: 96 | go-version: '1.24' 97 | cache: true 98 | 99 | - name: Run Gosec Security Scanner 100 | uses: securego/gosec@master 101 | with: 102 | args: '-no-fail -fmt sarif -out results.sarif ./...' 103 | 104 | - name: Upload SARIF file 105 | uses: github/codeql-action/upload-sarif@v3 106 | with: 107 | sarif_file: results.sarif 108 | 109 | build: 110 | name: Build 111 | runs-on: ubuntu-latest 112 | steps: 113 | - name: Checkout code 114 | uses: actions/checkout@v4 115 | 116 | - name: Set up Go 117 | uses: actions/setup-go@v5 118 | with: 119 | go-version: '1.24' 120 | cache: true 121 | 122 | - name: Build for Linux 123 | run: make build 124 | 125 | - name: Build for multiple platforms 126 | run: make build-all 127 | 128 | - name: Upload build artifacts 129 | uses: actions/upload-artifact@v4 130 | with: 131 | name: binaries 132 | path: bin/ -------------------------------------------------------------------------------- /internal/plugins/base.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/mrgb7/playground/internal/installer" 7 | "github.com/mrgb7/playground/internal/k8s" 8 | "github.com/mrgb7/playground/pkg/logger" 9 | ) 10 | 11 | const ( 12 | StatusNotInstalled = "Not installed" 13 | StatusUnknown = "UNKNOWN" 14 | StatusRunning = "running" 15 | ) 16 | 17 | type BasePlugin struct { 18 | KubeConfig string 19 | plugin Plugin 20 | } 21 | 22 | func NewBasePlugin(kubeConfig string, plugin Plugin) *BasePlugin { 23 | return &BasePlugin{ 24 | KubeConfig: kubeConfig, 25 | plugin: plugin, 26 | } 27 | } 28 | 29 | func (b *BasePlugin) UnifiedInstall(kubeConfig, clusterName string, ensure ...bool) error { 30 | inst, err := NewInstaller(b.plugin, kubeConfig, clusterName) 31 | if err != nil { 32 | return fmt.Errorf("failed to create installer: %w", err) 33 | } 34 | 35 | var installerType string 36 | switch inst.(type) { 37 | case *installer.ArgoInstaller: 38 | installerType = InstallerTypeArgoCD 39 | case *installer.HelmInstaller: 40 | installerType = InstallerTypeHelm 41 | default: 42 | installerType = "unknown" 43 | } 44 | 45 | opts := newInstallOptions(b.plugin, kubeConfig) 46 | 47 | err = inst.Install(opts) 48 | if err != nil { 49 | return err 50 | } 51 | 52 | tracker, trackerErr := NewInstallerTracker(kubeConfig) 53 | if trackerErr != nil { 54 | logger.Warnln("Failed to create installer tracker after installing %s: %v", b.plugin.GetName(), trackerErr) 55 | } else { 56 | recordErr := tracker.RecordPluginInstaller(b.plugin.GetName(), installerType) 57 | if recordErr != nil { 58 | logger.Warnln("Failed to record installer type for %s: %v", b.plugin.GetName(), recordErr) 59 | } 60 | } 61 | if len(ensure) > 0 && ensure[0] { 62 | cl, err := k8s.NewK8sClient(kubeConfig) 63 | if err != nil { 64 | return fmt.Errorf("failed to create k8s client: %w", err) 65 | } 66 | opt := b.plugin.GetOptions() 67 | if err := <-cl.EnsureApp(*opt.Namespace, b.plugin.GetName()); err != nil { 68 | return fmt.Errorf("failed to ensure plugin %s in namespace %s: %w", b.plugin.GetName(), *opt.Namespace, err) 69 | } 70 | } 71 | 72 | return nil 73 | } 74 | 75 | func (b *BasePlugin) UnifiedUninstall(kubeConfig, clusterName string, ensure ...bool) error { 76 | inst, err := NewInstaller(b.plugin, kubeConfig, clusterName) 77 | if err != nil { 78 | return fmt.Errorf("failed to create installer: %w", err) 79 | } 80 | opts := newInstallOptions(b.plugin, kubeConfig) 81 | 82 | // Uninstall the plugin 83 | err = inst.UnInstall(opts) 84 | if err != nil { 85 | return err 86 | } 87 | 88 | tracker, trackerErr := NewInstallerTracker(kubeConfig) 89 | if trackerErr != nil { 90 | logger.Warnln("Failed to create installer tracker after uninstalling %s: %v", b.plugin.GetName(), trackerErr) 91 | } else { 92 | removeErr := tracker.RemovePluginInstaller(b.plugin.GetName()) 93 | if removeErr != nil { 94 | logger.Warnln("Failed to remove installer tracking for %s: %v", b.plugin.GetName(), removeErr) 95 | } 96 | } 97 | 98 | return nil 99 | } 100 | 101 | func newInstallOptions(plugin Plugin, kubeConfig string) *installer.InstallOptions { 102 | opt := plugin.GetOptions() 103 | chartName := opt.ChartName 104 | version := opt.Version 105 | return &installer.InstallOptions{ 106 | Namespace: *opt.Namespace, 107 | Values: opt.ChartValues, 108 | ChartName: chartName, 109 | RepoURL: *opt.Repository, 110 | ApplicationName: plugin.GetName(), 111 | Version: *version, 112 | KubeConfig: kubeConfig, 113 | RepoName: *opt.RepoName, 114 | CRDsGroupVersion: opt.CRDsGroupVersion, 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /pkg/logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | 8 | "github.com/fatih/color" 9 | ) 10 | 11 | var ( 12 | infoColor = color.New(color.FgGreen) 13 | warnColor = color.New(color.FgYellow) 14 | errorColor = color.New(color.FgRed) 15 | debugColor = color.New(color.FgCyan) 16 | successColor = color.New(color.FgGreen, color.Bold) 17 | enableDebug = false // Flag to enable/disable debug messages 18 | 19 | ) 20 | 21 | func init() { 22 | enableDebug = os.Getenv("LOG_LEVEL") == "debug" 23 | } 24 | 25 | // Info prints info message with format 26 | func Info(format string, args ...interface{}) { 27 | _, _ = infoColor.Printf(format+"\n", args...) 28 | } 29 | 30 | // Infof is an alias for Info for consistency 31 | func Infof(format string, args ...interface{}) { 32 | Info(format, args...) 33 | } 34 | 35 | // Infoln prints info message with newline 36 | func Infoln(format string, args ...interface{}) { 37 | _, _ = infoColor.Printf(format+"\n", args...) 38 | } 39 | 40 | // Warn prints warning message with format 41 | func Warn(format string, args ...interface{}) { 42 | _, _ = warnColor.Printf(format+"\n", args...) 43 | } 44 | 45 | // Warnf is an alias for Warn for consistency 46 | func Warnf(format string, args ...interface{}) { 47 | Warn(format, args...) 48 | } 49 | 50 | // Warnln prints warning message with newline 51 | func Warnln(format string, args ...interface{}) { 52 | _, _ = warnColor.Printf(format+"\n", args...) 53 | } 54 | 55 | // Error prints error message with format (suppressed in silent mode) 56 | func Error(format string, args ...interface{}) { 57 | _, _ = errorColor.Printf(format+"\n", args...) 58 | } 59 | 60 | // Errorf is an alias for Error for consistency 61 | func Errorf(format string, args ...interface{}) { 62 | Error(format, args...) 63 | } 64 | 65 | // Errorln prints error message with newline (suppressed in silent mode) 66 | func Errorln(format string, args ...interface{}) { 67 | _, _ = errorColor.Printf(format+"\n", args...) 68 | } 69 | 70 | // Debug prints debug message with format (suppressed in silent mode) 71 | func Debug(format string, args ...interface{}) { 72 | if enableDebug { 73 | _, _ = debugColor.Printf(format+"\n", args...) 74 | } 75 | } 76 | 77 | // Debugf is an alias for Debug for consistency 78 | func Debugf(format string, args ...interface{}) { 79 | if enableDebug { 80 | Debug(format, args...) 81 | } 82 | } 83 | 84 | // Debugln prints debug message with newline (suppressed in silent mode) 85 | func Debugln(format string, args ...interface{}) { 86 | if enableDebug { 87 | _, _ = debugColor.Printf(format+"\n", args...) 88 | } 89 | } 90 | 91 | // Success prints success message with format 92 | func Success(format string, args ...interface{}) { 93 | _, _ = successColor.Printf(format+"\n", args...) 94 | } 95 | 96 | // Successf is an alias for Success for consistency 97 | func Successf(format string, args ...interface{}) { 98 | Success(format, args...) 99 | } 100 | 101 | // Successln prints success message with newline 102 | func Successln(format string, args ...interface{}) { 103 | _, _ = successColor.Printf(format+"\n", args...) 104 | } 105 | 106 | // Fatal prints error message and exits 107 | func Fatal(format string, args ...interface{}) { 108 | _, _ = errorColor.Printf(format+"\n", args...) 109 | os.Exit(1) 110 | } 111 | 112 | // Print prints plain message with format 113 | func Print(format string, args ...interface{}) { 114 | fmt.Printf(format+"\n", args...) 115 | } 116 | 117 | // Println prints plain message with newline 118 | func Println(format string, args ...interface{}) { 119 | fmt.Printf(format+"\n", args...) 120 | } 121 | 122 | // GetWriter returns an io.Writer for use with external libraries 123 | func GetWriter() io.Writer { 124 | return os.Stdout 125 | } 126 | -------------------------------------------------------------------------------- /cmd/cluster/create_test.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/mrgb7/playground/types" 7 | ) 8 | 9 | func TestConstants(t *testing.T) { 10 | // Test that command constants are properly defined 11 | if K3sCreateMasterCmd == "" { 12 | t.Error("K3sCreateMasterCmd should not be empty") 13 | } 14 | 15 | if GetAccessTokenCmd == "" { 16 | t.Error("GetAccessTokenCmd should not be empty") 17 | } 18 | 19 | if K3sCreateWorkerCmd == "" { 20 | t.Error("K3sCreateWorkerCmd should not be empty") 21 | } 22 | 23 | if KubeConfigCmd == "" { 24 | t.Error("KubeConfigCmd should not be empty") 25 | } 26 | 27 | if K3sInstallTimeout <= 0 { 28 | t.Errorf("K3sInstallTimeout should be positive, got: %d", K3sInstallTimeout) 29 | } 30 | } 31 | 32 | func TestCreateCommandExists(t *testing.T) { 33 | if createCmd == nil { 34 | t.Fatal("createCmd should not be nil") 35 | } 36 | 37 | if createCmd.Use != "create" { 38 | t.Errorf("Expected createCmd.Use to be 'create', got: %s", createCmd.Use) 39 | } 40 | 41 | if createCmd.Short == "" { 42 | t.Error("createCmd.Short should not be empty") 43 | } 44 | } 45 | 46 | func TestValidateCPUCount(t *testing.T) { 47 | tests := []struct { 48 | name string 49 | cpus int 50 | nodeType string 51 | expectError bool 52 | }{ 53 | {"valid master CPU", 2, "master", false}, 54 | {"valid worker CPU", 1, "worker", false}, 55 | {"maximum CPU", 32, "master", false}, 56 | {"zero CPU", 0, "master", true}, 57 | {"negative CPU", -1, "worker", true}, 58 | {"excessive CPU", 33, "master", true}, 59 | } 60 | 61 | for _, tt := range tests { 62 | t.Run(tt.name, func(t *testing.T) { 63 | err := types.ValidateCPUCount(tt.cpus, tt.nodeType) 64 | if tt.expectError && err == nil { 65 | t.Errorf("Expected error for %d CPUs but got none", tt.cpus) 66 | } 67 | if !tt.expectError && err != nil { 68 | t.Errorf("Unexpected error for %d CPUs: %v", tt.cpus, err) 69 | } 70 | }) 71 | } 72 | } 73 | 74 | func TestValidateMemoryFormat(t *testing.T) { 75 | tests := []struct { 76 | name string 77 | memory string 78 | nodeType string 79 | expectError bool 80 | }{ 81 | {"valid memory G", "2G", "master", false}, 82 | {"valid memory M", "1024M", "worker", false}, 83 | {"invalid format K", "2K", "master", true}, 84 | {"invalid format no unit", "2", "master", true}, 85 | {"invalid format lowercase", "2g", "master", true}, 86 | {"invalid format empty", "", "master", true}, 87 | {"invalid format text", "abc", "master", true}, 88 | } 89 | 90 | for _, tt := range tests { 91 | t.Run(tt.name, func(t *testing.T) { 92 | err := types.ValidateMemoryFormat(tt.memory, tt.nodeType) 93 | if tt.expectError && err == nil { 94 | t.Errorf("Expected error for memory '%s' but got none", tt.memory) 95 | } 96 | if !tt.expectError && err != nil { 97 | t.Errorf("Unexpected error for memory '%s': %v", tt.memory, err) 98 | } 99 | }) 100 | } 101 | } 102 | 103 | func TestValidateDiskFormat(t *testing.T) { 104 | tests := []struct { 105 | name string 106 | disk string 107 | nodeType string 108 | expectError bool 109 | }{ 110 | {"valid disk G", "20G", "master", false}, 111 | {"valid disk M", "1024M", "worker", false}, 112 | {"valid disk T", "1T", "master", false}, 113 | {"invalid format K", "20K", "master", true}, 114 | {"invalid format no unit", "20", "master", true}, 115 | {"invalid format lowercase", "20g", "master", true}, 116 | {"invalid format empty", "", "master", true}, 117 | {"invalid format text", "abc", "master", true}, 118 | } 119 | 120 | for _, tt := range tests { 121 | t.Run(tt.name, func(t *testing.T) { 122 | err := types.ValidateDiskFormat(tt.disk, tt.nodeType) 123 | if tt.expectError && err == nil { 124 | t.Errorf("Expected error for disk '%s' but got none", tt.disk) 125 | } 126 | if !tt.expectError && err != nil { 127 | t.Errorf("Unexpected error for disk '%s': %v", tt.disk, err) 128 | } 129 | }) 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /internal/plugins/utils_test.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/mrgb7/playground/internal/installer" 7 | ) 8 | 9 | func TestIsArgoCDRunning(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | kubeConfig string 13 | expected bool 14 | }{ 15 | { 16 | name: "invalid kubeconfig", 17 | kubeConfig: "invalid-config", 18 | expected: false, 19 | }, 20 | { 21 | name: "empty kubeconfig", 22 | kubeConfig: "", 23 | expected: false, 24 | }, 25 | } 26 | 27 | for _, tt := range tests { 28 | t.Run(tt.name, func(t *testing.T) { 29 | result := IsArgoCDRunning(tt.kubeConfig) 30 | if result != tt.expected { 31 | t.Errorf("IsArgoCDRunning() = %v, expected %v", result, tt.expected) 32 | } 33 | }) 34 | } 35 | } 36 | 37 | func TestNewInstaller(t *testing.T) { 38 | tests := []struct { 39 | name string 40 | kubeConfig string 41 | clusterName string 42 | expectError bool 43 | pluginName string 44 | }{ 45 | { 46 | name: "invalid kubeconfig", 47 | kubeConfig: "invalid-config", 48 | clusterName: "test-cluster", 49 | expectError: false, 50 | pluginName: "test-plugin", 51 | }, 52 | { 53 | name: "empty cluster name", 54 | kubeConfig: createValidKubeConfig(), 55 | clusterName: "", 56 | expectError: false, 57 | pluginName: "test-plugin", 58 | }, 59 | } 60 | 61 | for _, tt := range tests { 62 | t.Run(tt.name, func(t *testing.T) { 63 | mock := &MockPlugin{name: tt.pluginName} 64 | inst, err := NewInstaller(mock, tt.kubeConfig, tt.clusterName) 65 | 66 | if tt.expectError && err == nil { 67 | t.Errorf("Expected error but got none") 68 | } 69 | 70 | if !tt.expectError && err != nil { 71 | t.Errorf("Unexpected error: %v", err) 72 | } 73 | 74 | if !tt.expectError && inst == nil { 75 | t.Errorf("Expected installer but got nil") 76 | } 77 | }) 78 | } 79 | } 80 | 81 | type MockPlugin struct { 82 | name string 83 | } 84 | 85 | func (m *MockPlugin) GetName() string { 86 | return m.name 87 | } 88 | 89 | func (m *MockPlugin) GetInstaller() (installer.Installer, error) { 90 | return &MockInstaller{}, nil 91 | } 92 | 93 | func (m *MockPlugin) Install(kubeConfig, clusterName string, ensure ...bool) error { 94 | return nil 95 | } 96 | 97 | func (m *MockPlugin) Uninstall(kubeConfig, clusterName string, ensure ...bool) error { 98 | return nil 99 | } 100 | 101 | func (m *MockPlugin) Status() string { 102 | return "mock status" 103 | } 104 | 105 | func (m *MockPlugin) GetOptions() PluginOptions { 106 | version := "1.0.0" 107 | namespace := "test-namespace" 108 | chartName := "test-chart" 109 | repository := "https://test.repo.com" 110 | repoName := "test-repo" 111 | return PluginOptions{ 112 | Version: &version, 113 | Namespace: &namespace, 114 | ChartName: &chartName, 115 | Repository: &repository, 116 | RepoName: &repoName, 117 | ChartValues: map[string]interface{}{"test": "value"}, 118 | } 119 | } 120 | 121 | func (m *MockPlugin) GetNamespace() string { 122 | return "test-namespace" 123 | } 124 | 125 | func (m *MockPlugin) GetVersion() string { 126 | return "1.0.0" 127 | } 128 | 129 | func (m *MockPlugin) GetChartName() string { 130 | return "test-chart" 131 | } 132 | 133 | func (m *MockPlugin) GetRepository() string { 134 | return "https://test.repo.com" 135 | } 136 | 137 | func (m *MockPlugin) GetChartValues() map[string]interface{} { 138 | return map[string]interface{}{"test": "value"} 139 | } 140 | 141 | func (m *MockPlugin) GetRepoName() string { 142 | return "test-repo" 143 | } 144 | 145 | type MockInstaller struct{} 146 | 147 | func (m *MockInstaller) Install(options *installer.InstallOptions) error { 148 | return nil 149 | } 150 | 151 | func (m *MockInstaller) UnInstall(options *installer.InstallOptions) error { 152 | return nil 153 | } 154 | 155 | func createValidKubeConfig() string { 156 | return ` 157 | apiVersion: v1 158 | kind: Config 159 | clusters: 160 | - cluster: 161 | server: https://kubernetes.docker.internal:6443 162 | insecure-skip-tls-verify: true 163 | name: test-cluster 164 | contexts: 165 | - context: 166 | cluster: test-cluster 167 | user: test-user 168 | name: test-context 169 | current-context: test-context 170 | users: 171 | - name: test-user 172 | user: 173 | token: test-token 174 | ` 175 | } 176 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | # Colors for output 6 | RED='\033[0;31m' 7 | GREEN='\033[0;32m' 8 | YELLOW='\033[1;33m' 9 | BLUE='\033[0;34m' 10 | NC='\033[0m' # No Color 11 | 12 | # Print colored output 13 | print_status() { 14 | echo -e "${BLUE}[INFO]${NC} $1" 15 | } 16 | 17 | print_success() { 18 | echo -e "${GREEN}[SUCCESS]${NC} $1" 19 | } 20 | 21 | print_warning() { 22 | echo -e "${YELLOW}[WARNING]${NC} $1" 23 | } 24 | 25 | print_error() { 26 | echo -e "${RED}[ERROR]${NC} $1" 27 | } 28 | 29 | # Detect platform 30 | detect_platform() { 31 | local os 32 | local arch 33 | 34 | os=$(uname -s | tr '[:upper:]' '[:lower:]') 35 | arch=$(uname -m) 36 | 37 | case "$os" in 38 | linux) 39 | os="linux" 40 | ;; 41 | darwin) 42 | os="darwin" 43 | ;; 44 | *) 45 | print_error "Unsupported operating system: $os" 46 | exit 1 47 | ;; 48 | esac 49 | 50 | case "$arch" in 51 | x86_64|amd64) 52 | arch="amd64" 53 | ;; 54 | arm64|aarch64) 55 | arch="arm64" 56 | ;; 57 | *) 58 | print_error "Unsupported architecture: $arch" 59 | exit 1 60 | ;; 61 | esac 62 | 63 | echo "${os}-${arch}" 64 | } 65 | 66 | # Get latest release tag 67 | get_latest_release() { 68 | curl -s https://api.github.com/repos/mrgb7/playground/releases/latest | grep tag_name | cut -d '"' -f 4 69 | } 70 | 71 | # Download and install binary 72 | install_playground() { 73 | local platform="$1" 74 | local version="$2" 75 | local temp_dir 76 | 77 | temp_dir=$(mktemp -d) 78 | cd "$temp_dir" 79 | 80 | local binary_name="playground-${platform}" 81 | local archive_name="playground-${version}-${platform}.tar.gz" 82 | local download_url="https://github.com/mrgb7/playground/releases/download/${version}/${archive_name}" 83 | 84 | print_status "Downloading playground ${version} for ${platform}..." 85 | if ! curl -L -o "$archive_name" "$download_url"; then 86 | print_error "Failed to download $download_url" 87 | exit 1 88 | fi 89 | 90 | print_status "Extracting archive..." 91 | if ! tar -xzf "$archive_name"; then 92 | print_error "Failed to extract archive" 93 | exit 1 94 | fi 95 | 96 | if [[ ! -f "$binary_name" ]]; then 97 | print_error "Binary $binary_name not found in archive" 98 | exit 1 99 | fi 100 | 101 | chmod +x "$binary_name" 102 | 103 | # Determine install location 104 | local install_dir="/usr/local/bin" 105 | if [[ ! -w "$install_dir" ]]; then 106 | print_status "Installing to $install_dir (requires sudo)..." 107 | sudo mv "$binary_name" "$install_dir/playground" 108 | else 109 | print_status "Installing to $install_dir..." 110 | mv "$binary_name" "$install_dir/playground" 111 | fi 112 | 113 | # Clean up 114 | cd / 115 | rm -rf "$temp_dir" 116 | 117 | print_success "playground installed successfully!" 118 | print_status "Run 'playground version' to verify the installation" 119 | } 120 | 121 | # Check dependencies 122 | check_dependencies() { 123 | if ! command -v curl >/dev/null 2>&1; then 124 | print_error "curl is required but not installed" 125 | exit 1 126 | fi 127 | 128 | if ! command -v tar >/dev/null 2>&1; then 129 | print_error "tar is required but not installed" 130 | exit 1 131 | fi 132 | } 133 | 134 | # Main installation function 135 | main() { 136 | print_status "Starting playground installation..." 137 | 138 | check_dependencies 139 | 140 | local platform 141 | platform=$(detect_platform) 142 | print_status "Detected platform: $platform" 143 | 144 | local version 145 | version=$(get_latest_release) 146 | print_status "Latest release: $version" 147 | 148 | install_playground "$platform" "$version" 149 | 150 | # Verify installation 151 | if command -v playground >/dev/null 2>&1; then 152 | print_success "Installation verified!" 153 | playground version 154 | else 155 | print_warning "Installation completed but 'playground' command not found in PATH" 156 | print_status "You may need to restart your shell or add /usr/local/bin to your PATH" 157 | fi 158 | } 159 | 160 | # Run main function 161 | main "$@" -------------------------------------------------------------------------------- /internal/plugins/argocd.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "context" 5 | "crypto/sha256" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | "time" 11 | 12 | "github.com/mrgb7/playground/internal/k8s" 13 | "github.com/mrgb7/playground/pkg/logger" 14 | "gopkg.in/yaml.v3" 15 | ) 16 | 17 | type Argocd struct { 18 | KubeConfig string 19 | *BasePlugin 20 | Tracker *InstallerTracker 21 | } 22 | 23 | var ( 24 | ArgocdRepoURL = "https://argoproj.github.io/argo-helm" 25 | ArgocdChartName = "argo-cd" 26 | ArgocdChartVersion = "8.0.0" 27 | ArgocdReleaseName = "argocd" 28 | ArgocdNamespace = "argocd" 29 | ArgoRepoName = "argo" 30 | ArgocdValuesFileURL = "https://raw.githubusercontent.com/mrgb7/core-infrastructure/" + 31 | "refs/heads/main/argocd/argocd-values-local.yaml" 32 | ) 33 | 34 | const ( 35 | HTTPTimeoutSeconds = 30 36 | MaxResponseSize = 10 * 1024 * 1024 37 | ) 38 | 39 | func NewArgocd(kubeConfig string) (*Argocd, error) { 40 | t, err := NewInstallerTracker(kubeConfig) 41 | if err != nil { 42 | return nil, fmt.Errorf("failed to create installer tracker: %w", err) 43 | } 44 | argo := &Argocd{ 45 | KubeConfig: kubeConfig, 46 | Tracker: t, 47 | } 48 | argo.BasePlugin = NewBasePlugin(kubeConfig, argo) 49 | return argo, nil 50 | } 51 | 52 | func (a *Argocd) GetName() string { 53 | return "argocd" 54 | } 55 | 56 | func (a *Argocd) GetOptions() PluginOptions { 57 | return PluginOptions{ 58 | Version: &ArgocdChartVersion, 59 | Namespace: &ArgocdNamespace, 60 | ChartName: &ArgocdChartName, 61 | RepoName: &ArgoRepoName, 62 | Repository: &ArgocdRepoURL, 63 | releaseName: &ArgocdReleaseName, 64 | ChartValues: a.getChartValues(), 65 | CRDsGroupVersion: "argoproj.io", 66 | } 67 | } 68 | 69 | func (a *Argocd) Install(kubeConfig, clusterName string, ensure ...bool) error { 70 | return a.UnifiedInstall(kubeConfig, clusterName, ensure...) 71 | } 72 | 73 | func (a *Argocd) Uninstall(kubeConfig, clusterName string, ensure ...bool) error { 74 | if err := a.checkUsage(); err != nil { 75 | return err 76 | } 77 | 78 | return a.UnifiedUninstall(kubeConfig, clusterName, ensure...) 79 | } 80 | 81 | func (a *Argocd) checkUsage() error { 82 | plugins, _ := a.Tracker.GetAllPluginByInstaller(a.GetName()) 83 | 84 | if len(plugins) > 0 { 85 | return fmt.Errorf("you cannot uninstall argocd because it is used by other plugins: %v", plugins) 86 | } 87 | return nil 88 | } 89 | 90 | func (a *Argocd) getValuesContent() (map[string]interface{}, error) { 91 | if _, err := url.Parse(ArgocdValuesFileURL); err != nil { 92 | return nil, fmt.Errorf("invalid values file URL: %w", err) 93 | } 94 | 95 | httpClient := &http.Client{ 96 | Timeout: HTTPTimeoutSeconds * time.Second, 97 | } 98 | 99 | ctx, cancel := context.WithTimeout(context.Background(), HTTPTimeoutSeconds*time.Second) 100 | defer cancel() 101 | 102 | req, err := http.NewRequestWithContext(ctx, "GET", ArgocdValuesFileURL, nil) 103 | if err != nil { 104 | return nil, fmt.Errorf("failed to create HTTP request: %w", err) 105 | } 106 | 107 | resp, err := httpClient.Do(req) 108 | if err != nil { 109 | return nil, fmt.Errorf("failed to fetch values file: %w", err) 110 | } 111 | defer func() { 112 | if err = resp.Body.Close(); err != nil { 113 | logger.Debugln("Failed to close response body: %v", err) 114 | } 115 | }() 116 | 117 | if resp.StatusCode != http.StatusOK { 118 | return nil, fmt.Errorf("failed to fetch values file: HTTP %d %s", resp.StatusCode, resp.Status) 119 | } 120 | 121 | limitedReader := io.LimitReader(resp.Body, MaxResponseSize) 122 | content, err := io.ReadAll(limitedReader) 123 | if err != nil { 124 | return nil, fmt.Errorf("failed to read response body: %w", err) 125 | } 126 | 127 | hash := sha256.Sum256(content) 128 | logger.Debugf("ArgoCD values file SHA256: %x", hash) 129 | 130 | var values map[string]interface{} 131 | if err := yaml.Unmarshal(content, &values); err != nil { 132 | return nil, fmt.Errorf("failed to unmarshal YAML content: %w", err) 133 | } 134 | 135 | return values, nil 136 | } 137 | 138 | func (a *Argocd) Status() string { 139 | c, err := k8s.NewK8sClient(a.KubeConfig) 140 | if err != nil { 141 | logger.Debugf("failed to create k8s client: %v", err) 142 | return StatusUnknown 143 | } 144 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 145 | defer cancel() 146 | ns, err := c.GetNameSpace(ArgocdNamespace, ctx) 147 | if ns == "" || err != nil { 148 | logger.Debugf("failed to get argocd namespace: %v", err) 149 | return StatusNotInstalled 150 | } 151 | return StatusRunning 152 | } 153 | 154 | func (a *Argocd) getChartValues() map[string]interface{} { 155 | val, err := a.getValuesContent() 156 | if err != nil { 157 | logger.Errorln("failed to get values content: %v", err) 158 | return nil 159 | } 160 | return val 161 | } 162 | 163 | func (a *Argocd) GetDependencies() []string { 164 | return []string{} 165 | } 166 | -------------------------------------------------------------------------------- /internal/plugins/nginx_test.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestNewNginx(t *testing.T) { 8 | kubeConfig := "test-config" 9 | nginx := NewNginx(kubeConfig) 10 | 11 | if nginx.KubeConfig != kubeConfig { 12 | t.Errorf("expected KubeConfig %s, got %s", kubeConfig, nginx.KubeConfig) 13 | } 14 | 15 | if nginx.BasePlugin == nil { 16 | t.Errorf("BasePlugin should not be nil") 17 | } 18 | } 19 | 20 | func TestNginx_GetName(t *testing.T) { 21 | nginx := NewNginx("") 22 | expected := "nginx-ingress" 23 | if nginx.GetName() != expected { 24 | t.Errorf("expected name %s, got %s", expected, nginx.GetName()) 25 | } 26 | } 27 | 28 | func TestNginx_GetNamespace(t *testing.T) { 29 | nginx := NewNginx("") 30 | options := nginx.GetOptions() 31 | expected := NginxNamespace 32 | if options.Namespace == nil || *options.Namespace != expected { 33 | t.Errorf("expected namespace %s, got %v", expected, options.Namespace) 34 | } 35 | } 36 | 37 | func TestNginx_GetVersion(t *testing.T) { 38 | nginx := NewNginx("") 39 | options := nginx.GetOptions() 40 | expected := NginxChartVersion 41 | if options.Version == nil || *options.Version != expected { 42 | t.Errorf("expected version %s, got %v", expected, options.Version) 43 | } 44 | } 45 | 46 | func TestNginx_GetChartName(t *testing.T) { 47 | nginx := NewNginx("") 48 | options := nginx.GetOptions() 49 | expected := NginxChartName 50 | if options.ChartName == nil || *options.ChartName != expected { 51 | t.Errorf("expected chart name %s, got %v", expected, options.ChartName) 52 | } 53 | } 54 | 55 | func TestNginx_GetRepository(t *testing.T) { 56 | nginx := NewNginx("") 57 | options := nginx.GetOptions() 58 | expected := "https://kubernetes.github.io/ingress-nginx" 59 | if options.Repository == nil || *options.Repository != expected { 60 | t.Errorf("expected repository %s, got %v", expected, options.Repository) 61 | } 62 | } 63 | 64 | func TestNginx_GetRepoName(t *testing.T) { 65 | nginx := NewNginx("") 66 | options := nginx.GetOptions() 67 | expected := NginxRepoName 68 | if options.RepoName == nil || *options.RepoName != expected { 69 | t.Errorf("expected repo name %s, got %v", expected, options.RepoName) 70 | } 71 | } 72 | 73 | func TestNginx_GetChartValues(t *testing.T) { 74 | nginx := NewNginx("") 75 | values := nginx.GetChartValues() 76 | 77 | if values == nil { 78 | t.Fatalf("GetChartValues should not return nil") 79 | } 80 | 81 | // Check that controller configuration exists 82 | controller, ok := values["controller"].(map[string]interface{}) 83 | if !ok { 84 | t.Fatalf("controller configuration should exist") 85 | } 86 | 87 | // Check replica count 88 | replicaCount, ok := controller["replicaCount"].(int) 89 | if !ok || replicaCount != DefaultNginxReplicas { 90 | t.Errorf("expected replicaCount %d, got %v", DefaultNginxReplicas, replicaCount) 91 | } 92 | 93 | // Check service configuration 94 | service, ok := controller["service"].(map[string]interface{}) 95 | if !ok { 96 | t.Fatalf("service configuration should exist") 97 | } 98 | 99 | serviceType, ok := service["type"].(string) 100 | if !ok || serviceType != "LoadBalancer" { 101 | t.Errorf("expected service type LoadBalancer, got %v", serviceType) 102 | } 103 | 104 | // Check that default backend is enabled 105 | defaultBackend, ok := values["defaultBackend"].(map[string]interface{}) 106 | if !ok { 107 | t.Fatalf("defaultBackend configuration should exist") 108 | } 109 | 110 | enabled, ok := defaultBackend["enabled"].(bool) 111 | if !ok || enabled { 112 | t.Errorf("expected defaultBackend to be enabled, got %v", enabled) 113 | } 114 | } 115 | 116 | func TestNginx_Constants(t *testing.T) { 117 | if DefaultNginxReplicas != 2 { 118 | t.Errorf("expected DefaultNginxReplicas to be 2, got %d", DefaultNginxReplicas) 119 | } 120 | 121 | if NginxNamespace != NginxChartName { 122 | t.Errorf("expected NginxNamespace to be '%s', got '%s'", NginxChartName, NginxNamespace) 123 | } 124 | 125 | if NginxChartVersion != "4.11.3" { 126 | t.Errorf("expected NginxChartVersion to be '4.11.3', got '%s'", NginxChartVersion) 127 | } 128 | } 129 | 130 | func TestNginx_Status_InvalidKubeConfig(t *testing.T) { 131 | nginx := NewNginx("invalid-config") 132 | status := nginx.Status() 133 | expected := StatusUnknown 134 | if status != expected { 135 | t.Errorf("expected status %s for invalid config, got %s", expected, status) 136 | } 137 | } 138 | 139 | func TestNginx_Status_EmptyKubeConfig(t *testing.T) { 140 | nginx := NewNginx("") 141 | status := nginx.Status() 142 | expected := StatusUnknown 143 | if status != expected { 144 | t.Errorf("expected status %s for empty config, got %s", expected, status) 145 | } 146 | } 147 | 148 | func TestNginx_Status_ValidConfig_NamespaceNotFound(t *testing.T) { 149 | nginx := NewNginx("~/.kube/config") 150 | status := nginx.Status() 151 | expected := StatusUnknown 152 | if status != expected { 153 | t.Errorf("expected status %s when kubeconfig is invalid, got %s", expected, status) 154 | } 155 | } 156 | 157 | func TestNginx_Status_Constants(t *testing.T) { 158 | if StatusRunning != "running" { 159 | t.Errorf("expected StatusRunning to be 'running', got '%s'", StatusRunning) 160 | } 161 | 162 | if StatusNotInstalled != "Not installed" { 163 | t.Errorf("expected StatusNotInstalled to be 'Not installed', got '%s'", StatusNotInstalled) 164 | } 165 | 166 | if StatusUnknown != "UNKNOWN" { 167 | t.Errorf("expected StatusUnknown to be 'UNKNOWN', got '%s'", StatusUnknown) 168 | } 169 | } 170 | 171 | func TestNginx_Status_Message_Format(t *testing.T) { 172 | nginx := NewNginx("valid-config") 173 | expectedFormat := nginx.GetName() + " is " + StatusRunning 174 | if expectedFormat != "nginx-ingress is running" { 175 | t.Errorf("expected status format 'nginx-ingress is running', got '%s'", expectedFormat) 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /types/Cluster.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | 8 | "github.com/mrgb7/playground/internal/multipass" 9 | ) 10 | 11 | type Cluster struct { 12 | Name string 13 | Nodes []*Node 14 | KubeConfig string 15 | } 16 | type ClusterConfig struct { 17 | Name string 18 | Size int 19 | WithCoreComponents bool 20 | MasterCPUs int 21 | MasterMemory string 22 | MasterDisk string 23 | WorkerCPUs int 24 | WorkerMemory string 25 | WorkerDisk string 26 | } 27 | 28 | const ( 29 | MaxClusterSize = 10 // maximum number of nodes allowed in cluster 30 | MaxClusterNameLength = 63 // maximum length for cluster name (DNS label limit) 31 | MinClusterSize = 1 // minimum number of nodes in cluster 32 | MaxCPUCount = 32 // maximum number of CPUs per node 33 | 34 | ) 35 | 36 | func NewCluster(name string) *Cluster { 37 | return &Cluster{ 38 | Name: name, 39 | } 40 | } 41 | 42 | func (c *Cluster) GetMaster() *Node { 43 | for _, node := range c.Nodes { 44 | if strings.HasSuffix(node.Name, "master") { 45 | return node 46 | } 47 | } 48 | return nil 49 | } 50 | 51 | func (c *Cluster) GetWorkers() []*Node { 52 | workers := []*Node{} 53 | for _, node := range c.Nodes { 54 | if !strings.HasSuffix(node.Name, "master") { 55 | workers = append(workers, node) 56 | } 57 | } 58 | return workers 59 | } 60 | 61 | func (c *Cluster) SetKubeConfig() error { 62 | masterNodeName := fmt.Sprintf("%s-master", c.Name) 63 | cl := multipass.NewMultipassClient() 64 | masterIP, err := cl.GetNodeIP(masterNodeName) 65 | if err != nil { 66 | return fmt.Errorf("failed to get master node IP: %w", err) 67 | } 68 | res, err := cl.ExecuteShell(masterNodeName, "sudo cat /etc/rancher/k3s/k3s.yaml") 69 | if err != nil { 70 | return fmt.Errorf("failed to get kubeconfig: %w", err) 71 | } 72 | res = strings.ReplaceAll(res, "127.0.0.1", masterIP) 73 | c.KubeConfig = res 74 | return nil 75 | } 76 | 77 | func (c *Cluster) GetMasterIP() string { 78 | masterNodeName := fmt.Sprintf("%s-master", c.Name) 79 | cl := multipass.NewMultipassClient() 80 | masterIP, err := cl.GetNodeIP(masterNodeName) 81 | if err != nil { 82 | return "" 83 | } 84 | return masterIP 85 | } 86 | 87 | func (c *Cluster) IsExists() bool { 88 | cl := multipass.NewMultipassClient() 89 | _, err := cl.GetClusterInfo(c.Name) 90 | return err == nil 91 | } 92 | 93 | func (c *Cluster) Validate(config ClusterConfig) error { 94 | if err := validateClusterName(config.Name); err != nil { 95 | return fmt.Errorf("invalid cluster name: %w", err) 96 | } 97 | 98 | if err := validateClusterSize(config.Size); err != nil { 99 | return fmt.Errorf("invalid cluster size: %w", err) 100 | } 101 | 102 | if err := ValidateCPUCount(config.MasterCPUs, "master"); err != nil { 103 | return fmt.Errorf("invalid master CPU count: %w", err) 104 | } 105 | 106 | if err := ValidateMemoryFormat(config.MasterMemory, "master"); err != nil { 107 | return fmt.Errorf("invalid master memory format: %w", err) 108 | } 109 | 110 | if err := ValidateDiskFormat(config.MasterDisk, "master"); err != nil { 111 | return fmt.Errorf("invalid master disk format: %w", err) 112 | } 113 | 114 | if err := ValidateCPUCount(config.WorkerCPUs, "worker"); err != nil { 115 | return fmt.Errorf("invalid worker CPU count: %w", err) 116 | } 117 | 118 | if err := ValidateMemoryFormat(config.WorkerMemory, "worker"); err != nil { 119 | return fmt.Errorf("invalid worker memory format: %w", err) 120 | } 121 | 122 | if err := ValidateDiskFormat(config.WorkerDisk, "worker"); err != nil { 123 | return fmt.Errorf("invalid worker disk format: %w", err) 124 | } 125 | 126 | return nil 127 | } 128 | 129 | func validateClusterName(name string) error { 130 | if name == "" { 131 | return fmt.Errorf("cluster name cannot be empty") 132 | } 133 | 134 | matched, err := regexp.MatchString(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`, name) 135 | if err != nil { 136 | return fmt.Errorf("error validating cluster name: %w", err) 137 | } 138 | 139 | if !matched { 140 | return fmt.Errorf("cluster name must start and end with alphanumeric characters " + 141 | "and contain only lowercase letters, numbers, and hyphens") 142 | } 143 | 144 | if len(name) > MaxClusterNameLength { 145 | return fmt.Errorf("cluster name must be %d characters or less", MaxClusterNameLength) 146 | } 147 | 148 | return nil 149 | } 150 | 151 | func validateClusterSize(size int) error { 152 | if size < MinClusterSize { 153 | return fmt.Errorf("cluster size must be at least %d", MinClusterSize) 154 | } 155 | 156 | if size > MaxClusterSize { 157 | return fmt.Errorf("cluster size cannot exceed %d nodes", MaxClusterSize) 158 | } 159 | 160 | return nil 161 | } 162 | 163 | func ValidateCPUCount(cpus int, nodeType string) error { 164 | if cpus < 1 { 165 | return fmt.Errorf("%s CPU count must be at least 1", nodeType) 166 | } 167 | if cpus > MaxCPUCount { 168 | return fmt.Errorf("%s CPU count cannot exceed %d", nodeType, MaxCPUCount) 169 | } 170 | return nil 171 | } 172 | 173 | func ValidateMemoryFormat(memory, nodeType string) error { 174 | matched, err := regexp.MatchString(`^[0-9]+[GM]$`, memory) 175 | if err != nil { 176 | return fmt.Errorf("error validating %s memory format: %w", nodeType, err) 177 | } 178 | if !matched { 179 | return fmt.Errorf("%s memory must be in format like '2G' or '1024M'", nodeType) 180 | } 181 | return nil 182 | } 183 | 184 | func ValidateDiskFormat(disk, nodeType string) error { 185 | matched, err := regexp.MatchString(`^[0-9]+[GMT]$`, disk) 186 | if err != nil { 187 | return fmt.Errorf("error validating %s disk format: %w", nodeType, err) 188 | } 189 | if !matched { 190 | return fmt.Errorf("%s disk must be in format like '20G', '1024M', or '1T'", nodeType) 191 | } 192 | return nil 193 | } 194 | -------------------------------------------------------------------------------- /internal/plugins/installer_tracker.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "time" 8 | 9 | "github.com/mrgb7/playground/internal/k8s" 10 | "github.com/mrgb7/playground/pkg/logger" 11 | v1 "k8s.io/api/core/v1" 12 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 13 | ) 14 | 15 | const ( 16 | InstallerTrackerConfigMapName = "playground-plugin-installer-tracker" 17 | InstallerTrackerNamespace = "kube-system" 18 | InstallerTypeHelm = "helm" 19 | InstallerTypeArgoCD = "argocd" 20 | ) 21 | 22 | type InstallerTracker struct { 23 | kubeConfig string 24 | k8sClient *k8s.K8sClient 25 | } 26 | 27 | func NewInstallerTracker(kubeConfig string) (*InstallerTracker, error) { 28 | client, err := k8s.NewK8sClient(kubeConfig) 29 | if err != nil { 30 | return nil, fmt.Errorf("failed to create k8s client: %w", err) 31 | } 32 | 33 | return &InstallerTracker{ 34 | kubeConfig: kubeConfig, 35 | k8sClient: client, 36 | }, nil 37 | } 38 | 39 | func (t *InstallerTracker) RecordPluginInstaller(pluginName, installerType string) error { 40 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 41 | defer cancel() 42 | 43 | configMap, err := t.getOrCreateTrackerConfigMap(ctx) 44 | if err != nil { 45 | return fmt.Errorf("failed to get or create tracker ConfigMap: %w", err) 46 | } 47 | 48 | if configMap.Data == nil { 49 | configMap.Data = make(map[string]string) 50 | } 51 | 52 | configMap.Data[pluginName] = installerType 53 | 54 | _, err = t.k8sClient.Clientset.CoreV1().ConfigMaps(InstallerTrackerNamespace).Update(ctx, configMap, metav1.UpdateOptions{}) 55 | if err != nil { 56 | return fmt.Errorf("failed to update tracker ConfigMap: %w", err) 57 | } 58 | 59 | logger.Debugln("Recorded installer type '%s' for plugin '%s'", installerType, pluginName) 60 | return nil 61 | } 62 | 63 | func (t *InstallerTracker) GetAllPluginByInstaller(installer string) ([]string, error) { 64 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 65 | defer cancel() 66 | 67 | configMap, err := t.k8sClient.Clientset.CoreV1().ConfigMaps(InstallerTrackerNamespace).Get( 68 | ctx, InstallerTrackerConfigMapName, metav1.GetOptions{}) 69 | if err != nil { 70 | if strings.Contains(err.Error(), "not found") { 71 | logger.Debugln("Tracker ConfigMap not found, no installers recorded") 72 | return nil, nil 73 | } 74 | return nil, fmt.Errorf("failed to get tracker ConfigMap: %w", err) 75 | } 76 | 77 | if configMap.Data == nil { 78 | return nil, nil 79 | } 80 | 81 | var data []string 82 | for plugin, installerType := range configMap.Data { 83 | if installerType == installer { 84 | data = append(data, plugin) 85 | } 86 | } 87 | 88 | return data, nil 89 | } 90 | 91 | func (t *InstallerTracker) GetPluginInstaller(pluginName string) (string, error) { 92 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 93 | defer cancel() 94 | 95 | configMap, err := t.k8sClient.Clientset.CoreV1().ConfigMaps(InstallerTrackerNamespace).Get( 96 | ctx, InstallerTrackerConfigMapName, metav1.GetOptions{}) 97 | if err != nil { 98 | // If ConfigMap doesn't exist, return empty (no tracking info) 99 | if strings.Contains(err.Error(), "not found") { 100 | logger.Debugln("Tracker ConfigMap not found, no installer recorded for plugin '%s'", pluginName) 101 | return "", nil 102 | } 103 | return "", fmt.Errorf("failed to get tracker ConfigMap: %w", err) 104 | } 105 | 106 | if configMap.Data == nil { 107 | return "", nil 108 | } 109 | 110 | installerType := configMap.Data[pluginName] 111 | logger.Debugln("Found recorded installer type '%s' for plugin '%s'", installerType, pluginName) 112 | return installerType, nil 113 | } 114 | 115 | func (t *InstallerTracker) RemovePluginInstaller(pluginName string) error { 116 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 117 | defer cancel() 118 | 119 | configMap, err := t.k8sClient.Clientset.CoreV1().ConfigMaps(InstallerTrackerNamespace).Get( 120 | ctx, InstallerTrackerConfigMapName, metav1.GetOptions{}) 121 | if err != nil { 122 | if strings.Contains(err.Error(), "not found") { 123 | logger.Debugln("Tracker ConfigMap not found, nothing to remove for plugin '%s'", pluginName) 124 | return nil 125 | } 126 | return fmt.Errorf("failed to get tracker ConfigMap: %w", err) 127 | } 128 | 129 | if configMap.Data == nil { 130 | return nil 131 | } 132 | 133 | delete(configMap.Data, pluginName) 134 | 135 | _, err = t.k8sClient.Clientset.CoreV1().ConfigMaps(InstallerTrackerNamespace).Update(ctx, configMap, metav1.UpdateOptions{}) 136 | if err != nil { 137 | return fmt.Errorf("failed to update tracker ConfigMap: %w", err) 138 | } 139 | 140 | logger.Debugln("Removed installer tracking record for plugin '%s'", pluginName) 141 | return nil 142 | } 143 | 144 | func (t *InstallerTracker) getOrCreateTrackerConfigMap(ctx context.Context) (*v1.ConfigMap, error) { 145 | configMap, err := t.k8sClient.Clientset.CoreV1().ConfigMaps(InstallerTrackerNamespace).Get( 146 | ctx, InstallerTrackerConfigMapName, metav1.GetOptions{}) 147 | if err == nil { 148 | return configMap, nil 149 | } 150 | 151 | if !strings.Contains(err.Error(), "not found") { 152 | return nil, fmt.Errorf("failed to get tracker ConfigMap: %w", err) 153 | } 154 | 155 | // ConfigMap doesn't exist, create it 156 | newConfigMap := &v1.ConfigMap{ 157 | ObjectMeta: metav1.ObjectMeta{ 158 | Name: InstallerTrackerConfigMapName, 159 | Namespace: InstallerTrackerNamespace, 160 | Labels: map[string]string{ 161 | "app.kubernetes.io/name": "playground", 162 | "app.kubernetes.io/component": "installer-tracker", 163 | "app.kubernetes.io/managed-by": "playground", 164 | }, 165 | }, 166 | Data: make(map[string]string), 167 | } 168 | 169 | createdConfigMap, err := t.k8sClient.Clientset.CoreV1().ConfigMaps(InstallerTrackerNamespace).Create( 170 | ctx, newConfigMap, metav1.CreateOptions{}) 171 | if err != nil { 172 | return nil, fmt.Errorf("failed to create tracker ConfigMap: %w", err) 173 | } 174 | 175 | logger.Debugln("Created new installer tracker ConfigMap") 176 | return createdConfigMap, nil 177 | } 178 | -------------------------------------------------------------------------------- /internal/k8s/client.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/mrgb7/playground/pkg/logger" 9 | corev1 "k8s.io/api/core/v1" 10 | apiextensionsclientset "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" 11 | "k8s.io/apimachinery/pkg/api/errors" 12 | v1 "k8s.io/apimachinery/pkg/apis/meta/v1" 13 | "k8s.io/client-go/dynamic" 14 | "k8s.io/client-go/kubernetes" 15 | "k8s.io/client-go/rest" 16 | "k8s.io/client-go/tools/clientcmd" 17 | ) 18 | 19 | type K8sClient struct { 20 | Clientset *kubernetes.Clientset 21 | Dynamic *dynamic.DynamicClient 22 | apiextensionsclientset *apiextensionsclientset.Clientset 23 | Config *rest.Config 24 | } 25 | 26 | func NewK8sClient(kubeConfig string) (*K8sClient, error) { 27 | restConfig, err := clientcmd.RESTConfigFromKubeConfig([]byte(kubeConfig)) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | clientset, err := kubernetes.NewForConfig(restConfig) 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | dynamicClient, err := dynamic.NewForConfig(restConfig) 38 | if err != nil { 39 | return nil, err 40 | } 41 | apiextensionsClient, err := apiextensionsclientset.NewForConfig(restConfig) 42 | if err != nil { 43 | return nil, fmt.Errorf("failed to create apiextensions client: %w", err) 44 | } 45 | 46 | return &K8sClient{ 47 | Clientset: clientset, 48 | Dynamic: dynamicClient, 49 | apiextensionsclientset: apiextensionsClient, 50 | Config: restConfig, 51 | }, nil 52 | } 53 | 54 | func (k *K8sClient) GetNameSpace(name string, ctx context.Context) (string, error) { 55 | namespace, err := k.Clientset.CoreV1(). 56 | Namespaces().Get(ctx, name, v1.GetOptions{}) 57 | if err != nil { 58 | return "", err 59 | } 60 | return namespace.Name, nil 61 | } 62 | 63 | func (k *K8sClient) DeleteNamespace(namespace string) error { 64 | if namespace == "" { 65 | return nil 66 | } 67 | 68 | ns, err := k.Clientset.CoreV1(). 69 | Namespaces(). 70 | Get(context.Background(), namespace, v1.GetOptions{}) 71 | if err != nil { 72 | if errors.IsNotFound(err) { 73 | return nil 74 | } 75 | return fmt.Errorf("error checking namespace: %w", err) 76 | } 77 | 78 | if ns.Status.Phase == corev1.NamespaceTerminating { 79 | return k.waitForNamespaceDeletion(namespace) 80 | } 81 | 82 | err = k.Clientset.CoreV1(). 83 | Namespaces(). 84 | Delete(context.Background(), namespace, v1.DeleteOptions{}) 85 | if err != nil { 86 | return fmt.Errorf("error deleting namespace: %w", err) 87 | } 88 | 89 | return k.waitForNamespaceDeletion(namespace) 90 | } 91 | 92 | func (k *K8sClient) GetCRDsByGroup(group string) ([]string, error) { 93 | if k.apiextensionsclientset == nil { 94 | return nil, fmt.Errorf("apiextensions client is not initialized") 95 | } 96 | 97 | crdList, err := k.apiextensionsclientset.ApiextensionsV1(). 98 | CustomResourceDefinitions(). 99 | List(context.Background(), v1.ListOptions{}) 100 | if err != nil { 101 | return nil, fmt.Errorf("failed to list CRDs for group %s: %w", group, err) 102 | } 103 | 104 | if len(crdList.Items) == 0 { 105 | return nil, fmt.Errorf("no CRDs found for group %s", group) 106 | } 107 | 108 | var crds []string 109 | for _, item := range crdList.Items { 110 | if item.Spec.Group == group { 111 | crds = append(crds, item.Name) 112 | } 113 | } 114 | return crds, nil 115 | } 116 | 117 | func (k *K8sClient) DeleteCRDsGroup(group string) error { 118 | crds, err := k.GetCRDsByGroup(group) 119 | if err != nil { 120 | return fmt.Errorf("failed to get CRDs for group %s: %w", group, err) 121 | } 122 | 123 | for _, crd := range crds { 124 | err = k.apiextensionsclientset. 125 | ApiextensionsV1(). 126 | CustomResourceDefinitions(). 127 | Delete(context.Background(), crd, v1.DeleteOptions{}) 128 | if err != nil { 129 | return fmt.Errorf("failed to delete CRD %s: %w", crd, err) 130 | } 131 | } 132 | 133 | return nil 134 | } 135 | 136 | func (k *K8sClient) EnsureApp(namespace, appName string) <-chan error { 137 | logger.Infof("Ensuring app %s in namespace %s", appName, namespace) 138 | doneCh := make(chan error, 1) 139 | go func() { 140 | ticker := time.NewTicker(5 * time.Second) 141 | defer ticker.Stop() 142 | timer := time.NewTimer(5 * time.Minute) 143 | for { 144 | select { 145 | case <-ticker.C: 146 | deploys, err := k.Clientset.AppsV1(). 147 | Deployments(namespace). 148 | List(context.Background(), 149 | v1.ListOptions{LabelSelector: fmt.Sprintf("app.kubernetes.io/instance=%s", appName)}) 150 | if err != nil { 151 | continue 152 | } 153 | if len(deploys.Items) == 0 { 154 | continue 155 | } 156 | allReady := true 157 | for _, deploy := range deploys.Items { 158 | if deploy.Status.ReadyReplicas < deploy.Status.Replicas || deploy.Status.Replicas <= 0 { 159 | logger.Debugf("Deployment %s in namespace %s is not ready yet", deploy.Name, namespace) 160 | allReady = false 161 | break 162 | } 163 | } 164 | if !allReady { 165 | logger.Debugf("App %s in namespace %s is not ready yet", appName, namespace) 166 | continue 167 | } 168 | doneCh <- nil 169 | 170 | case <-timer.C: 171 | doneCh <- fmt.Errorf("timeout waiting for app %s in namespace %s to be ready", appName, namespace) 172 | return 173 | } 174 | } 175 | }() 176 | 177 | return doneCh 178 | } 179 | 180 | func (c *K8sClient) waitForNamespaceDeletion(namespace string) error { 181 | timeout := 5 * time.Minute 182 | ctx, cancel := context.WithTimeout(context.Background(), timeout) 183 | defer cancel() 184 | 185 | ticker := time.NewTicker(5 * time.Second) 186 | defer ticker.Stop() 187 | 188 | for { 189 | select { 190 | case <-ctx.Done(): 191 | return fmt.Errorf("timeout waiting for namespace deletion after %v", timeout) 192 | case <-ticker.C: 193 | _, err := c.Clientset.CoreV1(). 194 | Namespaces(). 195 | Get(context.Background(), namespace, v1.GetOptions{}) 196 | if err != nil { 197 | if errors.IsNotFound(err) { 198 | return nil 199 | } 200 | return fmt.Errorf("error checking namespace status: %w", err) 201 | } 202 | } 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /internal/installer/helm.go: -------------------------------------------------------------------------------- 1 | package installer 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "time" 9 | 10 | "github.com/mrgb7/playground/internal/k8s" 11 | "github.com/mrgb7/playground/pkg/logger" 12 | "helm.sh/helm/v3/pkg/action" 13 | "helm.sh/helm/v3/pkg/chart" 14 | "helm.sh/helm/v3/pkg/chart/loader" 15 | "helm.sh/helm/v3/pkg/cli" 16 | "helm.sh/helm/v3/pkg/getter" 17 | "helm.sh/helm/v3/pkg/repo" 18 | ) 19 | 20 | var settings = cli.New() 21 | 22 | func NewHelmInstaller(kubeConfig string) (*HelmInstaller, error) { 23 | return &HelmInstaller{ 24 | KubeConfig: kubeConfig, 25 | }, nil 26 | } 27 | 28 | type HelmInstaller struct { 29 | KubeConfig string 30 | } 31 | 32 | func (h *HelmInstaller) Install(options *InstallOptions) error { 33 | if options == nil { 34 | return fmt.Errorf("install options cannot be nil") 35 | } 36 | 37 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) 38 | defer cancel() 39 | actionConfig, err := h.createHelmActionConfig(options.Namespace) 40 | if err != nil { 41 | return fmt.Errorf("failed to create helm action config: %w", err) 42 | } 43 | 44 | histClient := action.NewHistory(actionConfig) 45 | histClient.Max = 1 46 | _, err = histClient.Run(options.ApplicationName) 47 | 48 | if err == nil { 49 | // Release exists, upgrade it 50 | upgrade := action.NewUpgrade(actionConfig) 51 | upgrade.Namespace = options.Namespace 52 | 53 | chart, err := h.downloadAndLoadChart(options) 54 | if err != nil { 55 | return fmt.Errorf("failed to download and load chart: %w", err) 56 | } 57 | 58 | rel, err := upgrade.RunWithContext(ctx, options.ApplicationName, chart, options.Values) 59 | if err != nil { 60 | logger.Errorf("Error upgrading chart: %v", err) 61 | return fmt.Errorf("failed to upgrade chart: %w", err) 62 | } 63 | 64 | if rel == nil { 65 | return fmt.Errorf("failed to get release information") 66 | } 67 | } else { 68 | // Release doesn't exist, install it 69 | install := action.NewInstall(actionConfig) 70 | install.Namespace = options.Namespace 71 | install.ReleaseName = options.ApplicationName 72 | install.CreateNamespace = true 73 | 74 | chart, err := h.downloadAndLoadChart(options) 75 | if err != nil { 76 | return fmt.Errorf("failed to download and load chart: %w", err) 77 | } 78 | 79 | rel, err := install.RunWithContext(ctx, chart, options.Values) 80 | if err != nil { 81 | logger.Errorf("Error installing chart: %v", err) 82 | return fmt.Errorf("failed to install chart: %w", err) 83 | } 84 | 85 | if rel == nil { 86 | return fmt.Errorf("failed to get release information") 87 | } 88 | } 89 | return nil 90 | } 91 | 92 | func (h *HelmInstaller) UnInstall(options *InstallOptions) error { 93 | if options == nil { 94 | return fmt.Errorf("install options cannot be nil") 95 | } 96 | 97 | actionConfig, err := h.createHelmActionConfig(options.Namespace) 98 | if err != nil { 99 | return fmt.Errorf("failed to create helm action config: %w", err) 100 | } 101 | 102 | uninstall := action.NewUninstall(actionConfig) 103 | uninstall.Timeout = 5 * time.Minute 104 | uninstall.Wait = true 105 | 106 | _, err = uninstall.Run(options.ApplicationName) 107 | if err != nil { 108 | logger.Errorf("Error uninstalling chart: %v", err) 109 | return fmt.Errorf("failed to uninstall chart: %w", err) 110 | } 111 | 112 | k8sClient, err := k8s.NewK8sClient(h.KubeConfig) 113 | if err != nil { 114 | logger.Errorf("Failed to create k8s client: %v", err) 115 | return nil 116 | } 117 | 118 | if err := k8sClient.DeleteNamespace(options.Namespace); err != nil { 119 | logger.Errorf("Failed to cleanup namespace: %v", err) 120 | } 121 | if options.CRDsGroupVersion != "" { 122 | if err := k8sClient.DeleteCRDsGroup(options.CRDsGroupVersion); err != nil { 123 | logger.Warnf("Failed to delete CRDs: %v", err) 124 | } 125 | } 126 | 127 | return nil 128 | } 129 | 130 | func (h *HelmInstaller) createHelmActionConfig(namespace string) (*action.Configuration, error) { 131 | tmpPath := filepath.Join(os.TempDir(), fmt.Sprintf("kubeconfig-%d", time.Now().UnixNano())) 132 | 133 | if err := os.WriteFile(tmpPath, []byte(h.KubeConfig), 0o600); err != nil { 134 | return nil, fmt.Errorf("failed to write kubeconfig to temp file: %w", err) 135 | } 136 | 137 | settings.KubeConfig = tmpPath 138 | actionConfig := new(action.Configuration) 139 | 140 | // Create a wrapper function to use our logger with Helm's Init method 141 | logFunc := func(format string, v ...interface{}) { 142 | logger.Debugf(format, v...) 143 | } 144 | 145 | if err := actionConfig.Init(settings.RESTClientGetter(), namespace, os.Getenv("HELM_DRIVER"), logFunc); err != nil { 146 | return nil, fmt.Errorf("failed to initialize helm action config: %w", err) 147 | } 148 | 149 | return actionConfig, nil 150 | } 151 | 152 | func (h *HelmInstaller) downloadAndLoadChart(options *InstallOptions) (*chart.Chart, error) { 153 | chartPathOptions := action.ChartPathOptions{ 154 | RepoURL: options.RepoURL, 155 | Version: options.Version, 156 | PassCredentialsAll: true, 157 | } 158 | 159 | if err := h.addHelmRepo(options); err != nil { 160 | return nil, fmt.Errorf("failed to add helm repository: %w", err) 161 | } 162 | 163 | chartPath, err := chartPathOptions.LocateChart(*options.ChartName, settings) 164 | if err != nil { 165 | return nil, fmt.Errorf("failed to locate chart %s: %w", *options.ChartName, err) 166 | } 167 | 168 | logger.Infof("Chart found at: %s", chartPath) 169 | 170 | loadedChart, err := loader.Load(chartPath) 171 | if err != nil { 172 | return nil, fmt.Errorf("failed to load chart from %s: %w", chartPath, err) 173 | } 174 | 175 | if loadedChart.Metadata.Name == "" { 176 | return nil, fmt.Errorf("chart has no name") 177 | } 178 | 179 | if loadedChart.Metadata.Version == "" { 180 | return nil, fmt.Errorf("chart has no version") 181 | } 182 | 183 | logger.Infof("Successfully loaded chart: %s version %s", loadedChart.Metadata.Name, loadedChart.Metadata.Version) 184 | 185 | return loadedChart, nil 186 | } 187 | 188 | func (h *HelmInstaller) addHelmRepo(options *InstallOptions) error { 189 | entry := repo.Entry{ 190 | Name: options.RepoName, 191 | URL: options.RepoURL, 192 | } 193 | 194 | r, err := repo.NewChartRepository(&entry, getter.All(settings)) 195 | if err != nil { 196 | return fmt.Errorf("failed to create chart repository: %w", err) 197 | } 198 | 199 | indexFilePath, err := r.DownloadIndexFile() 200 | if err != nil { 201 | return fmt.Errorf("failed to download repository index: %w", err) 202 | } 203 | 204 | _, err = repo.LoadIndexFile(indexFilePath) 205 | if err != nil { 206 | return fmt.Errorf("failed to load repository index: %w", err) 207 | } 208 | 209 | return nil 210 | } 211 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Playground - Makefile for development tasks 2 | 3 | # Variables 4 | BINARY_NAME=playground 5 | BUILD_DIR=bin 6 | RELEASE_DIR=release 7 | MAIN_PACKAGE=. 8 | GO_FILES=$(shell find . -name "*.go" -type f -not -path "./vendor/*") 9 | 10 | # Version information 11 | VERSION ?= $(shell git describe --tags --abbrev=0 2>/dev/null || echo "v0.1.0") 12 | GIT_COMMIT ?= $(shell git rev-parse HEAD 2>/dev/null || echo "unknown") 13 | BUILD_DATE ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") 14 | 15 | # Build flags 16 | LDFLAGS = -ldflags "\ 17 | -X github.com/mrgb7/playground/cmd/root.Version=$(VERSION) \ 18 | -X github.com/mrgb7/playground/cmd/root.GitCommit=$(GIT_COMMIT) \ 19 | -X github.com/mrgb7/playground/cmd/root.BuildDate=$(BUILD_DATE)" 20 | 21 | # Default target 22 | .PHONY: all 23 | all: clean test build 24 | 25 | # Build the binary 26 | .PHONY: build 27 | build: 28 | @echo "Building $(BINARY_NAME)..." 29 | @mkdir -p $(BUILD_DIR) 30 | @go build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) $(MAIN_PACKAGE) 31 | @echo "Binary built: $(BUILD_DIR)/$(BINARY_NAME)" 32 | 33 | # Build for multiple platforms 34 | .PHONY: build-all 35 | build-all: 36 | @echo "Building for multiple platforms..." 37 | @mkdir -p $(BUILD_DIR) 38 | @GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 $(MAIN_PACKAGE) 39 | @GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 $(MAIN_PACKAGE) 40 | @GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 $(MAIN_PACKAGE) 41 | @GOOS=windows GOARCH=amd64 go build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe $(MAIN_PACKAGE) 42 | @echo "Multi-platform binaries built in $(BUILD_DIR)/" 43 | 44 | # Build release binaries (Linux and macOS only) 45 | .PHONY: build-release 46 | build-release: 47 | @echo "Building release binaries..." 48 | @mkdir -p $(RELEASE_DIR) 49 | @GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o $(RELEASE_DIR)/$(BINARY_NAME)-linux-amd64 $(MAIN_PACKAGE) 50 | @GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o $(RELEASE_DIR)/$(BINARY_NAME)-darwin-amd64 $(MAIN_PACKAGE) 51 | @GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o $(RELEASE_DIR)/$(BINARY_NAME)-darwin-arm64 $(MAIN_PACKAGE) 52 | @echo "Release binaries built in $(RELEASE_DIR)/" 53 | 54 | # Package release binaries 55 | .PHONY: package-release 56 | package-release: build-release 57 | @echo "Packaging release binaries..." 58 | @cd $(RELEASE_DIR) && tar -czf $(BINARY_NAME)-$(VERSION)-linux-amd64.tar.gz $(BINARY_NAME)-linux-amd64 59 | @cd $(RELEASE_DIR) && tar -czf $(BINARY_NAME)-$(VERSION)-darwin-amd64.tar.gz $(BINARY_NAME)-darwin-amd64 60 | @cd $(RELEASE_DIR) && tar -czf $(BINARY_NAME)-$(VERSION)-darwin-arm64.tar.gz $(BINARY_NAME)-darwin-arm64 61 | @echo "Release packages created in $(RELEASE_DIR)/" 62 | 63 | # Run tests 64 | .PHONY: test 65 | test: 66 | @echo "Running tests..." 67 | @go test -v ./... 68 | 69 | # Run tests with coverage 70 | .PHONY: test-coverage 71 | test-coverage: 72 | @echo "Running tests with coverage..." 73 | @go test -v -coverprofile=coverage.out ./... 74 | @go tool cover -html=coverage.out -o coverage.html 75 | @echo "Coverage report generated: coverage.html" 76 | 77 | # Run tests with race detection 78 | .PHONY: test-race 79 | test-race: 80 | @echo "Running tests with race detection..." 81 | @go test -v -race ./... 82 | 83 | # Benchmark tests 84 | .PHONY: benchmark 85 | benchmark: 86 | @echo "Running benchmarks..." 87 | @go test -bench=. -benchmem ./... 88 | 89 | # Format code 90 | .PHONY: fmt 91 | fmt: 92 | @echo "Formatting code..." 93 | @go fmt ./... 94 | 95 | # Lint code 96 | .PHONY: lint 97 | lint: 98 | @echo "Linting code..." 99 | @if command -v golangci-lint >/dev/null 2>&1; then \ 100 | golangci-lint run; \ 101 | else \ 102 | echo "golangci-lint not installed. Install it with: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest"; \ 103 | go vet ./...; \ 104 | fi 105 | 106 | # Tidy dependencies 107 | .PHONY: tidy 108 | tidy: 109 | @echo "Tidying dependencies..." 110 | @go mod tidy 111 | 112 | # Download dependencies 113 | .PHONY: deps 114 | deps: 115 | @echo "Downloading dependencies..." 116 | @go mod download 117 | 118 | # Clean build artifacts 119 | .PHONY: clean 120 | clean: 121 | @echo "Cleaning build artifacts..." 122 | @rm -rf $(BUILD_DIR) $(RELEASE_DIR) 123 | @rm -f coverage.out coverage.html 124 | 125 | # Install the binary 126 | .PHONY: install 127 | install: build 128 | @echo "Installing $(BINARY_NAME)..." 129 | @go install $(LDFLAGS) $(MAIN_PACKAGE) 130 | 131 | # Run the application 132 | .PHONY: run 133 | run: 134 | @go run $(LDFLAGS) $(MAIN_PACKAGE) $(ARGS) 135 | 136 | # Development setup 137 | .PHONY: dev-setup 138 | dev-setup: 139 | @echo "Setting up development environment..." 140 | @go mod tidy 141 | @if ! command -v golangci-lint >/dev/null 2>&1; then \ 142 | echo "Installing golangci-lint..."; \ 143 | go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest; \ 144 | fi 145 | @echo "Development environment ready!" 146 | 147 | # Check for security vulnerabilities 148 | .PHONY: security 149 | security: 150 | @echo "Checking for security vulnerabilities..." 151 | @if command -v govulncheck >/dev/null 2>&1; then \ 152 | govulncheck ./...; \ 153 | else \ 154 | echo "govulncheck not installed. Install it with: go install golang.org/x/vuln/cmd/govulncheck@latest"; \ 155 | fi 156 | 157 | # Generate documentation 158 | .PHONY: docs 159 | docs: 160 | @echo "Generating documentation..." 161 | @godoc -http=:6060 & 162 | @echo "Documentation server started at http://localhost:6060" 163 | 164 | # Pre-commit checks 165 | .PHONY: pre-commit 166 | pre-commit: fmt lint test 167 | @echo "Pre-commit checks completed successfully!" 168 | 169 | # CI pipeline 170 | .PHONY: ci 171 | ci: deps fmt lint test-race test-coverage 172 | @echo "CI pipeline completed successfully!" 173 | 174 | # Help 175 | .PHONY: help 176 | help: 177 | @echo "Available targets:" 178 | @echo " build - Build the binary" 179 | @echo " build-all - Build for multiple platforms" 180 | @echo " build-release - Build release binaries (Linux and macOS only)" 181 | @echo " package-release - Package release binaries into tar.gz files" 182 | @echo " test - Run tests" 183 | @echo " test-coverage - Run tests with coverage report" 184 | @echo " test-race - Run tests with race detection" 185 | @echo " benchmark - Run benchmark tests" 186 | @echo " fmt - Format code" 187 | @echo " lint - Lint code" 188 | @echo " tidy - Tidy dependencies" 189 | @echo " deps - Download dependencies" 190 | @echo " clean - Clean build artifacts" 191 | @echo " install - Install the binary" 192 | @echo " run - Run the application (use ARGS=... for arguments)" 193 | @echo " dev-setup - Set up development environment" 194 | @echo " security - Check for security vulnerabilities" 195 | @echo " docs - Generate and serve documentation" 196 | @echo " pre-commit - Run pre-commit checks" 197 | @echo " ci - Run CI pipeline" 198 | @echo " help - Show this help message" -------------------------------------------------------------------------------- /internal/plugins/utils.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "time" 8 | 9 | "github.com/mrgb7/playground/internal/installer" 10 | "github.com/mrgb7/playground/internal/k8s" 11 | "github.com/mrgb7/playground/pkg/logger" 12 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 13 | ) 14 | 15 | const ( 16 | ArgocdInstallNamespace = "argocd" 17 | ArgocdServerLabelSelector = "app.kubernetes.io/name=argocd-server" 18 | ) 19 | 20 | func IsArgoCDRunning(kubeConfig string) bool { 21 | client, err := k8s.NewK8sClient(kubeConfig) 22 | if err != nil { 23 | logger.Debugln("Failed to create k8s client: %v", err) 24 | return false 25 | } 26 | 27 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 28 | defer cancel() 29 | 30 | namespace, err := client.GetNameSpace(ArgocdInstallNamespace, ctx) 31 | if err != nil || namespace == "" { 32 | logger.Debugln("ArgoCD namespace not found: %v", err) 33 | return false 34 | } 35 | 36 | podList, err := client.Clientset.CoreV1().Pods(ArgocdInstallNamespace).List(ctx, metav1.ListOptions{ 37 | LabelSelector: ArgocdServerLabelSelector, 38 | }) 39 | if err != nil { 40 | logger.Debugln("Failed to list ArgoCD server pods: %v", err) 41 | return false 42 | } 43 | 44 | if len(podList.Items) == 0 { 45 | logger.Debugln("No ArgoCD server pods found") 46 | return false 47 | } 48 | 49 | for _, pod := range podList.Items { 50 | if pod.Status.Phase == "Running" { 51 | readyContainers := 0 52 | totalContainers := len(pod.Status.ContainerStatuses) 53 | 54 | for _, containerStatus := range pod.Status.ContainerStatuses { 55 | if containerStatus.Ready { 56 | readyContainers++ 57 | } 58 | } 59 | 60 | if readyContainers == totalContainers && totalContainers > 0 { 61 | return true 62 | } 63 | } 64 | } 65 | 66 | logger.Debugln("ArgoCD pods are not ready") 67 | return false 68 | } 69 | 70 | func NewInstaller(plugin Plugin, kubeConfig, clusterName string) (installer.Installer, error) { 71 | tracker, err := NewInstallerTracker(kubeConfig) 72 | if err != nil { 73 | logger.Warnln("Failed to create installer tracker: %v", err) 74 | // Continue with legacy logic if tracker fails 75 | } else { 76 | recordedInstaller, err := tracker.GetPluginInstaller(plugin.GetName()) 77 | if err != nil { 78 | logger.Warnln("Failed to get recorded installer for plugin %s: %v", plugin.GetName(), err) 79 | } else if recordedInstaller != "" { 80 | // Use the recorded installer type 81 | logger.Infoln("Using recorded installer type '%s' for plugin '%s'", recordedInstaller, plugin.GetName()) 82 | switch recordedInstaller { 83 | case InstallerTypeArgoCD: 84 | return installer.NewArgoInstaller(kubeConfig, clusterName) 85 | case InstallerTypeHelm: 86 | return installer.NewHelmInstaller(kubeConfig) 87 | default: 88 | logger.Warnln("Unknown recorded installer type '%s' for plugin '%s', falling back to logic", recordedInstaller, plugin.GetName()) 89 | } 90 | } 91 | } 92 | 93 | if IsArgoCDRunning(kubeConfig) { 94 | argoInstaller, err := installer.NewArgoInstaller(kubeConfig, clusterName) 95 | if err != nil { 96 | logger.Errorln("Failed to create ArgoCD installer: %v", err) 97 | return nil, err 98 | } 99 | return argoInstaller, nil 100 | } 101 | 102 | return installer.NewHelmInstaller(kubeConfig) 103 | } 104 | 105 | // IsPluginInstalled checks if a plugin is installed based on its status 106 | func IsPluginInstalled(status string) bool { 107 | statusLower := strings.ToLower(status) 108 | return strings.Contains(statusLower, "running") 109 | } 110 | 111 | // GetInstalledPlugins returns a list of currently installed plugin names 112 | func GetInstalledPlugins(kubeConfig string) []string { 113 | installedPlugins := make([]string, 0) 114 | 115 | // Get all available plugins 116 | plugins, err := CreatePluginsList(kubeConfig, "", "") 117 | if err != nil { 118 | logger.Warnln("Failed to create plugins list: %v", err) 119 | return installedPlugins 120 | } 121 | 122 | // Check status of each plugin 123 | for _, plugin := range plugins { 124 | status := plugin.Status() 125 | // Consider plugin installed if status contains "running" or specific success indicators 126 | if IsPluginInstalled(status) { 127 | installedPlugins = append(installedPlugins, plugin.GetName()) 128 | } 129 | } 130 | 131 | return installedPlugins 132 | } 133 | 134 | // CreateDependencyPluginsList creates a list of DependencyPlugin from regular plugins 135 | func CreateDependencyPluginsList(kubeConfig, masterClusterIP, clusterName string) ([]DependencyPlugin, error) { 136 | plugins, err := CreatePluginsList(kubeConfig, masterClusterIP, clusterName) 137 | if err != nil { 138 | return nil, err 139 | } 140 | 141 | dependencyPlugins := make([]DependencyPlugin, 0, len(plugins)) 142 | for _, plugin := range plugins { 143 | // All our plugins should implement DependencyPlugin interface 144 | if depPlugin, ok := plugin.(DependencyPlugin); ok { 145 | dependencyPlugins = append(dependencyPlugins, depPlugin) 146 | } else { 147 | logger.Warnln("Plugin %s does not implement DependencyPlugin interface", plugin.GetName()) 148 | } 149 | } 150 | 151 | return dependencyPlugins, nil 152 | } 153 | 154 | // ValidateAndGetInstallOrder validates dependencies and returns the correct install order 155 | func ValidateAndGetInstallOrder(targetPlugin string, kubeConfig, masterClusterIP, clusterName string) ([]string, error) { 156 | // Get all dependency plugins 157 | dependencyPlugins, err := CreateDependencyPluginsList(kubeConfig, masterClusterIP, clusterName) 158 | if err != nil { 159 | return nil, fmt.Errorf("failed to create dependency plugins list: %w", err) 160 | } 161 | 162 | // Create validator 163 | validator := NewDependencyValidator(dependencyPlugins) 164 | 165 | // Get currently installed plugins 166 | installedPlugins := GetInstalledPlugins(kubeConfig) 167 | 168 | // Validate installation order 169 | installOrder, err := validator.ValidateInstallation([]string{targetPlugin}, installedPlugins) 170 | if err != nil { 171 | return nil, fmt.Errorf("dependency validation failed: %w", err) 172 | } 173 | 174 | return installOrder, nil 175 | } 176 | 177 | // ValidateAndGetUninstallOrder validates dependencies and returns the correct uninstall order 178 | func ValidateAndGetUninstallOrder(targetPlugin string, kubeConfig, masterClusterIP, clusterName string) ([]string, error) { 179 | // Get all dependency plugins 180 | dependencyPlugins, err := CreateDependencyPluginsList(kubeConfig, masterClusterIP, clusterName) 181 | if err != nil { 182 | return nil, fmt.Errorf("failed to create dependency plugins list: %w", err) 183 | } 184 | 185 | // Create validator 186 | validator := NewDependencyValidator(dependencyPlugins) 187 | 188 | // Get currently installed plugins 189 | installedPlugins := GetInstalledPlugins(kubeConfig) 190 | 191 | // Validate uninstallation order 192 | uninstallOrder, err := validator.ValidateUninstallation([]string{targetPlugin}, installedPlugins) 193 | if err != nil { 194 | return nil, fmt.Errorf("dependency validation failed: %w", err) 195 | } 196 | 197 | return uninstallOrder, nil 198 | } 199 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mrgb7/playground 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.2 6 | 7 | require ( 8 | github.com/fatih/color v1.18.0 9 | github.com/spf13/cobra v1.9.1 10 | gopkg.in/yaml.v3 v3.0.1 11 | helm.sh/helm/v3 v3.17.3 12 | k8s.io/api v0.33.1 13 | k8s.io/apiextensions-apiserver v0.32.2 14 | k8s.io/apimachinery v0.33.1 15 | k8s.io/client-go v0.33.1 16 | ) 17 | 18 | replace k8s.io/kubernetes => k8s.io/kubernetes v1.31.1 19 | 20 | require ( 21 | dario.cat/mergo v1.0.1 // indirect 22 | github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect 23 | github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect 24 | github.com/BurntSushi/toml v1.4.0 // indirect 25 | github.com/MakeNowJust/heredoc v1.0.0 // indirect 26 | github.com/Masterminds/goutils v1.1.1 // indirect 27 | github.com/Masterminds/semver/v3 v3.3.0 // indirect 28 | github.com/Masterminds/sprig/v3 v3.3.0 // indirect 29 | github.com/Masterminds/squirrel v1.5.4 // indirect 30 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect 31 | github.com/beorn7/perks v1.0.1 // indirect 32 | github.com/blang/semver/v4 v4.0.0 // indirect 33 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 34 | github.com/chai2010/gettext-go v1.0.2 // indirect 35 | github.com/containerd/containerd v1.7.27 // indirect 36 | github.com/containerd/errdefs v0.3.0 // indirect 37 | github.com/containerd/log v0.1.0 // indirect 38 | github.com/containerd/platforms v0.2.1 // indirect 39 | github.com/cyphar/filepath-securejoin v0.3.6 // indirect 40 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 41 | github.com/distribution/reference v0.6.0 // indirect 42 | github.com/docker/cli v25.0.1+incompatible // indirect 43 | github.com/docker/distribution v2.8.3+incompatible // indirect 44 | github.com/docker/docker v25.0.6+incompatible // indirect 45 | github.com/docker/docker-credential-helpers v0.7.0 // indirect 46 | github.com/docker/go-connections v0.5.0 // indirect 47 | github.com/docker/go-metrics v0.0.1 // indirect 48 | github.com/emicklei/go-restful/v3 v3.11.0 // indirect 49 | github.com/evanphx/json-patch v5.9.0+incompatible // indirect 50 | github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect 51 | github.com/felixge/httpsnoop v1.0.4 // indirect 52 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 53 | github.com/go-errors/errors v1.4.2 // indirect 54 | github.com/go-gorp/gorp/v3 v3.1.0 // indirect 55 | github.com/go-logr/logr v1.4.2 // indirect 56 | github.com/go-logr/stdr v1.2.2 // indirect 57 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 58 | github.com/go-openapi/jsonreference v0.20.2 // indirect 59 | github.com/go-openapi/swag v0.23.0 // indirect 60 | github.com/gobwas/glob v0.2.3 // indirect 61 | github.com/gogo/protobuf v1.3.2 // indirect 62 | github.com/google/btree v1.1.3 // indirect 63 | github.com/google/gnostic-models v0.6.9 // indirect 64 | github.com/google/go-cmp v0.7.0 // indirect 65 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect 66 | github.com/google/uuid v1.6.0 // indirect 67 | github.com/gorilla/mux v1.8.0 // indirect 68 | github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect 69 | github.com/gosuri/uitable v0.0.4 // indirect 70 | github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect 71 | github.com/hashicorp/errwrap v1.1.0 // indirect 72 | github.com/hashicorp/go-multierror v1.1.1 // indirect 73 | github.com/huandu/xstrings v1.5.0 // indirect 74 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 75 | github.com/jmoiron/sqlx v1.4.0 // indirect 76 | github.com/josharian/intern v1.0.0 // indirect 77 | github.com/json-iterator/go v1.1.12 // indirect 78 | github.com/klauspost/compress v1.16.7 // indirect 79 | github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect 80 | github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect 81 | github.com/lib/pq v1.10.9 // indirect 82 | github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect 83 | github.com/mailru/easyjson v0.7.7 // indirect 84 | github.com/mattn/go-colorable v0.1.13 // indirect 85 | github.com/mattn/go-isatty v0.0.20 // indirect 86 | github.com/mattn/go-runewidth v0.0.14 // indirect 87 | github.com/mitchellh/copystructure v1.2.0 // indirect 88 | github.com/mitchellh/go-wordwrap v1.0.1 // indirect 89 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 90 | github.com/moby/locker v1.0.1 // indirect 91 | github.com/moby/spdystream v0.5.0 // indirect 92 | github.com/moby/sys/mountinfo v0.7.1 // indirect 93 | github.com/moby/term v0.5.0 // indirect 94 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 95 | github.com/modern-go/reflect2 v1.0.2 // indirect 96 | github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect 97 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 98 | github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect 99 | github.com/opencontainers/go-digest v1.0.0 // indirect 100 | github.com/opencontainers/image-spec v1.1.0 // indirect 101 | github.com/peterbourgon/diskv v2.0.1+incompatible // indirect 102 | github.com/pkg/errors v0.9.1 // indirect 103 | github.com/prometheus/client_golang v1.19.1 // indirect 104 | github.com/prometheus/client_model v0.6.1 // indirect 105 | github.com/prometheus/common v0.55.0 // indirect 106 | github.com/prometheus/procfs v0.15.1 // indirect 107 | github.com/rivo/uniseg v0.4.4 // indirect 108 | github.com/rubenv/sql-migrate v1.7.1 // indirect 109 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 110 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 111 | github.com/shopspring/decimal v1.4.0 // indirect 112 | github.com/sirupsen/logrus v1.9.3 // indirect 113 | github.com/spf13/cast v1.7.0 // indirect 114 | github.com/spf13/pflag v1.0.6 // indirect 115 | github.com/x448/float16 v0.8.4 // indirect 116 | github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect 117 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect 118 | github.com/xeipuuv/gojsonschema v1.2.0 // indirect 119 | github.com/xlab/treeprint v1.2.0 // indirect 120 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect 121 | go.opentelemetry.io/otel v1.28.0 // indirect 122 | go.opentelemetry.io/otel/metric v1.28.0 // indirect 123 | go.opentelemetry.io/otel/trace v1.28.0 // indirect 124 | golang.org/x/crypto v0.36.0 // indirect 125 | golang.org/x/net v0.38.0 // indirect 126 | golang.org/x/oauth2 v0.27.0 // indirect 127 | golang.org/x/sync v0.12.0 // indirect 128 | golang.org/x/sys v0.31.0 // indirect 129 | golang.org/x/term v0.30.0 // indirect 130 | golang.org/x/text v0.23.0 // indirect 131 | golang.org/x/time v0.9.0 // indirect 132 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 // indirect 133 | google.golang.org/grpc v1.65.0 // indirect 134 | google.golang.org/protobuf v1.36.5 // indirect 135 | gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect 136 | gopkg.in/inf.v0 v0.9.1 // indirect 137 | k8s.io/apiserver v0.32.2 // indirect 138 | k8s.io/cli-runtime v0.32.2 // indirect 139 | k8s.io/component-base v0.32.2 // indirect 140 | k8s.io/klog/v2 v2.130.1 // indirect 141 | k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect 142 | k8s.io/kubectl v0.32.2 // indirect 143 | k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect 144 | oras.land/oras-go v1.2.5 // indirect 145 | sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect 146 | sigs.k8s.io/kustomize/api v0.18.0 // indirect 147 | sigs.k8s.io/kustomize/kyaml v0.18.1 // indirect 148 | sigs.k8s.io/randfill v1.0.0 // indirect 149 | sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect 150 | sigs.k8s.io/yaml v1.4.0 // indirect 151 | ) 152 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Playground 2 | 3 | Thank you for your interest in contributing to Playground! This document provides guidelines and information for contributors. 4 | 5 | ## Development Setup 6 | 7 | 1. **Clone the repository**: 8 | ```bash 9 | git clone https://github.com/mrgb7/playground.git 10 | cd playground 11 | ``` 12 | 13 | 2. **Set up development environment**: 14 | ```bash 15 | make dev-setup 16 | ``` 17 | 18 | 3. **Run tests**: 19 | ```bash 20 | make test 21 | ``` 22 | 23 | 4. **Build the project**: 24 | ```bash 25 | make build 26 | ``` 27 | 28 | ## Semantic Versioning 29 | 30 | This project follows [Semantic Versioning (SemVer)](https://semver.org/). Version numbers follow the format `MAJOR.MINOR.PATCH`: 31 | 32 | - **MAJOR**: Incompatible API changes 33 | - **MINOR**: New functionality in a backwards compatible manner 34 | - **PATCH**: Backwards compatible bug fixes 35 | 36 | ## Conventional Commits 37 | 38 | We use [Conventional Commits](https://www.conventionalcommits.org/) to automatically determine version bumps and generate changelogs. 39 | 40 | ### Commit Message Format 41 | 42 | ``` 43 | [optional scope]: 44 | 45 | [optional body] 46 | 47 | [optional footer(s)] 48 | ``` 49 | 50 | ### Types 51 | 52 | - **feat**: A new feature (triggers MINOR version bump) 53 | - **fix**: A bug fix (triggers PATCH version bump) 54 | - **docs**: Documentation only changes 55 | - **style**: Changes that do not affect the meaning of the code 56 | - **refactor**: A code change that neither fixes a bug nor adds a feature 57 | - **perf**: A code change that improves performance 58 | - **test**: Adding missing tests or correcting existing tests 59 | - **chore**: Changes to the build process or auxiliary tools 60 | 61 | ### Breaking Changes 62 | 63 | To indicate breaking changes, add `!` after the type or include `BREAKING CHANGE:` in the footer: 64 | 65 | ```bash 66 | feat!: remove deprecated API endpoint 67 | ``` 68 | 69 | or 70 | 71 | ```bash 72 | feat: add new authentication system 73 | 74 | BREAKING CHANGE: The old authentication system has been removed. 75 | ``` 76 | 77 | This triggers a MAJOR version bump. 78 | 79 | ### Examples 80 | 81 | ```bash 82 | # Patch version bump 83 | fix: resolve memory leak in worker pool 84 | fix(auth): handle expired tokens correctly 85 | 86 | # Minor version bump 87 | feat: add user profile management 88 | feat(api): implement rate limiting 89 | 90 | # Major version bump 91 | feat!: redesign authentication system 92 | refactor!: change configuration file format 93 | ``` 94 | 95 | ## Development Workflow 96 | 97 | 1. **Create a feature branch**: 98 | ```bash 99 | git checkout -b feature/your-feature-name 100 | ``` 101 | 102 | 2. **Make your changes** following the coding standards 103 | 104 | 3. **Run pre-commit checks**: 105 | ```bash 106 | make pre-commit 107 | ``` 108 | 109 | 4. **Commit your changes** using conventional commit format: 110 | ```bash 111 | git commit -m "feat: add new awesome feature" 112 | ``` 113 | 114 | 5. **Push your branch**: 115 | ```bash 116 | git push origin feature/your-feature-name 117 | ``` 118 | 119 | 6. **Create a Pull Request** to the `main` branch 120 | 121 | ## Pull Request Guidelines 122 | 123 | - **Title**: Use conventional commit format in PR title 124 | - **Description**: Provide clear description of changes 125 | - **Tests**: Include tests for new functionality 126 | - **Documentation**: Update documentation if needed 127 | - **Breaking Changes**: Clearly document any breaking changes 128 | 129 | ## Continuous Integration 130 | 131 | Our CI pipeline includes: 132 | 133 | ### Pull Request Checks 134 | - **Tests**: Unit tests with race detection 135 | - **Code Format**: `gofmt` formatting check 136 | - **Linting**: `golangci-lint` analysis 137 | - **Security**: Security vulnerability scanning 138 | - **Build**: Multi-platform build verification 139 | 140 | ### Release Process 141 | When changes are merged to `main`: 142 | 143 | 1. **Automatic Version Detection**: Based on conventional commits since last release 144 | 2. **Tag Creation**: Semantic version tag is created 145 | 3. **Release Build**: Binaries are built for Linux and macOS 146 | 4. **GitHub Release**: Release with changelog is created automatically 147 | 148 | ## Manual Release 149 | 150 | You can trigger a manual release with specific version: 151 | 152 | 1. **Go to Actions tab** in GitHub 153 | 2. **Select "Release" workflow** 154 | 3. **Click "Run workflow"** 155 | 4. **Specify version type**: 156 | - `auto` (default): Analyze commits automatically 157 | - `major`: Force major version bump 158 | - `minor`: Force minor version bump 159 | - `patch`: Force patch version bump 160 | - `v1.2.3`: Specific version number 161 | 162 | ## Chaos Testing 163 | 164 | The project includes a comprehensive chaos testing script (`chaos.sh`) that validates the playground's functionality through destructive testing scenarios. 165 | 166 | ### Running Chaos Tests 167 | 168 | ```bash 169 | # Make the script executable 170 | chmod +x chaos.sh 171 | 172 | # Run the chaos test suite 173 | ./chaos.sh 174 | ``` 175 | 176 | ### What Chaos Tests Cover 177 | 178 | The chaos testing script performs the following validations: 179 | 180 | 1. **Build Verification**: Ensures the project builds successfully 181 | 2. **Cluster Lifecycle**: Tests cluster creation, listing, and deletion 182 | 3. **Plugin Management**: 183 | - Tests plugin installation and removal 184 | - Validates dependency management between plugins 185 | - Ensures proper cleanup of plugin resources 186 | 4. **Dependency Resolution**: 187 | - Tests automatic installation of required dependencies 188 | - Validates dependency protection (e.g., preventing removal of dependencies) 189 | 5. **Resource Configuration**: Tests cluster creation with custom configurations 190 | 6. **Error Handling**: Validates expected failures (e.g., duplicate cluster creation) 191 | 192 | ### Test Scenarios 193 | 194 | - **ArgoCD Plugin**: Installation, usage, and removal 195 | - **LoadBalancer Plugin**: Installation and interaction with ArgoCD 196 | - **TLS Plugin**: Dependency on cert-manager 197 | - **Ingress Plugin**: Complex dependencies (NGINX, cert-manager, load-balancer) 198 | - **Cluster Configuration**: Custom CPU and memory settings 199 | 200 | ### Prerequisites 201 | 202 | Before running chaos tests: 203 | - Ensure you have `kubectl` installed and configured 204 | - Have sufficient system resources for test clusters 205 | 206 | ### Cleanup 207 | 208 | The script automatically cleans up test resources, including the `chaos-test` cluster. If tests fail unexpectedly, you may need to manually clean up: 209 | 210 | ```bash 211 | ./bin/playground cluster delete chaos-test 212 | ``` 213 | 214 | ## Code Quality Standards 215 | 216 | - **Go Version**: Go 1.24+ 217 | - **Formatting**: Use `gofmt` for formatting 218 | - **Linting**: Pass `golangci-lint` checks 219 | - **Test Coverage**: Maintain test coverage for new code 220 | - **Documentation**: Include godoc comments for public APIs 221 | 222 | ## Supported Platforms 223 | 224 | Release binaries are built for: 225 | - Linux AMD64 226 | - macOS Intel (AMD64) 227 | - macOS Apple Silicon (ARM64) 228 | 229 | ## Getting Help 230 | 231 | - **Issues**: Create an issue for bugs or feature requests 232 | - **Discussions**: Use GitHub Discussions for questions 233 | - **Code Review**: Request reviews from maintainers 234 | 235 | ## Release Assets 236 | 237 | Each release includes: 238 | - Source code 239 | - Pre-built binaries for supported platforms 240 | - Checksums 241 | - Auto-generated changelog 242 | 243 | ### Installation Example 244 | 245 | ```bash 246 | # Download for Linux 247 | curl -L -o playground.tar.gz https://github.com/mrgb7/playground/releases/latest/download/playground-vX.Y.Z-linux-amd64.tar.gz 248 | tar -xzf playground.tar.gz 249 | chmod +x playground-linux-amd64 250 | sudo mv playground-linux-amd64 /usr/local/bin/playground 251 | 252 | # Verify installation 253 | playground version --verbose 254 | ``` -------------------------------------------------------------------------------- /internal/plugins/integration_test.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestPluginDependencyIntegration(t *testing.T) { 8 | // Test that all real plugins implement DependencyPlugin interface 9 | var plugins []Plugin 10 | 11 | // Add ArgoCD with error handling 12 | argocd, err := NewArgocd("") 13 | if err != nil { 14 | t.Logf("ArgoCD creation failed (expected in test): %v", err) 15 | } else { 16 | plugins = append(plugins, argocd) 17 | } 18 | 19 | plugins = append(plugins, NewCertManager("")) 20 | plugins = append(plugins, NewNginx("")) 21 | 22 | // Test LoadBalancer separately since it requires additional parameters 23 | lb, err := NewLoadBalancer("", "", "test-cluster") 24 | if err != nil { 25 | t.Logf("LoadBalancer creation failed (expected in test): %v", err) 26 | } else { 27 | plugins = append(plugins, lb) 28 | } 29 | 30 | for _, plugin := range plugins { 31 | // Test that plugin implements DependencyPlugin interface 32 | if _, ok := plugin.(DependencyPlugin); !ok { 33 | t.Errorf("Plugin %s does not implement DependencyPlugin interface", plugin.GetName()) 34 | } 35 | } 36 | } 37 | 38 | func TestDependencyValidationIntegration(t *testing.T) { 39 | // Create mock plugins that simulate real dependency relationships 40 | dependencyPlugins := []DependencyPlugin{ 41 | &MockDependencyPlugin{name: "argocd", dependencies: []string{}}, 42 | &MockDependencyPlugin{name: "cert-manager", dependencies: []string{}}, 43 | &MockDependencyPlugin{name: "load-balancer", dependencies: []string{}}, 44 | &MockDependencyPlugin{name: "nginx-ingress", dependencies: []string{"load-balancer"}}, 45 | &MockDependencyPlugin{name: "ingress", dependencies: []string{"nginx-ingress", "load-balancer"}}, 46 | &MockDependencyPlugin{name: "tls", dependencies: []string{"cert-manager"}}, 47 | } 48 | 49 | validator := NewDependencyValidator(dependencyPlugins) 50 | 51 | // Test 1: Installing ingress should install load-balancer and nginx-ingress first 52 | installOrder, err := validator.ValidateInstallation([]string{"ingress"}, []string{}) 53 | if err != nil { 54 | t.Fatalf("Failed to validate ingress installation: %v", err) 55 | } 56 | 57 | // Verify load-balancer comes before nginx-ingress and ingress 58 | lbIndex := indexOf(installOrder, "load-balancer") 59 | nginxIndex := indexOf(installOrder, "nginx-ingress") 60 | ingressIndex := indexOf(installOrder, "ingress") 61 | 62 | if lbIndex == -1 || nginxIndex == -1 || ingressIndex == -1 { 63 | t.Fatalf("Missing plugins in install order: %v", installOrder) 64 | } 65 | 66 | if lbIndex >= nginxIndex || nginxIndex >= ingressIndex { 67 | t.Errorf("Invalid install order: %v", installOrder) 68 | } 69 | 70 | // Test 2: Installing TLS should install cert-manager first 71 | tlsInstallOrder, err := validator.ValidateInstallation([]string{"tls"}, []string{}) 72 | if err != nil { 73 | t.Fatalf("Failed to validate TLS installation: %v", err) 74 | } 75 | 76 | if len(tlsInstallOrder) != 2 || tlsInstallOrder[0] != "cert-manager" || tlsInstallOrder[1] != "tls" { 77 | t.Errorf("Invalid TLS install order: %v", tlsInstallOrder) 78 | } 79 | 80 | // Test 3: Uninstalling load-balancer when nginx-ingress and ingress depend on it 81 | uninstallOrder, err := validator.ValidateUninstallation([]string{"load-balancer"}, 82 | []string{"load-balancer", "nginx-ingress", "ingress"}) 83 | if err != nil { 84 | t.Fatalf("Failed to validate load-balancer uninstallation: %v", err) 85 | } 86 | 87 | // Should uninstall in reverse dependency order: ingress, nginx-ingress, load-balancer 88 | expectedUninstallOrder := []string{"ingress", "nginx-ingress", "load-balancer"} 89 | if len(uninstallOrder) != len(expectedUninstallOrder) { 90 | t.Errorf("Unexpected uninstall order length: got %d, expected %d", 91 | len(uninstallOrder), len(expectedUninstallOrder)) 92 | } 93 | 94 | for i, expected := range expectedUninstallOrder { 95 | if i >= len(uninstallOrder) || uninstallOrder[i] != expected { 96 | t.Errorf("Invalid uninstall order at position %d: got %v, expected %v", 97 | i, uninstallOrder, expectedUninstallOrder) 98 | break 99 | } 100 | } 101 | 102 | // Test 4: Trying to uninstall cert-manager when TLS depends on it should fail 103 | _, err = validator.ValidateUninstallation([]string{"cert-manager"}, 104 | []string{"cert-manager", "tls"}) 105 | if err != nil { 106 | t.Fatalf("Failed to validate cert-manager uninstallation: %v", err) 107 | } 108 | } 109 | 110 | func TestCircularDependencyDetection(t *testing.T) { 111 | // Create plugins with circular dependency 112 | circularPlugins := []DependencyPlugin{ 113 | &MockDependencyPlugin{name: "A", dependencies: []string{"B"}}, 114 | &MockDependencyPlugin{name: "B", dependencies: []string{"C"}}, 115 | &MockDependencyPlugin{name: "C", dependencies: []string{"A"}}, 116 | } 117 | 118 | validator := NewDependencyValidator(circularPlugins) 119 | 120 | // Should detect circular dependency 121 | _, err := validator.ValidateInstallation([]string{"A"}, []string{}) 122 | if err == nil { 123 | t.Error("Expected error for circular dependency, but got none") 124 | } 125 | } 126 | 127 | func TestPartialInstallationScenario(t *testing.T) { 128 | dependencyPlugins := []DependencyPlugin{ 129 | &MockDependencyPlugin{name: "argocd", dependencies: []string{}}, 130 | &MockDependencyPlugin{name: "cert-manager", dependencies: []string{}}, 131 | &MockDependencyPlugin{name: "load-balancer", dependencies: []string{}}, 132 | &MockDependencyPlugin{name: "nginx-ingress", dependencies: []string{"load-balancer"}}, 133 | &MockDependencyPlugin{name: "ingress", dependencies: []string{"nginx-ingress", "load-balancer"}}, 134 | &MockDependencyPlugin{name: "tls", dependencies: []string{"cert-manager"}}, 135 | } 136 | 137 | validator := NewDependencyValidator(dependencyPlugins) 138 | 139 | // Scenario: load-balancer is already installed, now installing ingress 140 | installOrder, err := validator.ValidateInstallation([]string{"ingress"}, 141 | []string{"load-balancer"}) 142 | if err != nil { 143 | t.Fatalf("Failed to validate partial installation: %v", err) 144 | } 145 | 146 | // Should only install nginx-ingress and ingress (load-balancer already installed) 147 | expectedOrder := []string{"nginx-ingress", "ingress"} 148 | if len(installOrder) != len(expectedOrder) { 149 | t.Errorf("Unexpected install order length: got %d, expected %d", 150 | len(installOrder), len(expectedOrder)) 151 | } 152 | 153 | for i, expected := range expectedOrder { 154 | if i >= len(installOrder) || installOrder[i] != expected { 155 | t.Errorf("Invalid install order at position %d: got %v, expected %v", 156 | i, installOrder, expectedOrder) 157 | break 158 | } 159 | } 160 | } 161 | 162 | func TestMultiplePluginInstallationIntegration(t *testing.T) { 163 | dependencyPlugins := []DependencyPlugin{ 164 | &MockDependencyPlugin{name: "argocd", dependencies: []string{}}, 165 | &MockDependencyPlugin{name: "cert-manager", dependencies: []string{}}, 166 | &MockDependencyPlugin{name: "load-balancer", dependencies: []string{}}, 167 | &MockDependencyPlugin{name: "nginx-ingress", dependencies: []string{"load-balancer"}}, 168 | &MockDependencyPlugin{name: "ingress", dependencies: []string{"nginx-ingress", "load-balancer"}}, 169 | &MockDependencyPlugin{name: "tls", dependencies: []string{"cert-manager"}}, 170 | } 171 | 172 | validator := NewDependencyValidator(dependencyPlugins) 173 | 174 | // Install multiple plugins at once: ingress and tls 175 | installOrder, err := validator.ValidateInstallation([]string{"ingress", "tls"}, []string{}) 176 | if err != nil { 177 | t.Fatalf("Failed to validate multiple plugin installation: %v", err) 178 | } 179 | 180 | // Should install all dependencies 181 | expectedPlugins := map[string]bool{ 182 | "cert-manager": true, 183 | "load-balancer": true, 184 | "nginx-ingress": true, 185 | "ingress": true, 186 | "tls": true, 187 | } 188 | 189 | if len(installOrder) != len(expectedPlugins) { 190 | t.Errorf("Unexpected number of plugins to install: got %d, expected %d", 191 | len(installOrder), len(expectedPlugins)) 192 | } 193 | 194 | for _, plugin := range installOrder { 195 | if !expectedPlugins[plugin] { 196 | t.Errorf("Unexpected plugin in install order: %s", plugin) 197 | } 198 | } 199 | 200 | // Verify dependency order constraints 201 | lbIndex := indexOf(installOrder, "load-balancer") 202 | nginxIndex := indexOf(installOrder, "nginx-ingress") 203 | ingressIndex := indexOf(installOrder, "ingress") 204 | cmIndex := indexOf(installOrder, "cert-manager") 205 | tlsIndex := indexOf(installOrder, "tls") 206 | 207 | if lbIndex >= nginxIndex || nginxIndex >= ingressIndex { 208 | t.Errorf("Invalid dependency order for ingress chain: %v", installOrder) 209 | } 210 | 211 | if cmIndex >= tlsIndex { 212 | t.Errorf("Invalid dependency order for TLS chain: %v", installOrder) 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | paths-ignore: 7 | - '*.md' 8 | - 'docs/**' 9 | - 'install.sh' 10 | - '.gitignore' 11 | - 'LICENSE' 12 | workflow_dispatch: 13 | inputs: 14 | version_type: 15 | description: 'Version bump type (major, minor, patch) or specific version (e.g., v1.2.3)' 16 | required: false 17 | default: 'auto' 18 | type: string 19 | 20 | permissions: 21 | contents: write 22 | 23 | jobs: 24 | test: 25 | name: Test Before Release 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: Checkout code 29 | uses: actions/checkout@v4 30 | 31 | - name: Set up Go 32 | uses: actions/setup-go@v5 33 | with: 34 | go-version: '1.24' 35 | cache: true 36 | 37 | - name: Run tests 38 | run: go test -v -race ./... 39 | 40 | release: 41 | name: Create Release 42 | runs-on: ubuntu-latest 43 | needs: test 44 | outputs: 45 | version: ${{ steps.version.outputs.version }} 46 | upload_url: ${{ steps.create_release.outputs.upload_url }} 47 | steps: 48 | - name: Checkout code 49 | uses: actions/checkout@v4 50 | with: 51 | fetch-depth: 0 52 | 53 | - name: Set up Go 54 | uses: actions/setup-go@v5 55 | with: 56 | go-version: '1.24' 57 | cache: true 58 | 59 | - name: Generate semantic version 60 | id: version 61 | run: | 62 | # Get the latest tag 63 | LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") 64 | echo "Latest tag: $LATEST_TAG" 65 | 66 | # Extract version numbers 67 | VERSION_NUMS=$(echo $LATEST_TAG | sed 's/v//') 68 | MAJOR=$(echo $VERSION_NUMS | cut -d. -f1) 69 | MINOR=$(echo $VERSION_NUMS | cut -d. -f2) 70 | PATCH=$(echo $VERSION_NUMS | cut -d. -f3) 71 | 72 | # Handle manual version input 73 | if [ "${{ github.event.inputs.version_type }}" != "" ] && [ "${{ github.event.inputs.version_type }}" != "auto" ]; then 74 | VERSION_INPUT="${{ github.event.inputs.version_type }}" 75 | if [[ $VERSION_INPUT =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then 76 | # Specific version provided 77 | NEW_VERSION="${VERSION_INPUT#v}" 78 | NEW_VERSION="v${NEW_VERSION}" 79 | elif [ "$VERSION_INPUT" = "major" ]; then 80 | NEW_MAJOR=$((MAJOR + 1)) 81 | NEW_VERSION="v${NEW_MAJOR}.0.0" 82 | elif [ "$VERSION_INPUT" = "minor" ]; then 83 | NEW_MINOR=$((MINOR + 1)) 84 | NEW_VERSION="v${MAJOR}.${NEW_MINOR}.0" 85 | elif [ "$VERSION_INPUT" = "patch" ]; then 86 | NEW_PATCH=$((PATCH + 1)) 87 | NEW_VERSION="v${MAJOR}.${MINOR}.${NEW_PATCH}" 88 | else 89 | echo "Invalid version input: $VERSION_INPUT" 90 | exit 1 91 | fi 92 | else 93 | # Analyze commits since last tag for conventional commits 94 | COMMITS=$(git log ${LATEST_TAG}..HEAD --oneline --pretty=format:"%s" || git log --oneline --pretty=format:"%s") 95 | 96 | HAS_BREAKING=false 97 | HAS_FEAT=false 98 | HAS_FIX=false 99 | 100 | while IFS= read -r commit; do 101 | echo "Analyzing commit: $commit" 102 | 103 | # Check for breaking changes 104 | if echo "$commit" | grep -qE "^[a-zA-Z]+(\(.+\))?!:"; then 105 | HAS_BREAKING=true 106 | elif echo "$commit" | grep -qiE "(BREAKING CHANGE|BREAKING-CHANGE)"; then 107 | HAS_BREAKING=true 108 | # Check for features 109 | elif echo "$commit" | grep -qE "^feat(\(.+\))?:"; then 110 | HAS_FEAT=true 111 | # Check for fixes 112 | elif echo "$commit" | grep -qE "^fix(\(.+\))?:"; then 113 | HAS_FIX=true 114 | fi 115 | done <<< "$COMMITS" 116 | 117 | # Determine version bump based on conventional commits 118 | if [ "$HAS_BREAKING" = true ]; then 119 | NEW_MAJOR=$((MAJOR + 1)) 120 | NEW_VERSION="v${NEW_MAJOR}.0.0" 121 | echo "BREAKING CHANGE detected, bumping major version" 122 | elif [ "$HAS_FEAT" = true ]; then 123 | NEW_MINOR=$((MINOR + 1)) 124 | NEW_VERSION="v${MAJOR}.${NEW_MINOR}.0" 125 | echo "Feature detected, bumping minor version" 126 | elif [ "$HAS_FIX" = true ]; then 127 | NEW_PATCH=$((PATCH + 1)) 128 | NEW_VERSION="v${MAJOR}.${MINOR}.${NEW_PATCH}" 129 | echo "Fix detected, bumping patch version" 130 | else 131 | # Default to patch bump if no conventional commits found 132 | NEW_PATCH=$((PATCH + 1)) 133 | NEW_VERSION="v${MAJOR}.${MINOR}.${NEW_PATCH}" 134 | echo "No conventional commits found, defaulting to patch bump" 135 | fi 136 | fi 137 | 138 | echo "New version: $NEW_VERSION" 139 | echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT 140 | 141 | - name: Check if version already exists 142 | run: | 143 | if git tag --list | grep -q "^${{ steps.version.outputs.version }}$"; then 144 | echo "Version ${{ steps.version.outputs.version }} already exists!" 145 | exit 1 146 | fi 147 | 148 | - name: Create Git tag 149 | run: | 150 | git config user.name "github-actions[bot]" 151 | git config user.email "github-actions[bot]@users.noreply.github.com" 152 | git tag ${{ steps.version.outputs.version }} 153 | git push origin ${{ steps.version.outputs.version }} 154 | 155 | - name: Generate changelog 156 | id: changelog 157 | run: | 158 | LATEST_TAG=$(git describe --tags --abbrev=0 ${{ steps.version.outputs.version }}^ 2>/dev/null || echo "") 159 | 160 | if [ -z "$LATEST_TAG" ]; then 161 | COMMITS=$(git log --oneline --pretty=format:"- %s" | head -20) 162 | else 163 | COMMITS=$(git log ${LATEST_TAG}..${{ steps.version.outputs.version }} --oneline --pretty=format:"- %s") 164 | fi 165 | 166 | # Create changelog sections 167 | BREAKING_CHANGES="" 168 | FEATURES="" 169 | FIXES="" 170 | OTHER="" 171 | 172 | while IFS= read -r commit; do 173 | if echo "$commit" | grep -qE "^- [a-zA-Z]+(\(.+\))?!:"; then 174 | BREAKING_CHANGES="${BREAKING_CHANGES}${commit}\n" 175 | elif echo "$commit" | grep -qE "^- feat(\(.+\))?:"; then 176 | FEATURES="${FEATURES}${commit}\n" 177 | elif echo "$commit" | grep -qE "^- fix(\(.+\))?:"; then 178 | FIXES="${FIXES}${commit}\n" 179 | else 180 | OTHER="${OTHER}${commit}\n" 181 | fi 182 | done <<< "$COMMITS" 183 | 184 | # Build changelog 185 | CHANGELOG="## Changes\n\n" 186 | 187 | if [ -n "$BREAKING_CHANGES" ]; then 188 | CHANGELOG="${CHANGELOG}### 💥 BREAKING CHANGES\n${BREAKING_CHANGES}\n" 189 | fi 190 | 191 | if [ -n "$FEATURES" ]; then 192 | CHANGELOG="${CHANGELOG}### 🚀 Features\n${FEATURES}\n" 193 | fi 194 | 195 | if [ -n "$FIXES" ]; then 196 | CHANGELOG="${CHANGELOG}### 🐛 Bug Fixes\n${FIXES}\n" 197 | fi 198 | 199 | if [ -n "$OTHER" ]; then 200 | CHANGELOG="${CHANGELOG}### 📦 Other Changes\n${OTHER}\n" 201 | fi 202 | 203 | # Save changelog to file for multiline output 204 | echo -e "$CHANGELOG" > changelog.md 205 | echo "Generated changelog" 206 | 207 | - name: Build release binaries 208 | run: | 209 | VERSION=${{ steps.version.outputs.version }} make package-release 210 | 211 | - name: Create Release 212 | id: create_release 213 | uses: actions/create-release@v1 214 | env: 215 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 216 | with: 217 | tag_name: ${{ steps.version.outputs.version }} 218 | release_name: Release ${{ steps.version.outputs.version }} 219 | body_path: ./changelog.md 220 | draft: false 221 | prerelease: false 222 | 223 | - name: Upload Linux AMD64 224 | uses: actions/upload-release-asset@v1 225 | env: 226 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 227 | with: 228 | upload_url: ${{ steps.create_release.outputs.upload_url }} 229 | asset_path: ./release/playground-${{ steps.version.outputs.version }}-linux-amd64.tar.gz 230 | asset_name: playground-${{ steps.version.outputs.version }}-linux-amd64.tar.gz 231 | asset_content_type: application/gzip 232 | 233 | - name: Upload macOS Intel 234 | uses: actions/upload-release-asset@v1 235 | env: 236 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 237 | with: 238 | upload_url: ${{ steps.create_release.outputs.upload_url }} 239 | asset_path: ./release/playground-${{ steps.version.outputs.version }}-darwin-amd64.tar.gz 240 | asset_name: playground-${{ steps.version.outputs.version }}-darwin-amd64.tar.gz 241 | asset_content_type: application/gzip 242 | 243 | - name: Upload macOS Apple Silicon 244 | uses: actions/upload-release-asset@v1 245 | env: 246 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 247 | with: 248 | upload_url: ${{ steps.create_release.outputs.upload_url }} 249 | asset_path: ./release/playground-${{ steps.version.outputs.version }}-darwin-arm64.tar.gz 250 | asset_name: playground-${{ steps.version.outputs.version }}-darwin-arm64.tar.gz 251 | asset_content_type: application/gzip -------------------------------------------------------------------------------- /internal/plugins/loadbalancer.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "time" 8 | 9 | "github.com/mrgb7/playground/internal/k8s" 10 | "github.com/mrgb7/playground/pkg/logger" 11 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 13 | "k8s.io/apimachinery/pkg/runtime/schema" 14 | ) 15 | 16 | var ( 17 | repoURL = "https://metallb.github.io/metallb" 18 | chartName = "metallb" 19 | chartVersion = "0.14.9" 20 | releaseName = "metallb" 21 | namespace = "metallb-system" 22 | repoName = "metallb" 23 | ) 24 | 25 | type LoadBalancer struct { 26 | KubeConfig string 27 | k8sClient *k8s.K8sClient 28 | MasterClusterIP string 29 | ClusterName string 30 | *BasePlugin 31 | } 32 | 33 | func NewLoadBalancer(kubeConfig string, masterClusterIP string, clusterName string) (*LoadBalancer, error) { 34 | c, err := k8s.NewK8sClient(kubeConfig) 35 | if err != nil { 36 | return nil, fmt.Errorf("failed to create k8s client: %w", err) 37 | } 38 | lb := &LoadBalancer{ 39 | KubeConfig: kubeConfig, 40 | k8sClient: c, 41 | MasterClusterIP: masterClusterIP, 42 | ClusterName: clusterName, 43 | } 44 | lb.BasePlugin = NewBasePlugin(kubeConfig, lb) 45 | return lb, nil 46 | } 47 | 48 | func (l *LoadBalancer) GetOptions() PluginOptions { 49 | return PluginOptions{ 50 | Version: &chartVersion, 51 | Namespace: &namespace, 52 | ChartName: &chartName, 53 | RepoName: &repoName, 54 | Repository: &repoURL, 55 | releaseName: &releaseName, 56 | CRDsGroupVersion: "metallb.io", 57 | } 58 | } 59 | 60 | func (l *LoadBalancer) GetName() string { 61 | return "load-balancer" 62 | } 63 | 64 | func (l *LoadBalancer) Install(kubeConfig, clusterName string, ensure ...bool) error { 65 | err := l.UnifiedInstall(kubeConfig, clusterName, ensure...) 66 | if err != nil { 67 | return fmt.Errorf("failed to install loadbalancer: %w", err) 68 | } 69 | 70 | err = l.ensure() 71 | if err != nil { 72 | return fmt.Errorf("failed to ensure loadbalancer: %w", err) 73 | } 74 | 75 | err = l.deleteValidationWebhookConfig() 76 | if err != nil { 77 | return fmt.Errorf("failed to delete validation webhook config: %w", err) 78 | } 79 | err = l.addl2IpPool() 80 | if err != nil { 81 | return fmt.Errorf("failed to add l2 ip pool: %w", err) 82 | } 83 | err = l.addl2Adv() 84 | if err != nil { 85 | return fmt.Errorf("failed to add l2 advertisement: %w", err) 86 | } 87 | return nil 88 | } 89 | 90 | func (l *LoadBalancer) ensure() error { 91 | timeout := 5 * time.Minute 92 | ctx, cancel := context.WithTimeout(context.Background(), timeout) 93 | defer cancel() 94 | 95 | ticker := time.NewTicker(5 * time.Second) 96 | defer ticker.Stop() 97 | 98 | for { 99 | select { 100 | case <-ctx.Done(): 101 | return fmt.Errorf("timeout waiting for ensure %v", timeout) 102 | case <-ticker.C: 103 | _, err := l.k8sClient.GetNameSpace(namespace, ctx) 104 | if err != nil { 105 | continue 106 | } 107 | _, err = l.k8sClient.Clientset. 108 | AdmissionregistrationV1(). 109 | ValidatingWebhookConfigurations(). 110 | Get(ctx, "metallb-webhook-configuration", metav1.GetOptions{}) 111 | if err != nil { 112 | continue 113 | } 114 | logger.Successln("LoadBalancer is ensured") 115 | return nil 116 | } 117 | } 118 | } 119 | 120 | func (l *LoadBalancer) Uninstall(kubeConfig, clusterName string, ensure ...bool) error { 121 | logger.Infoln("Uninstalling loadbalancer") 122 | return l.UnifiedUninstall(kubeConfig, clusterName, ensure...) 123 | } 124 | 125 | func (l *LoadBalancer) Status() string { 126 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 127 | defer cancel() 128 | ns, err := l.k8sClient.GetNameSpace(namespace, ctx) 129 | if ns == "" || err != nil { 130 | logger.Debugf("failed to get metallb namespace: %v", err) 131 | return StatusNotInstalled 132 | } 133 | 134 | return StatusRunning 135 | } 136 | 137 | func (l *LoadBalancer) addl2IpPool() error { 138 | ipRange := l.getIPRange() 139 | ipPooRes := schema.GroupVersionResource{ 140 | Group: "metallb.io", 141 | Version: "v1beta1", 142 | Resource: "ipaddresspools", 143 | } 144 | 145 | ipPool := &unstructured.Unstructured{ 146 | Object: map[string]interface{}{ 147 | "apiVersion": "metallb.io/v1beta1", 148 | "kind": "IPAddressPool", 149 | "metadata": map[string]interface{}{ 150 | "name": "k3s-pool-ip", 151 | "namespace": "metallb-system", 152 | }, 153 | "spec": map[string]interface{}{ 154 | "addresses": []interface{}{ipRange}, 155 | }, 156 | }, 157 | } 158 | ipPool.SetGroupVersionKind(schema.GroupVersionKind{ 159 | Group: "metallb.io", 160 | Version: "v1beta1", 161 | Kind: "IPAddressPool", 162 | }) 163 | 164 | _, err := l.k8sClient.Dynamic.Resource(ipPooRes). 165 | Namespace(namespace). 166 | Create(context.TODO(), ipPool, metav1.CreateOptions{}) 167 | 168 | switch { 169 | case err != nil && strings.Contains(err.Error(), "already exists"): 170 | // Get the existing IP address pool to preserve metadata 171 | existing, getErr := l.k8sClient.Dynamic.Resource(ipPooRes). 172 | Namespace(namespace). 173 | Get(context.TODO(), "k3s-pool-ip", metav1.GetOptions{}) 174 | if getErr != nil { 175 | return fmt.Errorf("failed to get existing IP address pool: %w", getErr) 176 | } 177 | 178 | // Preserve the existing metadata and update only the spec 179 | ipPool.SetResourceVersion(existing.GetResourceVersion()) 180 | ipPool.SetUID(existing.GetUID()) 181 | ipPool.SetCreationTimestamp(existing.GetCreationTimestamp()) 182 | ipPool.SetGeneration(existing.GetGeneration()) 183 | 184 | // Copy any existing labels and annotations 185 | if labels := existing.GetLabels(); labels != nil { 186 | ipPool.SetLabels(labels) 187 | } 188 | if annotations := existing.GetAnnotations(); annotations != nil { 189 | ipPool.SetAnnotations(annotations) 190 | } 191 | 192 | _, err = l.k8sClient.Dynamic.Resource(ipPooRes). 193 | Namespace(namespace). 194 | Update(context.TODO(), ipPool, metav1.UpdateOptions{}) 195 | if err != nil { 196 | return fmt.Errorf("failed to update existing IP address pool: %w", err) 197 | } 198 | logger.Infoln("Updated existing IP address pool") 199 | case err != nil: 200 | logger.Errorln("failed to create ip address pool: %v", err) 201 | return fmt.Errorf("failed to create ip address pool: %w", err) 202 | default: 203 | logger.Successln("Created IP address pool successfully") 204 | } 205 | return nil 206 | } 207 | 208 | func (l *LoadBalancer) addl2Adv() error { 209 | l2AdvRes := schema.GroupVersionResource{ 210 | Group: "metallb.io", 211 | Version: "v1beta1", 212 | Resource: "l2advertisements", 213 | } 214 | 215 | l2Adv := &unstructured.Unstructured{ 216 | Object: map[string]interface{}{ 217 | "apiVersion": "metallb.io/v1beta1", 218 | "kind": "L2Advertisement", 219 | "metadata": map[string]interface{}{ 220 | "name": "k3s-lb-pool", 221 | "namespace": "metallb-system", 222 | }, 223 | "spec": map[string]interface{}{ 224 | "ipAddressPools": []interface{}{"k3s-pool-ip"}, 225 | }, 226 | }, 227 | } 228 | l2Adv.SetGroupVersionKind(schema.GroupVersionKind{ 229 | Group: "metallb.io", 230 | Version: "v1beta1", 231 | Kind: "L2Advertisement", 232 | }) 233 | 234 | _, err := l.k8sClient.Dynamic.Resource(l2AdvRes). 235 | Namespace(namespace). 236 | Create(context.TODO(), l2Adv, metav1.CreateOptions{}) 237 | 238 | switch { 239 | case err != nil && strings.Contains(err.Error(), "already exists"): 240 | // Get the existing L2Advertisement to preserve metadata 241 | existing, getErr := l.k8sClient.Dynamic.Resource(l2AdvRes). 242 | Namespace(namespace). 243 | Get(context.TODO(), "k3s-lb-pool", metav1.GetOptions{}) 244 | if getErr != nil { 245 | return fmt.Errorf("failed to get existing L2Advertisement: %w", getErr) 246 | } 247 | 248 | // Preserve the existing metadata and update only the spec 249 | l2Adv.SetResourceVersion(existing.GetResourceVersion()) 250 | l2Adv.SetUID(existing.GetUID()) 251 | l2Adv.SetCreationTimestamp(existing.GetCreationTimestamp()) 252 | l2Adv.SetGeneration(existing.GetGeneration()) 253 | 254 | // Copy any existing labels and annotations 255 | if labels := existing.GetLabels(); labels != nil { 256 | l2Adv.SetLabels(labels) 257 | } 258 | if annotations := existing.GetAnnotations(); annotations != nil { 259 | l2Adv.SetAnnotations(annotations) 260 | } 261 | 262 | _, err = l.k8sClient.Dynamic.Resource(l2AdvRes). 263 | Namespace(namespace). 264 | Update(context.TODO(), l2Adv, metav1.UpdateOptions{}) 265 | if err != nil { 266 | return fmt.Errorf("failed to update existing L2Advertisement: %w", err) 267 | } 268 | logger.Infoln("Updated existing L2Advertisement") 269 | case err != nil: 270 | logger.Errorln("failed to create l2 advertisement: %v", err) 271 | return fmt.Errorf("failed to create l2 advertisement: %w", err) 272 | default: 273 | logger.Successln("Created L2Advertisement successfully") 274 | } 275 | return nil 276 | } 277 | 278 | func (l *LoadBalancer) deleteValidationWebhookConfig() error { 279 | err := l.k8sClient.Clientset.AdmissionregistrationV1(). 280 | ValidatingWebhookConfigurations(). 281 | Delete(context.Background(), "metallb-webhook-configuration", metav1.DeleteOptions{}) 282 | if err != nil { 283 | if strings.Contains(err.Error(), "not found") { 284 | logger.Debugln("Validation webhook configuration already deleted") 285 | return nil // Already deleted 286 | } 287 | return fmt.Errorf("failed to delete validation webhook configuration: %w", err) 288 | } 289 | 290 | return nil 291 | } 292 | 293 | func (l *LoadBalancer) getIPRange() string { 294 | ipParts := strings.Split(l.MasterClusterIP, ".") 295 | dhcp := ipParts[:3] 296 | 297 | // Use cluster name to determine IP range offset to avoid conflicts 298 | clusterOffset := l.getClusterOffset() 299 | 300 | // Start from 100 and allocate 5 IPs per cluster to support more clusters 301 | // This allows for 31 clusters (100-254 range with 5 IPs each) 302 | baseStart := 100 303 | rangeSize := 5 304 | 305 | start := baseStart + (clusterOffset * rangeSize) 306 | end := start + rangeSize - 1 307 | 308 | // Ensure we don't exceed 254 (keeping 255 reserved) 309 | if end > 254 { 310 | // Fallback to a smaller range if we're near the limit 311 | start = 250 312 | end = 254 313 | logger.Warnln("Cluster %s: IP range limited due to address space constraints", l.ClusterName) 314 | } 315 | 316 | startIP := fmt.Sprintf("%s.%d", strings.Join(dhcp, "."), start) 317 | endIP := fmt.Sprintf("%s.%d", strings.Join(dhcp, "."), end) 318 | return fmt.Sprintf("%s-%s", startIP, endIP) 319 | } 320 | 321 | // getClusterOffset generates a consistent offset based on cluster name 322 | // Returns a value between 0-30 to support up to 31 clusters with 5 IPs each 323 | func (l *LoadBalancer) getClusterOffset() int { 324 | // Simple hash function to get a deterministic offset from cluster name 325 | hash := 0 326 | for _, c := range l.ClusterName { 327 | hash += int(c) 328 | } 329 | // Return offset between 0-30 to allow for 31 clusters with 5 IPs each (100-254) 330 | return hash % 31 331 | } 332 | 333 | func (l *LoadBalancer) GetDependencies() []string { 334 | return []string{} 335 | } 336 | -------------------------------------------------------------------------------- /cmd/cluster/create.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | "sync" 9 | 10 | "github.com/mrgb7/playground/internal/multipass" 11 | "github.com/mrgb7/playground/pkg/logger" 12 | "github.com/mrgb7/playground/types" 13 | "github.com/spf13/cobra" 14 | "k8s.io/client-go/tools/clientcmd" 15 | "k8s.io/client-go/tools/clientcmd/api" 16 | "k8s.io/client-go/util/homedir" 17 | ) 18 | 19 | // ClusterConfig holds the configuration for cluster creation 20 | 21 | // workerError represents an error that occurred while configuring a worker node 22 | type workerError struct { 23 | nodeName string 24 | err error 25 | } 26 | 27 | var ( 28 | cCreateName string 29 | cCreateSize int 30 | withCoreComponents bool 31 | masterCPUs int 32 | masterMemory string 33 | masterDisk string 34 | workerCPUs int 35 | workerMemory string 36 | workerDisk string 37 | ) 38 | 39 | const ( 40 | K3sCreateMasterCmd = `curl -sfL https://get.k3s.io | sh -s - --disable=servicelb --disable=traefik` 41 | GetAccessTokenCmd = `sudo cat /var/lib/rancher/k3s/server/node-token` //nolint:gosec 42 | K3sCreateWorkerCmd = `curl -sfL https://get.k3s.io | K3S_URL=https://%s:6443 K3S_TOKEN=%s sh -` 43 | KubeConfigCmd = `sudo cat /etc/rancher/k3s/k3s.yaml` 44 | K3sInstallTimeout = 300 // seconds - timeout for K3s installation 45 | DefaultMasterCPUs = 2 // default number of CPUs for master node 46 | DefaultWorkerCPUs = 2 // default number of CPUs for worker nodes 47 | 48 | ) 49 | 50 | var createCmd = &cobra.Command{ 51 | Use: "create", 52 | Short: "Create a new cluster", 53 | Long: `Create a new cluster with the specified configurations`, 54 | Run: func(cmd *cobra.Command, args []string) { 55 | config := &types.ClusterConfig{ 56 | Name: cCreateName, 57 | Size: cCreateSize, 58 | WithCoreComponents: withCoreComponents, 59 | MasterCPUs: masterCPUs, 60 | MasterMemory: masterMemory, 61 | MasterDisk: masterDisk, 62 | WorkerCPUs: workerCPUs, 63 | WorkerMemory: workerMemory, 64 | WorkerDisk: workerDisk, 65 | } 66 | 67 | if err := createCluster(config); err != nil { 68 | logger.Errorf("Failed to create cluster: %v", err) 69 | return 70 | } 71 | }, 72 | } 73 | 74 | func createCluster(config *types.ClusterConfig) error { 75 | client := multipass.NewMultipassClient() 76 | 77 | if !client.IsMultipassInstalled() { 78 | return fmt.Errorf("multipass is not installed or not in PATH") 79 | } 80 | 81 | cl := types.NewCluster(config.Name) 82 | 83 | err := cl.Validate(*config) 84 | if err != nil { 85 | return fmt.Errorf("validation failed: %w", err) 86 | } 87 | if cl.IsExists() { 88 | return fmt.Errorf("cluster '%s' already exists", config.Name) 89 | } 90 | 91 | return executeClusterCreation(client, config) 92 | } 93 | 94 | func executeClusterCreation(client multipass.Client, config *types.ClusterConfig) error { 95 | var wg sync.WaitGroup 96 | 97 | if err := client.CreateCluster( 98 | config.Name, config.Size, config.MasterCPUs, config.MasterMemory, config.MasterDisk, 99 | config.WorkerCPUs, config.WorkerMemory, config.WorkerDisk, &wg, 100 | ); err != nil { 101 | return fmt.Errorf("failed to create cluster: %w", err) 102 | } 103 | 104 | masterNodeName := fmt.Sprintf("%s-master", config.Name) 105 | 106 | // Install K3s on master node 107 | if err := installMasterNode(client, masterNodeName); err != nil { 108 | return fmt.Errorf("failed to install K3s on master: %w", err) 109 | } 110 | 111 | // Get access token and master IP 112 | accessToken, masterIP, err := getMasterCredentials(client, masterNodeName) 113 | if err != nil { 114 | return fmt.Errorf("failed to get master credentials: %w", err) 115 | } 116 | 117 | // Configure worker nodes 118 | workerErrors := configureWorkerNodes(client, config, masterIP, accessToken) 119 | 120 | // Report results 121 | reportClusterCreationResults(config, workerErrors) 122 | 123 | // Update kubeconfig 124 | return updateKubeConfig(client, masterNodeName, config.Name) 125 | } 126 | 127 | func installMasterNode(client multipass.Client, masterNodeName string) error { 128 | std, err := client.ExecuteShellWithTimeout(masterNodeName, K3sCreateMasterCmd, K3sInstallTimeout) 129 | if err != nil || std == "" { 130 | return fmt.Errorf("failed to create k3s on master: %w", err) 131 | } 132 | return nil 133 | } 134 | 135 | func getMasterCredentials(client multipass.Client, masterNodeName string) (string, string, error) { 136 | accessToken, err := client.ExecuteShell(masterNodeName, GetAccessTokenCmd) 137 | if err != nil || accessToken == "" { 138 | return "", "", fmt.Errorf("failed to get access token: %w", err) 139 | } 140 | accessToken = strings.TrimSpace(accessToken) 141 | 142 | masterIP, err := client.GetNodeIP(masterNodeName) 143 | if err != nil || masterIP == "" { 144 | return "", "", fmt.Errorf("failed to get master node IP: %w", err) 145 | } 146 | 147 | return accessToken, masterIP, nil 148 | } 149 | 150 | func configureWorkerNodes(client multipass.Client, config *types.ClusterConfig, masterIP, accessToken string) []workerError { 151 | workerErrors := make([]workerError, 0) 152 | var workerErrorsMutex sync.Mutex 153 | var wg sync.WaitGroup 154 | 155 | for i := 0; i < config.Size-1; i++ { 156 | wg.Add(1) 157 | go func(i int) { 158 | defer wg.Done() 159 | nodeName := fmt.Sprintf("%s-worker-%d", config.Name, i+1) 160 | _, err := client.ExecuteShellWithTimeout( 161 | nodeName, 162 | fmt.Sprintf(K3sCreateWorkerCmd, masterIP, accessToken), 163 | K3sInstallTimeout, 164 | ) 165 | if err != nil { 166 | workerErrorsMutex.Lock() 167 | workerErrors = append(workerErrors, workerError{ 168 | nodeName: nodeName, 169 | err: err, 170 | }) 171 | workerErrorsMutex.Unlock() 172 | logger.Errorln("Failed to install K3S on worker node %s: %v", nodeName, err) 173 | } else { 174 | logger.Successf("Successfully configured worker node: %s\n", nodeName) 175 | } 176 | }(i) 177 | } 178 | wg.Wait() 179 | 180 | return workerErrors 181 | } 182 | 183 | func reportClusterCreationResults(config *types.ClusterConfig, workerErrors []workerError) { 184 | if len(workerErrors) > 0 { 185 | logger.Warnln("Some worker nodes failed to configure properly:") 186 | for _, we := range workerErrors { 187 | logger.Errorln(" - %s: %v", we.nodeName, we.err) 188 | } 189 | logger.Warnln("Cluster created with %d/%d worker nodes successfully configured", 190 | config.Size-1-len(workerErrors), config.Size-1) 191 | } else { 192 | logger.Successln("Successfully created cluster '%s' with %d nodes", config.Name, config.Size) 193 | } 194 | } 195 | 196 | func updateKubeConfig(client multipass.Client, masterNodeName, clusterName string) error { 197 | logger.Infoln("Attempting to update kubeconfig...") 198 | 199 | kubConfig, err := client.ExecuteShell(masterNodeName, KubeConfigCmd) 200 | if err != nil || kubConfig == "" { 201 | return fmt.Errorf("failed to get kube config: %w", err) 202 | } 203 | 204 | // Get master IP to replace 127.0.0.1 in kubeconfig 205 | masterIP, err := client.GetNodeIP(masterNodeName) 206 | if err != nil { 207 | return fmt.Errorf("failed to get master IP: %w", err) 208 | } 209 | 210 | // Replace localhost with master IP 211 | kubConfig = strings.ReplaceAll(kubConfig, "127.0.0.1", masterIP) 212 | 213 | if err := createKubeConfigFile(kubConfig, clusterName); err != nil { 214 | logger.Errorln("Failed to update kubeconfig: %v", err) 215 | logger.Warnln("Cluster created successfully, but kubeconfig update failed.") 216 | logger.Infof("You can manually retrieve the kubeconfig using: playground cluster kubeconfig --name %s\n", clusterName) 217 | return err 218 | } 219 | 220 | logger.Successln("Successfully updated kubeconfig.") 221 | return nil 222 | } 223 | 224 | func createKubeConfigFile(kubeConfig, clusterName string) error { 225 | // Use client-go to properly parse the K3s kubeconfig format 226 | newConfig, err := clientcmd.Load([]byte(kubeConfig)) 227 | if err != nil { 228 | return fmt.Errorf("failed to parse new kubeconfig: %w", err) 229 | } 230 | 231 | // Update context and cluster names to include cluster name 232 | contextName := fmt.Sprintf("%s-context", clusterName) 233 | clusterKey := fmt.Sprintf("%s-cluster", clusterName) 234 | userKey := fmt.Sprintf("%s-user", clusterName) 235 | 236 | // Rename the default entries to use cluster-specific names 237 | if cluster, exists := newConfig.Clusters["default"]; exists { 238 | delete(newConfig.Clusters, "default") 239 | newConfig.Clusters[clusterKey] = cluster 240 | } 241 | 242 | if authInfo, exists := newConfig.AuthInfos["default"]; exists { 243 | delete(newConfig.AuthInfos, "default") 244 | newConfig.AuthInfos[userKey] = authInfo 245 | } 246 | 247 | if context, exists := newConfig.Contexts["default"]; exists { 248 | delete(newConfig.Contexts, "default") 249 | context.Cluster = clusterKey 250 | context.AuthInfo = userKey 251 | newConfig.Contexts[contextName] = context 252 | } 253 | 254 | // Set current context to the new cluster 255 | newConfig.CurrentContext = contextName 256 | 257 | kubeconfigPath := filepath.Join(homedir.HomeDir(), ".kube", "config") 258 | var existingConfig *api.Config 259 | 260 | if _, err := os.Stat(kubeconfigPath); os.IsNotExist(err) { 261 | existingConfig = api.NewConfig() 262 | } else { 263 | existingConfig, err = clientcmd.LoadFromFile(kubeconfigPath) 264 | if err != nil { 265 | return fmt.Errorf("failed to load existing kubeconfig: %w", err) 266 | } 267 | } 268 | 269 | // Merge configurations 270 | for name, cluster := range newConfig.Clusters { 271 | existingConfig.Clusters[name] = cluster 272 | } 273 | 274 | for name, authInfo := range newConfig.AuthInfos { 275 | existingConfig.AuthInfos[name] = authInfo 276 | } 277 | 278 | for name, context := range newConfig.Contexts { 279 | existingConfig.Contexts[name] = context 280 | } 281 | 282 | // Set current context to the new cluster 283 | existingConfig.CurrentContext = contextName 284 | 285 | if err := clientcmd.WriteToFile(*existingConfig, kubeconfigPath); err != nil { 286 | return fmt.Errorf("failed to write merged kubeconfig: %w", err) 287 | } 288 | 289 | return nil 290 | } 291 | 292 | func init() { 293 | createCmd.Flags().StringVarP(&cCreateName, "name", "n", "", "Name for the cluster (required)") 294 | createCmd.Flags().IntVarP(&cCreateSize, "size", "s", 1, "Number of nodes in the cluster") 295 | createCmd.Flags().BoolVarP(&withCoreComponents, "with-core-component", "c", false, 296 | "Install core components (nginx,cert-manager)") 297 | createCmd.Flags().IntVarP(&masterCPUs, "master-cpus", "m", DefaultMasterCPUs, "Number of CPUs for the master node") 298 | createCmd.Flags().StringVarP(&masterMemory, "master-memory", "M", "2G", "Memory for the master node") 299 | createCmd.Flags().StringVarP(&masterDisk, "master-disk", "D", "20G", "Disk for the master node") 300 | createCmd.Flags().IntVarP(&workerCPUs, "worker-cpus", "w", DefaultWorkerCPUs, "Number of CPUs for each worker node") 301 | createCmd.Flags().StringVarP(&workerMemory, "worker-memory", "W", "2G", "Memory for each worker node") 302 | createCmd.Flags().StringVarP(&workerDisk, "worker-disk", "d", "20G", "Disk for each worker node") 303 | if err := createCmd.MarkFlagRequired("name"); err != nil { 304 | logger.Errorln("Failed to mark name flag as required: %v", err) 305 | } 306 | } 307 | -------------------------------------------------------------------------------- /internal/plugins/dependency.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/mrgb7/playground/pkg/logger" 8 | ) 9 | 10 | type DependencyPlugin interface { 11 | Plugin 12 | GetDependencies() []string 13 | } 14 | 15 | type GraphNode struct { 16 | Plugin DependencyPlugin 17 | Dependencies []string 18 | Dependents []string 19 | } 20 | 21 | type DependencyGraph struct { 22 | nodes map[string]*GraphNode 23 | } 24 | 25 | func NewDependencyGraph() *DependencyGraph { 26 | return &DependencyGraph{ 27 | nodes: make(map[string]*GraphNode), 28 | } 29 | } 30 | 31 | func (dg *DependencyGraph) AddPlugin(plugin DependencyPlugin) { 32 | name := plugin.GetName() 33 | dependencies := plugin.GetDependencies() 34 | 35 | if dg.nodes[name] == nil { 36 | dg.nodes[name] = &GraphNode{ 37 | Plugin: plugin, 38 | Dependencies: make([]string, len(dependencies)), 39 | Dependents: make([]string, 0), 40 | } 41 | } else { 42 | dg.nodes[name].Plugin = plugin 43 | dg.nodes[name].Dependencies = make([]string, len(dependencies)) 44 | } 45 | 46 | copy(dg.nodes[name].Dependencies, dependencies) 47 | 48 | for _, dep := range dependencies { 49 | if dg.nodes[dep] == nil { 50 | dg.nodes[dep] = &GraphNode{ 51 | Plugin: nil, 52 | Dependencies: make([]string, 0), 53 | Dependents: make([]string, 0), 54 | } 55 | } 56 | 57 | found := false 58 | for _, dependent := range dg.nodes[dep].Dependents { 59 | if dependent == name { 60 | found = true 61 | break 62 | } 63 | } 64 | if !found { 65 | dg.nodes[dep].Dependents = append(dg.nodes[dep].Dependents, name) 66 | } 67 | } 68 | } 69 | 70 | func (dg *DependencyGraph) GetInstallOrder(targetPlugins []string) ([]string, error) { 71 | if len(targetPlugins) == 0 { 72 | return []string{}, nil 73 | } 74 | 75 | required := make(map[string]bool) 76 | for _, plugin := range targetPlugins { 77 | if err := dg.collectDependencies(plugin, required); err != nil { 78 | return nil, err 79 | } 80 | } 81 | 82 | plugins := make([]string, 0, len(required)) 83 | for plugin := range required { 84 | plugins = append(plugins, plugin) 85 | } 86 | 87 | return dg.topologicalSort(plugins) 88 | } 89 | 90 | func (dg *DependencyGraph) GetUninstallOrder(targetPlugins []string) ([]string, error) { 91 | if len(targetPlugins) == 0 { 92 | return []string{}, nil 93 | } 94 | 95 | toUninstall := make(map[string]bool) 96 | for _, plugin := range targetPlugins { 97 | if err := dg.collectDependents(plugin, toUninstall); err != nil { 98 | return nil, err 99 | } 100 | } 101 | 102 | plugins := make([]string, 0, len(toUninstall)) 103 | for plugin := range toUninstall { 104 | plugins = append(plugins, plugin) 105 | } 106 | 107 | order, err := dg.topologicalSort(plugins) 108 | if err != nil { 109 | return nil, err 110 | } 111 | 112 | for i, j := 0, len(order)-1; i < j; i, j = i+1, j-1 { 113 | order[i], order[j] = order[j], order[i] 114 | } 115 | 116 | return order, nil 117 | } 118 | 119 | func (dg *DependencyGraph) ValidateInstall(pluginName string, installedPlugins []string) error { 120 | node := dg.nodes[pluginName] 121 | if node == nil || node.Plugin == nil { 122 | return fmt.Errorf("plugin '%s' not found in dependency graph", pluginName) 123 | } 124 | 125 | installedSet := make(map[string]bool) 126 | for _, p := range installedPlugins { 127 | installedSet[p] = true 128 | } 129 | 130 | missingDeps := make([]string, 0) 131 | for _, dep := range node.Dependencies { 132 | if !installedSet[dep] { 133 | missingDeps = append(missingDeps, dep) 134 | } 135 | } 136 | 137 | if len(missingDeps) > 0 { 138 | return fmt.Errorf("plugin '%s' has unmet dependencies: %s", 139 | pluginName, strings.Join(missingDeps, ", ")) 140 | } 141 | 142 | return nil 143 | } 144 | 145 | func (dg *DependencyGraph) ValidateUninstall(pluginName string, installedPlugins []string) error { 146 | installedSet := make(map[string]bool) 147 | for _, p := range installedPlugins { 148 | installedSet[p] = true 149 | } 150 | 151 | node := dg.nodes[pluginName] 152 | if node == nil { 153 | return nil 154 | } 155 | 156 | blockers := make([]string, 0) 157 | for _, dependent := range node.Dependents { 158 | if installedSet[dependent] { 159 | blockers = append(blockers, dependent) 160 | } 161 | } 162 | 163 | if len(blockers) > 0 { 164 | return fmt.Errorf("cannot uninstall '%s': the following installed plugins depend on it: %s", 165 | pluginName, strings.Join(blockers, ", ")) 166 | } 167 | 168 | return nil 169 | } 170 | 171 | func (dg *DependencyGraph) GetDependencies(pluginName string) []string { 172 | node := dg.nodes[pluginName] 173 | if node == nil { 174 | return []string{} 175 | } 176 | return node.Dependencies 177 | } 178 | 179 | func (dg *DependencyGraph) GetDependents(pluginName string) []string { 180 | node := dg.nodes[pluginName] 181 | if node == nil { 182 | return []string{} 183 | } 184 | return node.Dependents 185 | } 186 | 187 | func (dg *DependencyGraph) HasCycles() bool { 188 | visited := make(map[string]bool) 189 | recStack := make(map[string]bool) 190 | 191 | for pluginName := range dg.nodes { 192 | if !visited[pluginName] { 193 | if dg.hasCyclesDFS(pluginName, visited, recStack) { 194 | return true 195 | } 196 | } 197 | } 198 | 199 | return false 200 | } 201 | 202 | func (dg *DependencyGraph) collectDependencies(pluginName string, collected map[string]bool) error { 203 | return dg.collectDependenciesWithStack(pluginName, collected, make(map[string]bool)) 204 | } 205 | 206 | func (dg *DependencyGraph) collectDependenciesWithStack(pluginName string, collected, stack map[string]bool) error { 207 | if collected[pluginName] { 208 | return nil 209 | } 210 | 211 | if stack[pluginName] { 212 | return fmt.Errorf("circular dependency detected involving plugin '%s'", pluginName) 213 | } 214 | 215 | node := dg.nodes[pluginName] 216 | if node == nil || node.Plugin == nil { 217 | return fmt.Errorf("plugin '%s' not found", pluginName) 218 | } 219 | 220 | stack[pluginName] = true 221 | 222 | for _, dep := range node.Dependencies { 223 | if err := dg.collectDependenciesWithStack(dep, collected, stack); err != nil { 224 | return err 225 | } 226 | } 227 | 228 | stack[pluginName] = false 229 | collected[pluginName] = true 230 | return nil 231 | } 232 | 233 | func (dg *DependencyGraph) collectDependents(pluginName string, collected map[string]bool) error { 234 | if collected[pluginName] { 235 | return nil 236 | } 237 | 238 | collected[pluginName] = true 239 | 240 | node := dg.nodes[pluginName] 241 | if node == nil { 242 | return nil 243 | } 244 | 245 | for _, dependent := range node.Dependents { 246 | if err := dg.collectDependents(dependent, collected); err != nil { 247 | return err 248 | } 249 | } 250 | 251 | return nil 252 | } 253 | 254 | func (dg *DependencyGraph) topologicalSort(plugins []string) ([]string, error) { 255 | inDegree := make(map[string]int) 256 | for _, plugin := range plugins { 257 | inDegree[plugin] = 0 258 | } 259 | 260 | for _, plugin := range plugins { 261 | node := dg.nodes[plugin] 262 | if node != nil { 263 | for _, dep := range node.Dependencies { 264 | if _, exists := inDegree[dep]; exists { 265 | inDegree[plugin]++ 266 | } 267 | } 268 | } 269 | } 270 | 271 | queue := make([]string, 0) 272 | for plugin, degree := range inDegree { 273 | if degree == 0 { 274 | queue = append(queue, plugin) 275 | } 276 | } 277 | 278 | result := make([]string, 0, len(plugins)) 279 | 280 | for len(queue) > 0 { 281 | current := queue[0] 282 | queue = queue[1:] 283 | result = append(result, current) 284 | 285 | for _, dependent := range plugins { 286 | node := dg.nodes[dependent] 287 | if node != nil { 288 | for _, dep := range node.Dependencies { 289 | if dep == current { 290 | inDegree[dependent]-- 291 | if inDegree[dependent] == 0 { 292 | queue = append(queue, dependent) 293 | } 294 | } 295 | } 296 | } 297 | } 298 | } 299 | 300 | if len(result) != len(plugins) { 301 | return nil, fmt.Errorf("circular dependency detected") 302 | } 303 | 304 | return result, nil 305 | } 306 | 307 | func (dg *DependencyGraph) hasCyclesDFS(plugin string, visited, recStack map[string]bool) bool { 308 | visited[plugin] = true 309 | recStack[plugin] = true 310 | 311 | node := dg.nodes[plugin] 312 | if node != nil { 313 | for _, dep := range node.Dependencies { 314 | if !visited[dep] { 315 | if dg.hasCyclesDFS(dep, visited, recStack) { 316 | return true 317 | } 318 | } else if recStack[dep] { 319 | return true 320 | } 321 | } 322 | } 323 | 324 | recStack[plugin] = false 325 | return false 326 | } 327 | 328 | type DependencyValidator struct { 329 | graph *DependencyGraph 330 | } 331 | 332 | func NewDependencyValidator(plugins []DependencyPlugin) *DependencyValidator { 333 | graph := NewDependencyGraph() 334 | 335 | for _, plugin := range plugins { 336 | graph.AddPlugin(plugin) 337 | } 338 | 339 | if graph.HasCycles() { 340 | logger.Error("Circular dependency detected in plugin graph") 341 | } 342 | 343 | return &DependencyValidator{ 344 | graph: graph, 345 | } 346 | } 347 | 348 | func (dv *DependencyValidator) ValidateInstallation(targetPlugins []string, installedPlugins []string) ([]string, error) { 349 | logger.Infoln("Validating plugin installation dependencies...") 350 | 351 | installOrder, err := dv.graph.GetInstallOrder(targetPlugins) 352 | if err != nil { 353 | return nil, fmt.Errorf("failed to determine install order: %w", err) 354 | } 355 | 356 | installedSet := make(map[string]bool) 357 | for _, p := range installedPlugins { 358 | installedSet[p] = true 359 | } 360 | 361 | needsInstallation := make([]string, 0) 362 | for _, plugin := range installOrder { 363 | if !installedSet[plugin] { 364 | if err := dv.graph.ValidateInstall(plugin, installedPlugins); err != nil { 365 | return nil, err 366 | } 367 | needsInstallation = append(needsInstallation, plugin) 368 | installedPlugins = append(installedPlugins, plugin) 369 | installedSet[plugin] = true 370 | } 371 | } 372 | 373 | logger.Successln("Dependency validation passed") 374 | return needsInstallation, nil 375 | } 376 | 377 | func (dv *DependencyValidator) ValidateUninstallation(targetPlugins []string, installedPlugins []string) ([]string, error) { 378 | logger.Infoln("Validating plugin uninstallation dependencies...") 379 | 380 | uninstallOrder, err := dv.graph.GetUninstallOrder(targetPlugins) 381 | if err != nil { 382 | return nil, fmt.Errorf("failed to determine uninstall order: %w", err) 383 | } 384 | 385 | installedSet := make(map[string]bool) 386 | for _, p := range installedPlugins { 387 | installedSet[p] = true 388 | } 389 | 390 | needsUninstallation := make([]string, 0) 391 | for _, plugin := range uninstallOrder { 392 | if installedSet[plugin] { 393 | if err := dv.graph.ValidateUninstall(plugin, installedPlugins); err != nil { 394 | return nil, err 395 | } 396 | needsUninstallation = append(needsUninstallation, plugin) 397 | newInstalled := make([]string, 0) 398 | for _, p := range installedPlugins { 399 | if p != plugin { 400 | newInstalled = append(newInstalled, p) 401 | } 402 | } 403 | installedPlugins = newInstalled 404 | installedSet[plugin] = false 405 | } 406 | } 407 | 408 | logger.Successln("Dependency validation passed") 409 | return needsUninstallation, nil 410 | } 411 | 412 | func (dv *DependencyValidator) GetDependencyInfo(pluginName string) (dependencies []string, dependents []string) { 413 | return dv.graph.GetDependencies(pluginName), dv.graph.GetDependents(pluginName) 414 | } 415 | -------------------------------------------------------------------------------- /internal/multipass/client.go: -------------------------------------------------------------------------------- 1 | package multipass 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "os" 9 | "os/exec" 10 | "strings" 11 | "sync" 12 | "time" 13 | 14 | "github.com/mrgb7/playground/pkg/logger" 15 | ) 16 | 17 | type Client interface { 18 | IsMultipassInstalled() bool 19 | CreateCluster(clusterName string, nodeCount int, masterCPUs int, masterMemory, masterDisk string, 20 | workerCPUs int, workerMemory, workerDisk string, wg *sync.WaitGroup) error 21 | DeleteCluster(clusterName string, wg *sync.WaitGroup) error 22 | ListClusters() ([]string, error) 23 | CreateNode(name string, cpus int, memory string, disk string) error 24 | DeleteNode(name string) error 25 | PurgeNodes() error 26 | GetNodeIP(name string) (string, error) 27 | ExecuteShell(name string, command string) (string, error) 28 | ExecuteShellWithTimeout(name string, command string, timeoutSeconds int, envs ...string) (string, error) 29 | } 30 | 31 | type MultiPassList struct { 32 | List []MultiPassListItem `json:"list"` 33 | } 34 | 35 | type MultiPassListItem struct { 36 | Name string `json:"name"` 37 | State string `json:"state"` 38 | } 39 | 40 | type MultiPassInfo struct { 41 | Errors []interface{} `json:"errors"` 42 | Info map[string]MultiPassNode `json:"info"` 43 | } 44 | 45 | type MultiPassNode struct { 46 | IPv4 []string `json:"ipv4"` 47 | State string `json:"state"` 48 | } 49 | 50 | type MultipassClient struct { 51 | BinaryPath string 52 | } 53 | 54 | const ( 55 | DefaultMasterCPUs = 2 56 | DefaultMasterMemory = "2G" 57 | DefaultMasterDisk = "10G" 58 | DefaultWorkerCPUs = 1 59 | DefaultWorkerMemory = "1G" 60 | DefaultWorkerDisk = "5G" 61 | ) 62 | 63 | func NewMultipassClient() *MultipassClient { 64 | return &MultipassClient{ 65 | BinaryPath: "multipass", 66 | } 67 | } 68 | 69 | func (m *MultipassClient) IsMultipassInstalled() bool { 70 | cmd := exec.Command(m.BinaryPath, "--version") //nolint:gosec 71 | err := cmd.Run() 72 | return err == nil 73 | } 74 | 75 | func (m *MultipassClient) CreateCluster( 76 | clusterName string, nodeCount int, masterCPUs int, masterMemory, masterDisk string, 77 | workerCPUs int, workerMemory, workerDisk string, wg *sync.WaitGroup, 78 | ) error { 79 | masterName := fmt.Sprintf("%s-master", clusterName) 80 | errChan := make(chan error, nodeCount) 81 | 82 | wg.Add(1) 83 | go func(name string) { 84 | defer wg.Done() 85 | err := m.CreateNode(name, masterCPUs, masterMemory, masterDisk) 86 | if err != nil { 87 | logger.Errorf("failed to create master node %s: %v\n", name, err) 88 | errChan <- fmt.Errorf("failed to create master node %s: %w", name, err) 89 | return 90 | } 91 | logger.Debugln("Master node %s created successfully", name) 92 | }(masterName) 93 | 94 | for i := 1; i < nodeCount; i++ { 95 | wg.Add(1) 96 | go func(workerIndex int) { 97 | defer wg.Done() 98 | nodeName := fmt.Sprintf("%s-worker-%d", clusterName, workerIndex) 99 | err := m.CreateNode(nodeName, workerCPUs, workerMemory, workerDisk) 100 | if err != nil { 101 | logger.Errorln("failed to create worker node %s: %v", nodeName, err) 102 | errChan <- fmt.Errorf("failed to create worker node %s: %w", nodeName, err) 103 | return 104 | } 105 | logger.Debugln("Worker node %s created successfully", nodeName) 106 | }(i) 107 | } 108 | 109 | go func() { 110 | wg.Wait() 111 | close(errChan) 112 | }() 113 | 114 | var creationErrors []error 115 | for err := range errChan { 116 | if err != nil { 117 | creationErrors = append(creationErrors, err) 118 | } 119 | } 120 | 121 | if len(creationErrors) > 0 { 122 | logger.Errorln("Error during cluster creation for '%s', attempting cleanup.", clusterName) 123 | 124 | var cleanupWG sync.WaitGroup 125 | if cleanupErr := m.DeleteCluster(clusterName, &cleanupWG); cleanupErr != nil { 126 | logger.Errorln("Failed to cleanup cluster %s during error recovery: %v", clusterName, cleanupErr) 127 | } 128 | 129 | return creationErrors[0] 130 | } 131 | 132 | logger.Debugln("Cluster %s created successfully with %d total instances.", clusterName, nodeCount) 133 | return nil 134 | } 135 | 136 | func (m *MultipassClient) DeleteCluster(clusterName string, wg *sync.WaitGroup) error { 137 | var list MultiPassList 138 | cmd := exec.Command(m.BinaryPath, "list", "--format", "json") //nolint:gosec 139 | var stdout, stderr bytes.Buffer 140 | cmd.Stdout = &stdout 141 | cmd.Stderr = &stderr 142 | if err := cmd.Run(); err != nil { 143 | return fmt.Errorf("failed to list instances: %s - %w", stderr.String(), err) 144 | } 145 | if err := json.Unmarshal(stdout.Bytes(), &list); err != nil { 146 | return fmt.Errorf("failed to parse JSON output: %w", err) 147 | } 148 | 149 | var instancesToDelete []string 150 | for _, instance := range list.List { 151 | if strings.HasPrefix(instance.Name, clusterName) { 152 | instancesToDelete = append(instancesToDelete, instance.Name) 153 | } 154 | } 155 | 156 | errChan := make(chan error, len(instancesToDelete)) 157 | 158 | for _, instanceName := range instancesToDelete { 159 | wg.Add(1) 160 | go func(name string) { 161 | defer wg.Done() 162 | if err := m.DeleteNode(name); err != nil { 163 | errChan <- fmt.Errorf("failed to delete node %s: %w", name, err) 164 | return 165 | } 166 | logger.Debugf("Successfully deleted node: %s", name) 167 | }(instanceName) 168 | } 169 | 170 | go func() { 171 | wg.Wait() 172 | close(errChan) 173 | }() 174 | 175 | var errors []error 176 | for err := range errChan { 177 | if err != nil { 178 | errors = append(errors, err) 179 | } 180 | } 181 | 182 | if len(errors) > 0 { 183 | var errMessages []string 184 | for _, err := range errors { 185 | errMessages = append(errMessages, err.Error()) 186 | } 187 | return fmt.Errorf("multiple deletion errors: %s", strings.Join(errMessages, "; ")) 188 | } 189 | 190 | return nil 191 | } 192 | 193 | func (m *MultipassClient) CreateNode(name string, cpus int, memory string, disk string) error { 194 | args := []string{ 195 | "launch", 196 | "--name", name, 197 | "--cpus", fmt.Sprintf("%d", cpus), 198 | "--memory", memory, 199 | "--disk", disk, 200 | } 201 | 202 | logger.Debugln("Creating node: %s with %d CPUs, %s memory, %s disk", name, cpus, memory, disk) 203 | cmd := exec.Command(m.BinaryPath, args...) //nolint:gosec 204 | var stdout, stderr bytes.Buffer 205 | cmd.Stdout = &stdout 206 | cmd.Stderr = &stderr 207 | 208 | if err := cmd.Run(); err != nil { 209 | return fmt.Errorf("failed to create node '%s': %s - %w", name, stderr.String(), err) 210 | } 211 | 212 | return nil 213 | } 214 | 215 | func (m *MultipassClient) DeleteNode(name string) error { 216 | cmd := exec.Command(m.BinaryPath, "delete", name) //nolint:gosec 217 | var stderr bytes.Buffer 218 | cmd.Stderr = &stderr 219 | 220 | if err := cmd.Run(); err != nil { 221 | return fmt.Errorf("failed to delete node '%s': %s - %w", name, stderr.String(), err) 222 | } 223 | 224 | logger.Debugln("Successfully deleted node '%s'", name) 225 | return nil 226 | } 227 | 228 | func (m *MultipassClient) PurgeNodes() error { 229 | logger.Infoln("Purging deleted nodes") 230 | // Binary path is controlled, this is a legitimate multipass CLI call 231 | cmd := exec.Command(m.BinaryPath, "purge") //nolint:gosec 232 | var stderr bytes.Buffer 233 | cmd.Stderr = &stderr 234 | 235 | if err := cmd.Run(); err != nil { 236 | return fmt.Errorf("failed to purge deleted instances: %s - %w", stderr.String(), err) 237 | } 238 | 239 | logger.Successln("Successfully purged deleted nodes") 240 | return nil 241 | } 242 | 243 | func (m *MultipassClient) GetNodeIP(name string) (string, error) { 244 | cmd := exec.Command(m.BinaryPath, "info", name, "--format", "json") //nolint:gosec 245 | var stdout, stderr bytes.Buffer 246 | cmd.Stdout = &stdout 247 | cmd.Stderr = &stderr 248 | 249 | if err := cmd.Run(); err != nil { 250 | return "", fmt.Errorf("failed to get IP address for node '%s': %s - %w", name, stderr.String(), err) 251 | } 252 | 253 | var data MultiPassInfo 254 | if err := json.Unmarshal(stdout.Bytes(), &data); err != nil { 255 | return "", fmt.Errorf("failed to parse JSON output: %w", err) 256 | } 257 | 258 | nodeInfo, exists := data.Info[name] 259 | if !exists { 260 | return "", fmt.Errorf("node '%s' not found in multipass info", name) 261 | } 262 | 263 | if len(nodeInfo.IPv4) == 0 { 264 | return "", fmt.Errorf("no IPv4 addresses found for node '%s'", name) 265 | } 266 | 267 | ip := nodeInfo.IPv4[0] 268 | return ip, nil 269 | } 270 | 271 | func (m *MultipassClient) ExecuteShell(name string, command string) (string, error) { 272 | return m.ExecuteShellWithTimeout(name, command, 0) // No timeout by default 273 | } 274 | 275 | func (m *MultipassClient) ExecuteShellWithTimeout(name string, command string, timeoutSeconds int, 276 | envs ...string, 277 | ) (string, error) { 278 | ctx := context.Background() 279 | var cancel context.CancelFunc 280 | 281 | if timeoutSeconds > 0 { 282 | ctx, cancel = context.WithTimeout(context.Background(), time.Duration(timeoutSeconds)*time.Second) 283 | defer cancel() 284 | } 285 | 286 | cmd := exec.CommandContext(ctx, m.BinaryPath, "exec", name, "--", "bash", "-c", command) //nolint:gosec 287 | cmd.Env = append(os.Environ(), envs...) 288 | var stdout, stderr bytes.Buffer 289 | cmd.Stdout = &stdout 290 | cmd.Stderr = &stderr 291 | if err := cmd.Run(); err != nil { 292 | logger.Errorln("Failed to execute command on node '%s': %v", name, err) 293 | if ctx.Err() == context.DeadlineExceeded { 294 | return stdout.String(), fmt.Errorf("command timed out after %d seconds", timeoutSeconds) 295 | } 296 | 297 | errMsg := fmt.Sprintf("Failed to execute shell command on node '%s': %s", name, stderr.String()) 298 | logger.Errorln(errMsg) 299 | return "", fmt.Errorf("failed to execute shell command on node '%s': %s - %w", name, stderr.String(), err) 300 | } 301 | 302 | return stdout.String(), nil 303 | } 304 | 305 | func (m *MultipassClient) ListClusters() ([]string, error) { 306 | var list MultiPassList 307 | cmd := exec.Command(m.BinaryPath, "list", "--format", "json") //nolint:gosec 308 | var stdout, stderr bytes.Buffer 309 | cmd.Stdout = &stdout 310 | cmd.Stderr = &stderr 311 | if err := cmd.Run(); err != nil { 312 | return nil, fmt.Errorf("failed to list instances: %s - %w", stderr.String(), err) 313 | } 314 | if err := json.Unmarshal(stdout.Bytes(), &list); err != nil { 315 | return nil, fmt.Errorf("failed to parse JSON output: %w", err) 316 | } 317 | 318 | var clusters []string 319 | seenClusters := make(map[string]bool) // To avoid duplicates 320 | 321 | for _, instance := range list.List { 322 | if strings.HasSuffix(instance.Name, "-master") { 323 | clusterName := strings.TrimSuffix(instance.Name, "-master") 324 | if !seenClusters[clusterName] { 325 | clusters = append(clusters, clusterName) 326 | seenClusters[clusterName] = true 327 | } 328 | } 329 | } 330 | 331 | return clusters, nil 332 | } 333 | 334 | func (m *MultipassClient) GetClusterInfo(clusterName string) (*MultiPassInfo, error) { 335 | masterName := clusterName + "-master" 336 | cmd := exec.Command(m.BinaryPath, "info", masterName, "--format", "json") //nolint:gosec 337 | var stdout, stderr bytes.Buffer 338 | cmd.Stdout = &stdout 339 | cmd.Stderr = &stderr 340 | 341 | if err := cmd.Run(); err != nil { 342 | return nil, fmt.Errorf("failed to get info for cluster '%s': %s - %w", clusterName, stderr.String(), err) 343 | } 344 | 345 | var info MultiPassInfo 346 | if err := json.Unmarshal(stdout.Bytes(), &info); err != nil { 347 | return nil, fmt.Errorf("failed to parse JSON output: %w", err) 348 | } 349 | 350 | if info.Info[masterName].State == "Deleted" { 351 | logger.Warn("Cluster found but '%s' is in 'Deleted' state, it may not be fully operational.", clusterName) 352 | logger.Warn("Cleaning up.") 353 | err := m.PurgeNodes() 354 | if err != nil { 355 | return nil, fmt.Errorf("failed to purge deleted nodes: %w", err) 356 | } 357 | 358 | return nil, fmt.Errorf("cluster not found: %s", clusterName) 359 | 360 | } 361 | 362 | return &info, nil 363 | } 364 | -------------------------------------------------------------------------------- /internal/plugins/dependency_test.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | // MockDependencyPlugin for testing 9 | type MockDependencyPlugin struct { 10 | name string 11 | dependencies []string 12 | } 13 | 14 | func (m *MockDependencyPlugin) GetName() string { return m.name } 15 | func (m *MockDependencyPlugin) GetDependencies() []string { return m.dependencies } 16 | func (m *MockDependencyPlugin) Install(kubeConfig, clusterName string, ensure ...bool) error { 17 | return nil 18 | } 19 | func (m *MockDependencyPlugin) Uninstall(kubeConfig, clusterName string, ensure ...bool) error { 20 | return nil 21 | } 22 | func (m *MockDependencyPlugin) Status() string { return "mock" } 23 | func (m *MockDependencyPlugin) GetNamespace() string { return "test" } 24 | func (m *MockDependencyPlugin) GetVersion() string { return "1.0.0" } 25 | func (m *MockDependencyPlugin) GetChartName() string { return "test" } 26 | func (m *MockDependencyPlugin) GetRepository() string { return "test" } 27 | func (m *MockDependencyPlugin) GetRepoName() string { return "test" } 28 | func (m *MockDependencyPlugin) GetChartValues() map[string]interface{} { return nil } 29 | func (m *MockDependencyPlugin) GetOptions() PluginOptions { 30 | version := "1.0.0" 31 | namespace := "test" 32 | chartName := "test" 33 | repoName := "test" 34 | repository := "test" 35 | return PluginOptions{ 36 | Version: &version, 37 | Namespace: &namespace, 38 | ChartName: &chartName, 39 | RepoName: &repoName, 40 | Repository: &repository, 41 | ChartValues: nil, 42 | } 43 | } 44 | 45 | func TestDependencyGraph_AddPlugin(t *testing.T) { 46 | graph := NewDependencyGraph() 47 | 48 | plugin := &MockDependencyPlugin{ 49 | name: "test-plugin", 50 | dependencies: []string{"dep1", "dep2"}, 51 | } 52 | 53 | graph.AddPlugin(plugin) 54 | 55 | if len(graph.nodes) != 3 { // test-plugin + dep1 + dep2 nodes 56 | t.Errorf("Expected 3 nodes, got %d", len(graph.nodes)) 57 | } 58 | 59 | node := graph.nodes["test-plugin"] 60 | if node == nil { 61 | t.Fatal("test-plugin node not found") 62 | } 63 | 64 | if !reflect.DeepEqual(node.Dependencies, []string{"dep1", "dep2"}) { 65 | t.Errorf("Dependencies not stored correctly, got %v", node.Dependencies) 66 | } 67 | 68 | // Check that dependency nodes have this plugin as dependent 69 | dep1Node := graph.nodes["dep1"] 70 | if dep1Node == nil { 71 | t.Fatal("dep1 node not found") 72 | } 73 | if len(dep1Node.Dependents) != 1 || dep1Node.Dependents[0] != "test-plugin" { 74 | t.Errorf("Expected dep1 to have test-plugin as dependent, got %v", dep1Node.Dependents) 75 | } 76 | } 77 | 78 | func TestDependencyGraph_GetInstallOrder(t *testing.T) { 79 | graph := NewDependencyGraph() 80 | 81 | // Create plugins with dependencies: A -> B -> C 82 | pluginA := &MockDependencyPlugin{name: "A", dependencies: []string{"B"}} 83 | pluginB := &MockDependencyPlugin{name: "B", dependencies: []string{"C"}} 84 | pluginC := &MockDependencyPlugin{name: "C", dependencies: []string{}} 85 | 86 | graph.AddPlugin(pluginA) 87 | graph.AddPlugin(pluginB) 88 | graph.AddPlugin(pluginC) 89 | 90 | order, err := graph.GetInstallOrder([]string{"A"}) 91 | if err != nil { 92 | t.Fatalf("GetInstallOrder failed: %v", err) 93 | } 94 | 95 | expected := []string{"C", "B", "A"} 96 | if !reflect.DeepEqual(order, expected) { 97 | t.Errorf("Expected install order %v, got %v", expected, order) 98 | } 99 | } 100 | 101 | func TestDependencyGraph_GetUninstallOrder(t *testing.T) { 102 | graph := NewDependencyGraph() 103 | 104 | // Create plugins with dependencies: A -> B -> C 105 | pluginA := &MockDependencyPlugin{name: "A", dependencies: []string{"B"}} 106 | pluginB := &MockDependencyPlugin{name: "B", dependencies: []string{"C"}} 107 | pluginC := &MockDependencyPlugin{name: "C", dependencies: []string{}} 108 | 109 | graph.AddPlugin(pluginA) 110 | graph.AddPlugin(pluginB) 111 | graph.AddPlugin(pluginC) 112 | 113 | order, err := graph.GetUninstallOrder([]string{"C"}) 114 | if err != nil { 115 | t.Fatalf("GetUninstallOrder failed: %v", err) 116 | } 117 | 118 | expected := []string{"A", "B", "C"} 119 | if !reflect.DeepEqual(order, expected) { 120 | t.Errorf("Expected uninstall order %v, got %v", expected, order) 121 | } 122 | } 123 | 124 | func TestDependencyGraph_ValidateInstall(t *testing.T) { 125 | graph := NewDependencyGraph() 126 | 127 | plugin := &MockDependencyPlugin{ 128 | name: "test-plugin", 129 | dependencies: []string{"dep1", "dep2"}, 130 | } 131 | graph.AddPlugin(plugin) 132 | 133 | // Test with missing dependencies 134 | err := graph.ValidateInstall("test-plugin", []string{}) 135 | if err == nil { 136 | t.Error("Expected error for missing dependencies") 137 | } 138 | 139 | // Test with partial dependencies 140 | err = graph.ValidateInstall("test-plugin", []string{"dep1"}) 141 | if err == nil { 142 | t.Error("Expected error for partial dependencies") 143 | } 144 | 145 | // Test with all dependencies 146 | err = graph.ValidateInstall("test-plugin", []string{"dep1", "dep2"}) 147 | if err != nil { 148 | t.Errorf("Unexpected error with all dependencies: %v", err) 149 | } 150 | } 151 | 152 | func TestDependencyGraph_ValidateUninstall(t *testing.T) { 153 | graph := NewDependencyGraph() 154 | 155 | pluginA := &MockDependencyPlugin{name: "A", dependencies: []string{"B"}} 156 | pluginB := &MockDependencyPlugin{name: "B", dependencies: []string{}} 157 | 158 | graph.AddPlugin(pluginA) 159 | graph.AddPlugin(pluginB) 160 | 161 | // Test uninstalling B when A depends on it 162 | err := graph.ValidateUninstall("B", []string{"A", "B"}) 163 | if err == nil { 164 | t.Error("Expected error when trying to uninstall plugin with dependents") 165 | } 166 | 167 | // Test uninstalling A (no dependents) 168 | err = graph.ValidateUninstall("A", []string{"A", "B"}) 169 | if err != nil { 170 | t.Errorf("Unexpected error uninstalling plugin without dependents: %v", err) 171 | } 172 | } 173 | 174 | func TestDependencyGraph_HasCycles(t *testing.T) { 175 | graph := NewDependencyGraph() 176 | 177 | // Create circular dependency: A -> B -> C -> A 178 | pluginA := &MockDependencyPlugin{name: "A", dependencies: []string{"B"}} 179 | pluginB := &MockDependencyPlugin{name: "B", dependencies: []string{"C"}} 180 | pluginC := &MockDependencyPlugin{name: "C", dependencies: []string{"A"}} 181 | 182 | graph.AddPlugin(pluginA) 183 | graph.AddPlugin(pluginB) 184 | graph.AddPlugin(pluginC) 185 | 186 | if !graph.HasCycles() { 187 | t.Error("Expected cycle detection to return true") 188 | } 189 | 190 | // Test without cycles 191 | graph2 := NewDependencyGraph() 192 | pluginD := &MockDependencyPlugin{name: "D", dependencies: []string{"E"}} 193 | pluginE := &MockDependencyPlugin{name: "E", dependencies: []string{}} 194 | 195 | graph2.AddPlugin(pluginD) 196 | graph2.AddPlugin(pluginE) 197 | 198 | if graph2.HasCycles() { 199 | t.Error("Expected cycle detection to return false") 200 | } 201 | } 202 | 203 | func TestDependencyValidator_ValidateInstallation(t *testing.T) { 204 | plugins := []DependencyPlugin{ 205 | &MockDependencyPlugin{name: "A", dependencies: []string{"B"}}, 206 | &MockDependencyPlugin{name: "B", dependencies: []string{"C"}}, 207 | &MockDependencyPlugin{name: "C", dependencies: []string{}}, 208 | } 209 | 210 | validator := NewDependencyValidator(plugins) 211 | 212 | order, err := validator.ValidateInstallation([]string{"A"}, []string{}) 213 | if err != nil { 214 | t.Fatalf("ValidateInstallation failed: %v", err) 215 | } 216 | 217 | expected := []string{"C", "B", "A"} 218 | if !reflect.DeepEqual(order, expected) { 219 | t.Errorf("Expected install order %v, got %v", expected, order) 220 | } 221 | } 222 | 223 | func TestDependencyValidator_ValidateUninstallation(t *testing.T) { 224 | plugins := []DependencyPlugin{ 225 | &MockDependencyPlugin{name: "A", dependencies: []string{"B"}}, 226 | &MockDependencyPlugin{name: "B", dependencies: []string{"C"}}, 227 | &MockDependencyPlugin{name: "C", dependencies: []string{}}, 228 | } 229 | 230 | validator := NewDependencyValidator(plugins) 231 | 232 | order, err := validator.ValidateUninstallation([]string{"C"}, []string{"A", "B", "C"}) 233 | if err != nil { 234 | t.Fatalf("ValidateUninstallation failed: %v", err) 235 | } 236 | 237 | expected := []string{"A", "B", "C"} 238 | if !reflect.DeepEqual(order, expected) { 239 | t.Errorf("Expected uninstall order %v, got %v", expected, order) 240 | } 241 | } 242 | 243 | func TestDependencyValidator_GetDependencyInfo(t *testing.T) { 244 | plugins := []DependencyPlugin{ 245 | &MockDependencyPlugin{name: "A", dependencies: []string{"B"}}, 246 | &MockDependencyPlugin{name: "B", dependencies: []string{"C"}}, 247 | &MockDependencyPlugin{name: "C", dependencies: []string{}}, 248 | } 249 | 250 | validator := NewDependencyValidator(plugins) 251 | 252 | deps, dependents := validator.GetDependencyInfo("B") 253 | 254 | expectedDeps := []string{"C"} 255 | expectedDependents := []string{"A"} 256 | 257 | if !reflect.DeepEqual(deps, expectedDeps) { 258 | t.Errorf("Expected dependencies %v, got %v", expectedDeps, deps) 259 | } 260 | 261 | if !reflect.DeepEqual(dependents, expectedDependents) { 262 | t.Errorf("Expected dependents %v, got %v", expectedDependents, dependents) 263 | } 264 | } 265 | 266 | func TestRealPluginDependencies(t *testing.T) { 267 | // Test real plugin dependencies 268 | argocd, err := NewArgocd("") 269 | if err != nil { 270 | t.Skipf("Skipping ArgoCD test due to initialization error: %v", err) 271 | } 272 | certManager := NewCertManager("") 273 | nginx := NewNginx("") 274 | 275 | // Test that plugins implement DependencyPlugin interface 276 | if argocd != nil { 277 | var _ DependencyPlugin = argocd 278 | } 279 | var _ DependencyPlugin = certManager 280 | var _ DependencyPlugin = nginx 281 | 282 | // Test dependency declarations 283 | if argocd != nil && len(argocd.GetDependencies()) != 0 { 284 | t.Errorf("ArgoCD should have no dependencies, got %v", argocd.GetDependencies()) 285 | } 286 | 287 | if len(certManager.GetDependencies()) != 0 { 288 | t.Errorf("CertManager should have no dependencies, got %v", certManager.GetDependencies()) 289 | } 290 | 291 | nginxDeps := nginx.GetDependencies() 292 | expectedNginxDeps := []string{"load-balancer"} 293 | if !reflect.DeepEqual(nginxDeps, expectedNginxDeps) { 294 | t.Errorf("Nginx dependencies should be %v, got %v", expectedNginxDeps, nginxDeps) 295 | } 296 | } 297 | 298 | func TestComplexDependencyScenario(t *testing.T) { 299 | // Test a complex scenario similar to real plugin dependencies 300 | plugins := []DependencyPlugin{ 301 | &MockDependencyPlugin{name: "argocd", dependencies: []string{}}, 302 | &MockDependencyPlugin{name: "cert-manager", dependencies: []string{}}, 303 | &MockDependencyPlugin{name: "load-balancer", dependencies: []string{}}, 304 | &MockDependencyPlugin{name: "nginx-ingress", dependencies: []string{"load-balancer"}}, 305 | &MockDependencyPlugin{name: "ingress", dependencies: []string{"nginx-ingress", "load-balancer"}}, 306 | &MockDependencyPlugin{name: "tls", dependencies: []string{"cert-manager"}}, 307 | } 308 | 309 | validator := NewDependencyValidator(plugins) 310 | 311 | // Test installing ingress (should install load-balancer and nginx-ingress first) 312 | order, err := validator.ValidateInstallation([]string{"ingress"}, []string{}) 313 | if err != nil { 314 | t.Fatalf("Failed to validate ingress installation: %v", err) 315 | } 316 | 317 | // Check that load-balancer comes before nginx-ingress and ingress 318 | lbIndex := indexOf(order, "load-balancer") 319 | nginxIndex := indexOf(order, "nginx-ingress") 320 | ingressIndex := indexOf(order, "ingress") 321 | 322 | if lbIndex == -1 || nginxIndex == -1 || ingressIndex == -1 { 323 | t.Fatalf("Missing plugins in install order: %v", order) 324 | } 325 | 326 | if lbIndex >= nginxIndex || nginxIndex >= ingressIndex { 327 | t.Errorf("Invalid install order: %v", order) 328 | } 329 | 330 | // Test uninstalling load-balancer when nginx-ingress and ingress depend on it 331 | _, err = validator.ValidateUninstallation([]string{"load-balancer"}, []string{"load-balancer", "nginx-ingress", "ingress"}) 332 | if err != nil { 333 | t.Fatalf("Failed to validate load-balancer uninstallation: %v", err) 334 | } 335 | } 336 | 337 | func indexOf(slice []string, item string) int { 338 | for i, v := range slice { 339 | if v == item { 340 | return i 341 | } 342 | } 343 | return -1 344 | } 345 | 346 | func TestMultiplePluginInstallation(t *testing.T) { 347 | plugins := []DependencyPlugin{ 348 | &MockDependencyPlugin{name: "A", dependencies: []string{}}, 349 | &MockDependencyPlugin{name: "B", dependencies: []string{"A"}}, 350 | &MockDependencyPlugin{name: "C", dependencies: []string{"A"}}, 351 | &MockDependencyPlugin{name: "D", dependencies: []string{"B", "C"}}, 352 | } 353 | 354 | validator := NewDependencyValidator(plugins) 355 | 356 | // Install multiple plugins at once 357 | order, err := validator.ValidateInstallation([]string{"B", "C", "D"}, []string{}) 358 | if err != nil { 359 | t.Fatalf("Failed to validate multiple plugin installation: %v", err) 360 | } 361 | 362 | // A should be first, D should be last 363 | aIndex := indexOf(order, "A") 364 | dIndex := indexOf(order, "D") 365 | 366 | if aIndex != 0 { 367 | t.Errorf("A should be first in install order, got index %d", aIndex) 368 | } 369 | 370 | if dIndex != len(order)-1 { 371 | t.Errorf("D should be last in install order, got index %d", dIndex) 372 | } 373 | } 374 | -------------------------------------------------------------------------------- /internal/plugins/ingress.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "time" 8 | 9 | "github.com/mrgb7/playground/internal/k8s" 10 | "github.com/mrgb7/playground/pkg/logger" 11 | v1 "k8s.io/api/core/v1" 12 | networkingv1 "k8s.io/api/networking/v1" 13 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 14 | "k8s.io/apimachinery/pkg/runtime/schema" 15 | ) 16 | 17 | var ( 18 | IngressNamespace = "ingress-system" 19 | IngressName = "ingress" 20 | IngressVersion = "1.0.0" 21 | TrueValue = "true" 22 | FalseValue = "false" 23 | ) 24 | 25 | const ( 26 | ArgoCDPort = 80 27 | ) 28 | 29 | type Ingress struct { 30 | KubeConfig string 31 | k8sClient *k8s.K8sClient 32 | ClusterName string 33 | *BasePlugin 34 | } 35 | 36 | func NewIngress(kubeConfig, clusterName string) (*Ingress, error) { 37 | c, err := k8s.NewK8sClient(kubeConfig) 38 | if err != nil { 39 | return nil, fmt.Errorf("failed to create k8s client: %w", err) 40 | } 41 | ingress := &Ingress{ 42 | KubeConfig: kubeConfig, 43 | k8sClient: c, 44 | ClusterName: clusterName, 45 | } 46 | ingress.BasePlugin = NewBasePlugin(kubeConfig, ingress) 47 | return ingress, nil 48 | } 49 | 50 | func (i *Ingress) GetName() string { 51 | return IngressName 52 | } 53 | 54 | func (i *Ingress) GetOptions() PluginOptions { 55 | return PluginOptions{ 56 | Version: &IngressVersion, 57 | Namespace: &IngressNamespace, 58 | } 59 | } 60 | 61 | func (i *Ingress) Install(kubeConfig, clusterName string, ensure ...bool) error { 62 | logger.Infoln("Installing ingress plugin for cluster: %s", clusterName) 63 | 64 | if err := i.ensureNginxLoadBalancer(); err != nil { 65 | return fmt.Errorf("failed to ensure nginx LoadBalancer: %w", err) 66 | } 67 | 68 | i.setupClusterDomain() 69 | 70 | if err := i.configureArgoCDIngress(); err != nil { 71 | return fmt.Errorf("failed to configure ArgoCD ingress: %w", err) 72 | } 73 | 74 | if err := i.printHostInstructions(); err != nil { 75 | return fmt.Errorf("failed to print host instructions: %w", err) 76 | } 77 | 78 | logger.Successln("Ingress plugin installed successfully") 79 | return nil 80 | } 81 | 82 | func (i *Ingress) Uninstall(kubeConfig, clusterName string, ensure ...bool) error { 83 | logger.Infoln("Uninstalling ingress plugin") 84 | 85 | err := i.removeArgoCDIngress() 86 | if err != nil { 87 | logger.Warnln("Failed to remove ArgoCD ingress: %v", err) 88 | } 89 | 90 | logger.Successln("Ingress plugin uninstalled successfully") 91 | return nil 92 | } 93 | 94 | func (i *Ingress) Status() string { 95 | nginx := NewNginx(i.KubeConfig) 96 | lb, _ := NewLoadBalancer(i.KubeConfig, "", i.ClusterName) 97 | 98 | nginxStatus := nginx.Status() 99 | lbStatus := lb.Status() 100 | 101 | if !strings.Contains(nginxStatus, StatusRunning) || !strings.Contains(lbStatus, StatusRunning) { 102 | return "Ingress dependencies not satisfied" 103 | } 104 | 105 | return "Ingress is configured" 106 | } 107 | 108 | func (i *Ingress) ensureNginxLoadBalancer() error { 109 | logger.Infoln("Ensuring nginx service is LoadBalancer type...") 110 | 111 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 112 | defer cancel() 113 | 114 | svc, err := i.k8sClient.Clientset.CoreV1().Services(NginxNamespace).Get( 115 | ctx, "nginx-ingress-ingress-nginx-controller", metav1.GetOptions{}) 116 | if err != nil { 117 | return fmt.Errorf("failed to get nginx service: %w", err) 118 | } 119 | 120 | if svc.Spec.Type == v1.ServiceTypeLoadBalancer { 121 | logger.Debugln("Nginx service is already LoadBalancer type") 122 | return nil 123 | } 124 | 125 | svc.Spec.Type = v1.ServiceTypeLoadBalancer 126 | _, err = i.k8sClient.Clientset. 127 | CoreV1(). 128 | Services(NginxNamespace). 129 | Update(ctx, svc, metav1.UpdateOptions{}) 130 | if err != nil { 131 | return fmt.Errorf("failed to update nginx service to LoadBalancer: %w", err) 132 | } 133 | 134 | logger.Successln("Updated nginx service to LoadBalancer type") 135 | return nil 136 | } 137 | 138 | func (i *Ingress) setupClusterDomain() { 139 | logger.Infoln("Setting up cluster domain: %s.local", i.ClusterName) 140 | } 141 | 142 | func (i *Ingress) configureArgoCDIngress() error { 143 | logger.Infoln("Checking for ArgoCD installation...") 144 | 145 | argocd, err := NewArgocd(i.KubeConfig) 146 | if err != nil { 147 | return fmt.Errorf("failed to get ArgoCD: %w", err) 148 | } 149 | argoCDStatus := argocd.Status() 150 | if !strings.Contains(argoCDStatus, StatusRunning) { 151 | logger.Infoln("ArgoCD not installed, skipping ingress configuration") 152 | return nil 153 | } 154 | 155 | logger.Infoln("ArgoCD found, configuring ingress...") 156 | 157 | isTLSAvailable := i.isTLSClusterIssuerAvailable() 158 | if isTLSAvailable { 159 | logger.Infoln("TLS cluster issuer found, enabling HTTPS for ArgoCD") 160 | } 161 | 162 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 163 | defer cancel() 164 | 165 | existingIngress, err := i.k8sClient.Clientset.NetworkingV1().Ingresses("argocd").Get( 166 | ctx, "argocd-server", metav1.GetOptions{}) 167 | if err != nil && !strings.Contains(err.Error(), "not found") { 168 | return fmt.Errorf("failed to check existing ArgoCD ingress: %w", err) 169 | } 170 | 171 | hostname := fmt.Sprintf("argocd.%s.local", i.ClusterName) 172 | 173 | if err == nil { 174 | return i.updateExistingArgoCDIngress(existingIngress, hostname, isTLSAvailable) 175 | } 176 | return i.createNewArgoCDIngress(hostname, isTLSAvailable) 177 | } 178 | 179 | func (i *Ingress) removeArgoCDIngress() error { 180 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 181 | defer cancel() 182 | 183 | err := i.k8sClient. 184 | Clientset. 185 | NetworkingV1(). 186 | Ingresses("argocd"). 187 | Delete(ctx, "argocd-server", metav1.DeleteOptions{}) 188 | if err != nil && !strings.Contains(err.Error(), "not found") { 189 | return fmt.Errorf("failed to delete ArgoCD ingress: %w", err) 190 | } 191 | 192 | return nil 193 | } 194 | 195 | func (i *Ingress) printHostInstructions() error { 196 | logger.Infoln("Getting nginx LoadBalancer IP...") 197 | 198 | ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) 199 | defer cancel() 200 | 201 | var nginxIP string 202 | for retries := 0; retries < 12; retries++ { 203 | svc, err := i.k8sClient. 204 | Clientset. 205 | CoreV1(). 206 | Services(NginxNamespace).Get(ctx, "nginx-ingress-ingress-nginx-controller", metav1.GetOptions{}) 207 | if err != nil { 208 | return fmt.Errorf("failed to get nginx service: %w", err) 209 | } 210 | 211 | if len(svc.Status.LoadBalancer.Ingress) > 0 { 212 | if svc.Status.LoadBalancer.Ingress[0].IP != "" { 213 | nginxIP = svc.Status.LoadBalancer.Ingress[0].IP 214 | break 215 | } 216 | } 217 | 218 | logger.Infoln("Waiting for LoadBalancer IP assignment... (%d/12)", retries+1) 219 | time.Sleep(5 * time.Second) 220 | } 221 | 222 | if nginxIP == "" { 223 | logger.Warnln("LoadBalancer IP not available yet. You can run this command later to get it:") 224 | logger.Infoln("kubectl get svc -n %s nginx-ingress-ingress-nginx-controller "+ 225 | "-o jsonpath='{.status.loadBalancer.ingress[0].ip}'", NginxNamespace) 226 | return nil 227 | } 228 | 229 | logger.Successln("LoadBalancer IP found: %s", nginxIP) 230 | logger.Infoln("") 231 | logger.Infoln("🎯 Add these entries to your /etc/hosts file:") 232 | logger.Infoln("echo '%s %s.local' | sudo tee -a /etc/hosts", nginxIP, i.ClusterName) 233 | 234 | argocd, err := NewArgocd(i.KubeConfig) 235 | if err != nil { 236 | return fmt.Errorf("failed to get ArgoCD plugin: %w", err) 237 | } 238 | argoCDStatus := argocd.Status() 239 | if strings.Contains(argoCDStatus, StatusRunning) { 240 | logger.Infoln("echo '%s argocd.%s.local' | sudo tee -a /etc/hosts", nginxIP, i.ClusterName) 241 | logger.Infoln("") 242 | 243 | isTLSAvailable := i.isTLSClusterIssuerAvailable() 244 | if isTLSAvailable { 245 | logger.Infoln("🚀 ArgoCD will be available at: https://argocd.%s.local", i.ClusterName) 246 | logger.Infoln("🔒 TLS certificates will be automatically generated") 247 | } else { 248 | logger.Infoln("🚀 ArgoCD will be available at: http://argocd.%s.local", i.ClusterName) 249 | logger.Infoln("💡 Install TLS plugin for HTTPS support:") 250 | logger.Infoln(" playground cluster plugin add --name tls --cluster %s", i.ClusterName) 251 | } 252 | } 253 | 254 | logger.Infoln("") 255 | logger.Infoln("🌐 Cluster domain: %s.local", i.ClusterName) 256 | 257 | return nil 258 | } 259 | 260 | func (i *Ingress) isTLSClusterIssuerAvailable() bool { 261 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 262 | defer cancel() 263 | 264 | gvr := schema.GroupVersionResource{ 265 | Group: "cert-manager.io", 266 | Version: "v1", 267 | Resource: "clusterissuers", 268 | } 269 | 270 | tls := &TLS{} 271 | issuerName := tls.GetClusterIssuerName() 272 | _, err := i.k8sClient. 273 | Dynamic. 274 | Resource(gvr). 275 | Get(ctx, issuerName, metav1.GetOptions{}) 276 | return err == nil 277 | } 278 | 279 | func (i *Ingress) updateExistingArgoCDIngress( 280 | existingIngress *networkingv1.Ingress, 281 | hostname string, 282 | isTLSAvailable bool, 283 | ) error { 284 | logger.Infoln("Updating existing ArgoCD ingress with cluster domain and TLS...") 285 | 286 | if len(existingIngress.Spec.Rules) > 0 { 287 | existingIngress.Spec.Rules[0].Host = hostname 288 | } 289 | 290 | if isTLSAvailable { 291 | if existingIngress.Annotations == nil { 292 | existingIngress.Annotations = make(map[string]string) 293 | } 294 | tls := &TLS{} 295 | existingIngress.Annotations["cert-manager.io/cluster-issuer"] = tls.GetClusterIssuerName() 296 | existingIngress.Annotations["nginx.ingress.kubernetes.io/ssl-redirect"] = TrueValue 297 | existingIngress.Annotations["nginx.ingress.kubernetes.io/force-ssl-redirect"] = TrueValue 298 | 299 | existingIngress.Spec.TLS = []networkingv1.IngressTLS{ 300 | { 301 | Hosts: []string{hostname}, 302 | SecretName: "argocd-server-tls", 303 | }, 304 | } 305 | } else if existingIngress.Annotations != nil { 306 | existingIngress.Annotations["nginx.ingress.kubernetes.io/ssl-redirect"] = FalseValue 307 | existingIngress.Annotations["nginx.ingress.kubernetes.io/force-ssl-redirect"] = FalseValue 308 | } 309 | 310 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 311 | defer cancel() 312 | 313 | _, err := i.k8sClient.Clientset. 314 | NetworkingV1(). 315 | Ingresses("argocd"). 316 | Update(ctx, existingIngress, metav1.UpdateOptions{}) 317 | if err != nil { 318 | return fmt.Errorf("failed to update existing ArgoCD ingress: %w", err) 319 | } 320 | 321 | if isTLSAvailable { 322 | logger.Successln("Updated existing ArgoCD ingress with HTTPS: https://argocd.%s.local", i.ClusterName) 323 | } else { 324 | logger.Successln("Updated existing ArgoCD ingress with host: argocd.%s.local", i.ClusterName) 325 | } 326 | return nil 327 | } 328 | 329 | func (i *Ingress) createNewArgoCDIngress(hostname string, isTLSAvailable bool) error { 330 | logger.Infoln("Creating new ArgoCD ingress...") 331 | 332 | annotations := map[string]string{ 333 | "nginx.ingress.kubernetes.io/backend-protocol": "HTTP", 334 | } 335 | 336 | var tlsConfig []networkingv1.IngressTLS 337 | 338 | if isTLSAvailable { 339 | tls := &TLS{} 340 | annotations["cert-manager.io/cluster-issuer"] = tls.GetClusterIssuerName() 341 | annotations["nginx.ingress.kubernetes.io/ssl-redirect"] = TrueValue 342 | annotations["nginx.ingress.kubernetes.io/force-ssl-redirect"] = TrueValue 343 | tlsConfig = []networkingv1.IngressTLS{ 344 | { 345 | Hosts: []string{hostname}, 346 | SecretName: "argocd-server-tls", 347 | }, 348 | } 349 | } else { 350 | annotations["nginx.ingress.kubernetes.io/ssl-redirect"] = FalseValue 351 | annotations["nginx.ingress.kubernetes.io/force-ssl-redirect"] = FalseValue 352 | } 353 | 354 | ingress := &networkingv1.Ingress{ 355 | ObjectMeta: metav1.ObjectMeta{ 356 | Name: "argocd-server", 357 | Namespace: "argocd", 358 | Annotations: annotations, 359 | }, 360 | Spec: networkingv1.IngressSpec{ 361 | IngressClassName: func() *string { s := "nginx"; return &s }(), 362 | TLS: tlsConfig, 363 | Rules: []networkingv1.IngressRule{ 364 | { 365 | Host: hostname, 366 | IngressRuleValue: networkingv1.IngressRuleValue{ 367 | HTTP: &networkingv1.HTTPIngressRuleValue{ 368 | Paths: []networkingv1.HTTPIngressPath{ 369 | { 370 | Path: "/", 371 | PathType: func() *networkingv1.PathType { pt := networkingv1.PathTypePrefix; return &pt }(), 372 | Backend: networkingv1.IngressBackend{ 373 | Service: &networkingv1.IngressServiceBackend{ 374 | Name: "argocd-server", 375 | Port: networkingv1.ServiceBackendPort{ 376 | Number: ArgoCDPort, 377 | }, 378 | }, 379 | }, 380 | }, 381 | }, 382 | }, 383 | }, 384 | }, 385 | }, 386 | }, 387 | } 388 | 389 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 390 | defer cancel() 391 | 392 | _, err := i.k8sClient.Clientset.NetworkingV1().Ingresses("argocd").Create(ctx, ingress, metav1.CreateOptions{}) 393 | if err != nil { 394 | return fmt.Errorf("failed to create ArgoCD ingress: %w", err) 395 | } 396 | 397 | if isTLSAvailable { 398 | logger.Successln("Created ArgoCD ingress with HTTPS: https://argocd.%s.local", i.ClusterName) 399 | } else { 400 | logger.Successln("Created ArgoCD ingress with host: argocd.%s.local", i.ClusterName) 401 | } 402 | return nil 403 | } 404 | 405 | func (i *Ingress) GetDependencies() []string { 406 | return []string{"tls", "nginx-ingress", "load-balancer"} // ingress depends on nginx-ingress and load-balancer 407 | } 408 | --------------------------------------------------------------------------------