diff --git a/cmd/cluster.go b/cmd/cluster.go new file mode 100644 index 0000000..99bacfd --- /dev/null +++ b/cmd/cluster.go @@ -0,0 +1,237 @@ +// Copyright (c) 2024 Parseable, Inc +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package cmd + +import ( + "context" + "fmt" + "log" + "os" + "pb/pkg/common" + "pb/pkg/helm" + "pb/pkg/installer" + + "github.com/olekukonko/tablewriter" + "github.com/spf13/cobra" + "gopkg.in/yaml.v2" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +var verbose bool + +var InstallOssCmd = &cobra.Command{ + Use: "install", + Short: "Deploy Parseable", + Example: "pb cluster install", + Run: func(cmd *cobra.Command, _ []string) { + // Add verbose flag + cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose logging") + installer.Installer(verbose) + }, +} + +// ListOssCmd lists the Parseable OSS servers +var ListOssCmd = &cobra.Command{ + Use: "list", + Short: "List available Parseable servers", + Example: "pb list", + Run: func(_ *cobra.Command, _ []string) { + _, err := common.PromptK8sContext() + if err != nil { + log.Fatalf("Failed to prompt for kubernetes context: %v", err) + } + + // Read the installer data from the ConfigMap + entries, err := common.ReadInstallerConfigMap() + if err != nil { + log.Fatalf("Failed to list servers: %v", err) + } + + // Check if there are no entries + if len(entries) == 0 { + fmt.Println("No clusters found.") + return + } + + // Display the entries in a table format + table := tablewriter.NewWriter(os.Stdout) + table.SetHeader([]string{"Name", "Namespace", "Version", "Status"}) + + for _, entry := range entries { + table.Append([]string{entry.Name, entry.Namespace, entry.Version, entry.Status}) + } + + table.Render() + }, +} + +// ShowValuesCmd lists the Parseable OSS servers +var ShowValuesCmd = &cobra.Command{ + Use: "show values", + Short: "Show values available in Parseable servers", + Example: "pb show values", + Run: func(_ *cobra.Command, _ []string) { + _, err := common.PromptK8sContext() + if err != nil { + log.Fatalf("Failed to prompt for Kubernetes context: %v", err) + } + + // Read the installer data from the ConfigMap + entries, err := common.ReadInstallerConfigMap() + if err != nil { + log.Fatalf("Failed to list OSS servers: %v", err) + } + + // Check if there are no entries + if len(entries) == 0 { + fmt.Println("No OSS servers found.") + return + } + + // Prompt user to select a cluster + selectedCluster, err := common.PromptClusterSelection(entries) + if err != nil { + log.Fatalf("Failed to select a cluster: %v", err) + } + + values, err := helm.GetReleaseValues(selectedCluster.Name, selectedCluster.Namespace) + if err != nil { + log.Fatalf("Failed to get values for release: %v", err) + } + + // Marshal values to YAML for nice formatting + yamlOutput, err := yaml.Marshal(values) + if err != nil { + log.Fatalf("Failed to marshal values to YAML: %v", err) + } + + // Print the YAML output + fmt.Println(string(yamlOutput)) + + // Print instructions for fetching secret values + fmt.Printf("\nTo get secret values of the Parseable cluster, run the following command:\n") + fmt.Printf("kubectl get secret -n %s parseable-env-secret -o jsonpath='{.data}' | jq -r 'to_entries[] | \"\\(.key): \\(.value | @base64d)\"'\n", selectedCluster.Namespace) + }, +} + +// UninstallOssCmd removes Parseable OSS servers +var UninstallOssCmd = &cobra.Command{ + Use: "uninstall", + Short: "Uninstall Parseable servers", + Example: "pb uninstall", + Run: func(_ *cobra.Command, _ []string) { + _, err := common.PromptK8sContext() + if err != nil { + log.Fatalf("Failed to prompt for Kubernetes context: %v", err) + } + + // Read the installer data from the ConfigMap + entries, err := common.ReadInstallerConfigMap() + if err != nil { + log.Fatalf("Failed to fetch OSS servers: %v", err) + } + + // Check if there are no entries + if len(entries) == 0 { + fmt.Println(common.Yellow + "\nNo Parseable OSS servers found to uninstall.") + return + } + + // Prompt user to select a cluster + selectedCluster, err := common.PromptClusterSelection(entries) + if err != nil { + log.Fatalf("Failed to select a cluster: %v", err) + } + + // Display a warning banner + fmt.Println("\n────────────────────────────────────────────────────────────────────────────") + fmt.Println("⚠️ Deleting this cluster will not delete any data on object storage.") + fmt.Println(" This operation will clean up the Parseable deployment on Kubernetes.") + fmt.Println("────────────────────────────────────────────────────────────────────────────") + + // Confirm uninstallation + fmt.Printf("\nYou have selected to uninstall the cluster '%s' in namespace '%s'.\n", selectedCluster.Name, selectedCluster.Namespace) + if !common.PromptConfirmation(fmt.Sprintf("Do you want to proceed with uninstalling '%s'?", selectedCluster.Name)) { + fmt.Println(common.Yellow + "Uninstall operation canceled.") + return + } + + //Perform uninstallation + if err := uninstallCluster(selectedCluster); err != nil { + log.Fatalf("Failed to uninstall cluster: %v", err) + } + + // Remove entry from ConfigMap + if err := common.RemoveInstallerEntry(selectedCluster.Name); err != nil { + log.Fatalf("Failed to remove entry from ConfigMap: %v", err) + } + + // Delete secret + if err := deleteSecret(selectedCluster.Namespace, "parseable-env-secret"); err != nil { + log.Printf("Warning: Failed to delete secret 'parseable-env-secret': %v", err) + } else { + fmt.Println(common.Green + "Secret 'parseable-env-secret' deleted successfully." + common.Reset) + } + + fmt.Println(common.Green + "Uninstallation completed successfully." + common.Reset) + }, +} + +func uninstallCluster(entry common.InstallerEntry) error { + helmApp := helm.Helm{ + ReleaseName: entry.Name, + Namespace: entry.Namespace, + RepoName: "parseable", + RepoURL: "https://charts.parseable.com", + ChartName: "parseable", + Version: entry.Version, + } + + fmt.Println(common.Yellow + "Starting uninstallation process..." + common.Reset) + + spinner := common.CreateDeploymentSpinner(fmt.Sprintf("Uninstalling Parseable OSS '%s'...", entry.Name)) + spinner.Start() + + _, err := helm.Uninstall(helmApp, false) + spinner.Stop() + + if err != nil { + return fmt.Errorf("failed to uninstall Parseable OSS: %v", err) + } + + fmt.Printf(common.Green+"Successfully uninstalled '%s' from namespace '%s'.\n"+common.Reset, entry.Name, entry.Namespace) + return nil +} + +func deleteSecret(namespace, secretName string) error { + config, err := common.LoadKubeConfig() + if err != nil { + return fmt.Errorf("failed to create Kubernetes client: %v", err) + } + + clientset, err := kubernetes.NewForConfig(config) + if err != nil { + return fmt.Errorf("failed to create Kubernetes client: %w", err) + } + + err = clientset.CoreV1().Secrets(namespace).Delete(context.TODO(), "parseable-env-secret", metav1.DeleteOptions{}) + if err != nil { + return fmt.Errorf("failed to delete secret '%s': %v", secretName, err) + } + + return nil +} diff --git a/cmd/installer.go b/cmd/installer.go deleted file mode 100644 index a8c9116..0000000 --- a/cmd/installer.go +++ /dev/null @@ -1,270 +0,0 @@ -// Copyright (c) 2024 Parseable, Inc -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package cmd - -import ( - "encoding/base64" - "encoding/json" - "fmt" - "net" - "os" - "os/exec" - "runtime" - "strings" - "sync" - "time" - - "pb/pkg/common" - "pb/pkg/helm" - "pb/pkg/installer" - - "github.com/briandowns/spinner" - "github.com/spf13/cobra" -) - -var verbose bool - -var InstallOssCmd = &cobra.Command{ - Use: "oss", - Short: "Deploy Parseable OSS", - Example: "pb install oss", - RunE: func(cmd *cobra.Command, _ []string) error { - // Add verbose flag - cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose logging") - - // Print the banner - printBanner() - - // Prompt user to select a deployment plan - selectedPlan, err := installer.PromptUserPlanSelection() - if err != nil { - return err - } - - fmt.Printf( - common.Cyan+" Ingestion Speed: %s\n"+ - common.Cyan+" Per Day Ingestion: %s\n"+ - common.Cyan+" Query Performance: %s\n"+ - common.Cyan+" CPU & Memory: %s\n"+ - common.Reset, selectedPlan.IngestionSpeed, selectedPlan.PerDayIngestion, - selectedPlan.QueryPerformance, selectedPlan.CPUAndMemorySpecs) - - // Get namespace and chart values from installer - valuesHolder, chartValues := installer.Installer(selectedPlan) - - // Helm application configuration - apps := []helm.Helm{ - { - ReleaseName: "parseable", - Namespace: valuesHolder.ParseableSecret.Namespace, - RepoName: "parseable", - RepoURL: "https://charts.parseable.com", - ChartName: "parseable", - Version: "1.6.5", - Values: chartValues, - }, - } - - // Create a spinner - spinner := createDeploymentSpinner(valuesHolder.ParseableSecret.Namespace) - - // Redirect standard output if not in verbose mode - var oldStdout *os.File - if !verbose { - oldStdout = os.Stdout - _, w, _ := os.Pipe() - os.Stdout = w - } - - spinner.Start() - - // Deploy using Helm - var wg sync.WaitGroup - errCh := make(chan error, len(apps)) - for _, app := range apps { - wg.Add(1) - go func(app helm.Helm) { - defer wg.Done() - if err := helm.Apply(app, verbose); err != nil { - errCh <- err - return - } - }(app) - } - - wg.Wait() - close(errCh) - - // Stop the spinner and restore stdout - spinner.Stop() - if !verbose { - os.Stdout = oldStdout - } - - // Check for errors - for err := range errCh { - if err != nil { - return err - } - } - - // Print success banner - printSuccessBanner(valuesHolder.ParseableSecret.Namespace, string(valuesHolder.DeploymentType), apps[0].Version, valuesHolder.ParseableSecret.Username, valuesHolder.ParseableSecret.Password) - - return nil - }, -} - -// printSuccessBanner remains the same as in the original code -func printSuccessBanner(namespace, deployment, version, username, password string) { - var ingestionURL, serviceName string - if deployment == "standalone" { - ingestionURL = "parseable." + namespace + ".svc.cluster.local" - serviceName = "parseable" - } else if deployment == "distributed" { - ingestionURL = "parseable-ingestor-svc." + namespace + ".svc.cluster.local" - serviceName = "parseable-query-svc" - } - - // Encode credentials to Base64 - credentials := map[string]string{ - "username": username, - "password": password, - } - credentialsJSON, err := json.Marshal(credentials) - if err != nil { - fmt.Printf("failed to marshal credentials: %v\n", err) - return - } - - base64EncodedString := base64.StdEncoding.EncodeToString(credentialsJSON) - - fmt.Println("\n" + common.Green + "🎉 Parseable Deployment Successful! 🎉" + common.Reset) - fmt.Println(strings.Repeat("=", 50)) - - fmt.Printf("%s Deployment Details:\n", common.Blue+"ℹ️ ") - fmt.Printf(" • Namespace: %s\n", common.Blue+namespace) - fmt.Printf(" • Chart Version: %s\n", common.Blue+version) - fmt.Printf(" • Ingestion URL: %s\n", ingestionURL) - - fmt.Println("\n" + common.Blue + "🔗 Resources:" + common.Reset) - fmt.Println(common.Blue + " • Documentation: https://www.parseable.com/docs/server/introduction") - fmt.Println(common.Blue + " • Stream Management: https://www.parseable.com/docs/server/api") - - fmt.Println("\n" + common.Blue + "Happy Logging!" + common.Reset) - - // Port-forward the service - localPort := "8000" - fmt.Printf(common.Green+"Port-forwarding %s service on port %s...\n"+common.Reset, serviceName, localPort) - - if err = startPortForward(namespace, serviceName, "80", localPort); err != nil { - fmt.Printf(common.Red+"failed to port-forward service: %s", err.Error()) - } - - // Redirect to UI - localURL := fmt.Sprintf("http://localhost:%s/login?q=%s", localPort, base64EncodedString) - fmt.Printf(common.Green+"Opening Parseable UI at %s\n"+common.Reset, localURL) - openBrowser(localURL) -} - -func createDeploymentSpinner(namespace string) *spinner.Spinner { - // Custom spinner with multiple character sets for dynamic effect - spinnerChars := []string{ - "●", "○", "◉", "○", "◉", "○", "◉", "○", "◉", - } - - s := spinner.New( - spinnerChars, - 120*time.Millisecond, - spinner.WithColor(common.Yellow), - spinner.WithSuffix(" ..."), - ) - - s.Prefix = fmt.Sprintf(common.Yellow+"Deploying to %s ", namespace) - - return s -} - -// printBanner displays a welcome banner -func printBanner() { - banner := ` - -------------------------------------- - Welcome to Parseable OSS Installation - -------------------------------------- -` - fmt.Println(common.Green + banner + common.Reset) -} - -func startPortForward(namespace, serviceName, remotePort, localPort string) error { - // Build the port-forward command - cmd := exec.Command("kubectl", "port-forward", - fmt.Sprintf("svc/%s", serviceName), - fmt.Sprintf("%s:%s", localPort, remotePort), - "-n", namespace, - ) - - // Redirect the command's output to the standard output for debugging - if !verbose { - cmd.Stdout = nil // Suppress standard output - cmd.Stderr = nil // Suppress standard error - } else { - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - } - - // Run the command in the background - if err := cmd.Start(); err != nil { - return fmt.Errorf("failed to start port-forward: %w", err) - } - - // Run in a goroutine to keep it alive - go func() { - _ = cmd.Wait() - }() - - // Check connection on the forwarded port - retries := 10 - for i := 0; i < retries; i++ { - conn, err := net.Dial("tcp", fmt.Sprintf("localhost:%s", localPort)) - if err == nil { - conn.Close() // Connection successful, break out of the loop - fmt.Println(common.Green + "Port-forwarding successfully established!") - time.Sleep(5 * time.Second) // some delay - return nil - } - time.Sleep(3 * time.Second) // Wait before retrying - } - - // If we reach here, port-forwarding failed - cmd.Process.Kill() // Stop the kubectl process - return fmt.Errorf(common.Red+"failed to establish port-forward connection to localhost:%s", localPort) -} - -func openBrowser(url string) { - var cmd *exec.Cmd - switch os := runtime.GOOS; os { - case "windows": - cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) - case "darwin": - cmd = exec.Command("open", url) - case "linux": - cmd = exec.Command("xdg-open", url) - default: - fmt.Printf("Please open the following URL manually: %s\n", url) - return - } - cmd.Start() -} diff --git a/cmd/uninstaller.go b/cmd/uninstaller.go deleted file mode 100644 index 66b51e6..0000000 --- a/cmd/uninstaller.go +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright (c) 2024 Parseable, Inc -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package cmd - -import ( - "fmt" - - "pb/pkg/common" - "pb/pkg/installer" - - "github.com/spf13/cobra" -) - -var UnInstallOssCmd = &cobra.Command{ - Use: "oss", - Short: "Uninstall Parseable OSS", - Example: "pb uninstall oss", - RunE: func(cmd *cobra.Command, _ []string) error { - // Add verbose flag - cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose logging") - - // Print the banner - printBanner() - - if err := installer.Uninstaller(verbose); err != nil { - fmt.Println(common.Red + err.Error()) - } - - return nil - }, -} diff --git a/go.mod b/go.mod index 53e13b4..dd0e3c8 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/gofrs/flock v0.12.1 github.com/manifoldco/promptui v0.9.0 github.com/oklog/ulid/v2 v2.1.0 + github.com/olekukonko/tablewriter v0.0.5 github.com/pkg/errors v0.9.1 github.com/spf13/pflag v1.0.5 golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 diff --git a/go.sum b/go.sum index 41a3813..1d77164 100644 --- a/go.sum +++ b/go.sum @@ -294,6 +294,7 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= @@ -342,6 +343,8 @@ github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU= github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= diff --git a/main.go b/main.go index 02e8f97..43d3245 100644 --- a/main.go +++ b/main.go @@ -186,10 +186,44 @@ var query = &cobra.Command{ }, } -var install = &cobra.Command{ - Use: "install", - Short: "Install parseable on kubernetes cluster", - Long: "\ninstall command is used to install parseable oss/enterprise on k8s cluster..", +var cluster = &cobra.Command{ + Use: "cluster", + Short: "Cluster operations for parseable.", + Long: "\nCluster operations for parseable cluster on kubernetes.", + PersistentPreRunE: combinedPreRun, + PersistentPostRun: func(cmd *cobra.Command, args []string) { + if os.Getenv("PB_ANALYTICS") == "disable" { + return + } + wg.Add(1) + go func() { + defer wg.Done() + analytics.PostRunAnalytics(cmd, "install", args) + }() + }, +} + +var list = &cobra.Command{ + Use: "list", + Short: "List parseable on kubernetes cluster", + Long: "\nlist command is used to list parseable oss installations.", + PersistentPreRunE: combinedPreRun, + PersistentPostRun: func(cmd *cobra.Command, args []string) { + if os.Getenv("PB_ANALYTICS") == "disable" { + return + } + wg.Add(1) + go func() { + defer wg.Done() + analytics.PostRunAnalytics(cmd, "install", args) + }() + }, +} + +var show = &cobra.Command{ + Use: "show", + Short: "Show outputs values defined when installing parseable on kubernetes cluster", + Long: "\nshow command is used to get values in parseable.", PersistentPreRunE: combinedPreRun, PersistentPostRun: func(cmd *cobra.Command, args []string) { if os.Getenv("PB_ANALYTICS") == "disable" { @@ -246,9 +280,16 @@ func main() { schema.AddCommand(pb.GenerateSchemaCmd) schema.AddCommand(pb.CreateSchemaCmd) - install.AddCommand(pb.InstallOssCmd) + cluster.AddCommand(pb.InstallOssCmd) + cluster.AddCommand(pb.ListOssCmd) + cluster.AddCommand(pb.ShowValuesCmd) + cluster.AddCommand(pb.UninstallOssCmd) + + list.AddCommand(pb.ListOssCmd) + + uninstall.AddCommand(pb.UninstallOssCmd) - uninstall.AddCommand(pb.UnInstallOssCmd) + show.AddCommand(pb.ShowValuesCmd) cli.AddCommand(profile) cli.AddCommand(query) @@ -256,11 +297,9 @@ func main() { cli.AddCommand(user) cli.AddCommand(role) cli.AddCommand(pb.TailCmd) + cli.AddCommand(cluster) cli.AddCommand(pb.AutocompleteCmd) - cli.AddCommand(install) - cli.AddCommand(uninstall) - cli.AddCommand(schema) // Set as command pb.VersionCmd.Run = func(_ *cobra.Command, _ []string) { diff --git a/pkg/common/common.go b/pkg/common/common.go index 56271d2..f67f01c 100644 --- a/pkg/common/common.go +++ b/pkg/common/common.go @@ -15,6 +15,28 @@ package common +import ( + "context" + "fmt" + "os" + "time" + + "github.com/briandowns/spinner" + "github.com/manifoldco/promptui" + "gopkg.in/yaml.v2" + apiErrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +const ( + configMapName = "parseable-installer" + namespace = "pb-system" + dataKey = "installer-data" +) + // ANSI escape codes for colors const ( Yellow = "\033[33m" @@ -24,3 +46,235 @@ const ( Blue = "\033[34m" Cyan = "\033[36m" ) + +// InstallerEntry represents an entry in the installer.yaml file +type InstallerEntry struct { + Name string `yaml:"name"` + Namespace string `yaml:"namespace"` + Version string `yaml:"version"` + Status string `yaml:"status"` // todo ideally should be a heartbeat +} + +// ReadInstallerConfigMap fetches and parses installer data from a ConfigMap +func ReadInstallerConfigMap() ([]InstallerEntry, error) { + + // Load kubeconfig and create a Kubernetes client + config, err := LoadKubeConfig() + if err != nil { + return nil, fmt.Errorf("failed to load kubeconfig: %w", err) + } + + clientset, err := kubernetes.NewForConfig(config) + if err != nil { + return nil, fmt.Errorf("failed to create Kubernetes client: %w", err) + } + + // Get the ConfigMap + cm, err := clientset.CoreV1().ConfigMaps(namespace).Get(context.TODO(), configMapName, metav1.GetOptions{}) + if err != nil { + if apiErrors.IsNotFound(err) { + fmt.Println(Yellow + "\nNo existing Parseable OSS clusters found.\n" + Reset) + return nil, nil + } + return nil, fmt.Errorf("failed to fetch ConfigMap: %w", err) + } + // Retrieve and parse the installer data + rawData, ok := cm.Data[dataKey] + if !ok { + fmt.Println(Yellow + "\n────────────────────────────────────────────────────────────────────────────") + fmt.Println(Yellow + "⚠️ No Parseable clusters found!") + fmt.Println(Yellow + "To get started, run: `pb install oss`") + fmt.Println(Yellow + "────────────────────────────────────────────────────────────────────────────") + return nil, nil + } + + var entries []InstallerEntry + if err := yaml.Unmarshal([]byte(rawData), &entries); err != nil { + return nil, fmt.Errorf("failed to parse ConfigMap data: %w", err) + } + + return entries, nil +} + +// LoadKubeConfig loads the kubeconfig from the default location +func LoadKubeConfig() (*rest.Config, error) { + kubeconfig := clientcmd.NewDefaultClientConfigLoadingRules().GetDefaultFilename() + return clientcmd.BuildConfigFromFlags("", kubeconfig) +} + +// PromptK8sContext retrieves Kubernetes contexts from kubeconfig. +func PromptK8sContext() (clusterName string, err error) { + kubeconfigPath := os.Getenv("KUBECONFIG") + if kubeconfigPath == "" { + kubeconfigPath = os.Getenv("HOME") + "/.kube/config" + } + + // Load kubeconfig file + config, err := clientcmd.LoadFromFile(kubeconfigPath) + if err != nil { + fmt.Printf("\033[31mError loading kubeconfig: %v\033[0m\n", err) + os.Exit(1) + } + + // Check if P_KUBE_CONTEXT is set + envContext := os.Getenv("P_KUBE_CONTEXT") + if envContext != "" { + // Validate if the context exists in kubeconfig + if _, exists := config.Contexts[envContext]; !exists { + return "", fmt.Errorf("context '%s' not found in kubeconfig", envContext) + } + + // Set current context to the value from P_KUBE_CONTEXT + config.CurrentContext = envContext + err = clientcmd.WriteToFile(*config, kubeconfigPath) + if err != nil { + return "", err + } + + fmt.Printf("\033[32mUsing Kubernetes context from P_KUBE_CONTEXT: %s ✔\033[0m\n", envContext) + return envContext, nil + } + + // Get available contexts from kubeconfig + currentContext := config.Contexts + var contexts []string + for i := range currentContext { + contexts = append(contexts, i) + } + + // Prompt user to select Kubernetes context + promptK8s := promptui.Select{ + Items: contexts, + Templates: &promptui.SelectTemplates{ + Label: "{{ `Select your Kubernetes context` | yellow }}", + Active: "▸ {{ . | yellow }} ", // Yellow arrow and context name for active selection + Inactive: " {{ . | yellow }}", // Default color for inactive items + Selected: "{{ `Selected Kubernetes context:` | green }} '{{ . | green }}' ✔", + }, + } + + _, clusterName, err = promptK8s.Run() + if err != nil { + return "", err + } + + // Set current context as selected + config.CurrentContext = clusterName + err = clientcmd.WriteToFile(*config, kubeconfigPath) + if err != nil { + return "", err + } + + return clusterName, nil +} + +func PromptClusterSelection(entries []InstallerEntry) (InstallerEntry, error) { + clusterNames := make([]string, len(entries)) + for i, entry := range entries { + clusterNames[i] = fmt.Sprintf("[Name: %s] [Namespace: %s] [Version: %s]", entry.Name, entry.Namespace, entry.Version) + } + + prompt := promptui.Select{ + Label: "Select a cluster to uninstall", + Items: clusterNames, + Templates: &promptui.SelectTemplates{ + Label: "{{ `Select Cluster` | yellow }}", + Active: "▸ {{ . | yellow }}", + Inactive: " {{ . | yellow }}", + Selected: "{{ `Selected:` | green }} {{ . | green }}", + }, + } + + index, _, err := prompt.Run() + if err != nil { + return InstallerEntry{}, fmt.Errorf("failed to prompt for cluster selection: %v", err) + } + + return entries[index], nil +} + +func PromptConfirmation(message string) bool { + prompt := promptui.Prompt{ + Label: message, + IsConfirm: true, + } + + _, err := prompt.Run() + return err == nil +} + +func CreateDeploymentSpinner(infoMsg string) *spinner.Spinner { + // Custom spinner with multiple character sets for dynamic effect + spinnerChars := []string{ + "●", "○", "◉", "○", "◉", "○", "◉", "○", "◉", + } + + s := spinner.New( + spinnerChars, + 120*time.Millisecond, + spinner.WithColor(Yellow), + spinner.WithSuffix(" ..."), + ) + + s.Prefix = Yellow + infoMsg + + return s +} +func RemoveInstallerEntry(name string) error { + // Load kubeconfig and create a Kubernetes client + config, err := LoadKubeConfig() + if err != nil { + return fmt.Errorf("failed to load kubeconfig: %w", err) + } + + clientset, err := kubernetes.NewForConfig(config) + if err != nil { + return fmt.Errorf("failed to create Kubernetes client: %w", err) + } + + // Fetch the ConfigMap + configMap, err := clientset.CoreV1().ConfigMaps(namespace).Get(context.TODO(), configMapName, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("failed to fetch ConfigMap: %v", err) + } + + // Log the current data in the ConfigMap + + // Assuming the entries are stored as YAML or JSON string, unmarshal them into a slice + var entries []map[string]interface{} + if err := yaml.Unmarshal([]byte(configMap.Data["installer-data"]), &entries); err != nil { + return fmt.Errorf("failed to unmarshal installer data: %w", err) + } + + // Find the entry to remove by name + var indexToRemove = -1 + for i, entry := range entries { + if entry["name"] == name { + indexToRemove = i + break + } + } + + // Check if the entry was found + if indexToRemove == -1 { + return fmt.Errorf("entry '%s' does not exist in ConfigMap", name) + } + + // Remove the entry + entries = append(entries[:indexToRemove], entries[indexToRemove+1:]...) + + // Marshal the updated entries back into YAML + updatedData, err := yaml.Marshal(entries) + if err != nil { + return fmt.Errorf("failed to marshal updated entries: %w", err) + } + configMap.Data["installer-data"] = string(updatedData) + + // Update the ConfigMap in Kubernetes + _, err = clientset.CoreV1().ConfigMaps(namespace).Update(context.TODO(), configMap, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("failed to update ConfigMap: %v", err) + } + + return nil +} diff --git a/pkg/helm/helm.go b/pkg/helm/helm.go index 91928fa..ea52248 100644 --- a/pkg/helm/helm.go +++ b/pkg/helm/helm.go @@ -257,7 +257,7 @@ func ListRelease(releaseName, namespace string) (bool, error) { return false, nil } -func GetReleaseValues(chartName, namespace string) (map[string]interface{}, error) { +func GetReleaseValues(releaseName, namespace string) (map[string]interface{}, error) { settings := cli.New() // Initialize action configuration @@ -266,24 +266,15 @@ func GetReleaseValues(chartName, namespace string) (map[string]interface{}, erro return nil, err } - // Create a new List action - client := action.NewList(actionConfig) + // Create a new get action + client := action.NewGet(actionConfig) - // Run the List action to get releases - releases, err := client.Run() + release, err := client.Run(releaseName) if err != nil { return nil, err } - // Iterate over the releases - for _, release := range releases { - // Check if the release's chart name matches the specified chart name - if release.Chart.Name() == chartName { - return release.Chart.Values, nil - } - } - - return nil, nil + return release.Config, nil } // DeleteRelease deletes a Helm release based on the specified chart name and namespace. diff --git a/pkg/installer/installer.go b/pkg/installer/installer.go index dfc01d4..c7c32df 100644 --- a/pkg/installer/installer.go +++ b/pkg/installer/installer.go @@ -20,44 +20,115 @@ import ( "bytes" "context" "encoding/base64" + "encoding/json" "fmt" "log" + "net" "os" - "path/filepath" + "os/exec" + "runtime" "strings" + "sync" + "time" "pb/pkg/common" + "pb/pkg/helm" "github.com/manifoldco/promptui" - yamlv2 "gopkg.in/yaml.v2" + yamling "gopkg.in/yaml.v3" + v1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/util/yaml" "k8s.io/client-go/discovery" "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" "k8s.io/client-go/restmapper" "k8s.io/client-go/tools/clientcmd" ) -// Installer orchestrates the installation process -func Installer(_ Plan) (values *ValuesHolder, chartValues []string) { - if _, err := promptK8sContext(); err != nil { +func Installer(verbose bool) { + printBanner() + waterFall(verbose) +} + +// waterFall orchestrates the installation process +func waterFall(verbose bool) { + var chartValues []string + plan, err := promptUserPlanSelection() + if err != nil { + log.Fatalf("Failed to prompt for plan selection: %v", err) + } + + _, err = common.PromptK8sContext() + if err != nil { log.Fatalf("Failed to prompt for kubernetes context: %v", err) } + if plan.Name == "Playground" { + chartValues = append(chartValues, "parseable.store=local-store") + chartValues = append(chartValues, "parseable.localModeSecret.enabled=true") + + // Prompt for namespace and credentials + pbInfo, err := promptNamespaceAndCredentials() + if err != nil { + log.Fatalf("Failed to prompt for namespace and credentials: %v", err) + } + + // Prompt for agent deployment + _, agentValues, err := promptAgentDeployment(chartValues, *pbInfo) + if err != nil { + log.Fatalf("Failed to prompt for agent deployment: %v", err) + } + + if err := applyParseableSecret(pbInfo, LocalStore, ObjectStoreConfig{}); err != nil { + log.Fatalf("Failed to apply secret object store configuration: %v", err) + } + + // Define the deployment configuration + config := HelmDeploymentConfig{ + ReleaseName: pbInfo.Name, + Namespace: pbInfo.Namespace, + RepoName: "parseable", + RepoURL: "https://charts.parseable.com", + ChartName: "parseable", + Version: "1.6.6", + Values: agentValues, + Verbose: verbose, + } + + if err := deployRelease(config); err != nil { + log.Fatalf("Failed to deploy parseable, err: %v", err) + } + + if err := updateInstallerConfigMap(common.InstallerEntry{ + Name: pbInfo.Name, + Namespace: pbInfo.Namespace, + Version: config.Version, + Status: "success", + }); err != nil { + log.Fatalf("Failed to update parseable installer file, err: %v", err) + } + + printSuccessBanner(*pbInfo, config.Version, "parseable", "parseable") + + return + } + // pb supports only distributed deployments chartValues = append(chartValues, "parseable.highAvailability.enabled=true") // Prompt for namespace and credentials - pbSecret, err := promptNamespaceAndCredentials() + pbInfo, err := promptNamespaceAndCredentials() if err != nil { log.Fatalf("Failed to prompt for namespace and credentials: %v", err) } // Prompt for agent deployment - agent, agentValues, err := promptAgentDeployment(chartValues, distributed, pbSecret.Namespace) + _, agentValues, err := promptAgentDeployment(chartValues, *pbInfo) if err != nil { log.Fatalf("Failed to prompt for agent deployment: %v", err) } @@ -69,54 +140,105 @@ func Installer(_ Plan) (values *ValuesHolder, chartValues []string) { } // Prompt for object store configuration and get the final chart values - objectStoreConfig, storeConfigValues, err := promptStoreConfigs(store, storeValues) + objectStoreConfig, storeConfigs, err := promptStoreConfigs(store, storeValues, plan) if err != nil { log.Fatalf("Failed to prompt for object store configuration: %v", err) } - if err := applyParseableSecret(pbSecret, store, objectStoreConfig); err != nil { + if err := applyParseableSecret(pbInfo, store, objectStoreConfig); err != nil { log.Fatalf("Failed to apply secret object store configuration: %v", err) } - valuesHolder := ValuesHolder{ - DeploymentType: distributed, - ObjectStoreConfig: objectStoreConfig, - LoggingAgent: loggingAgent(agent), - ParseableSecret: *pbSecret, + // Define the deployment configuration + config := HelmDeploymentConfig{ + ReleaseName: pbInfo.Name, + Namespace: pbInfo.Namespace, + RepoName: "parseable", + RepoURL: "https://charts.parseable.com", + ChartName: "parseable", + Version: "1.6.6", + Values: storeConfigs, + Verbose: verbose, + } + + if err := deployRelease(config); err != nil { + log.Fatalf("Failed to deploy parseable, err: %v", err) } - if err := writeParseableConfig(&valuesHolder); err != nil { - log.Fatalf("Failed to write Parseable configuration: %v", err) + if err := updateInstallerConfigMap(common.InstallerEntry{ + Name: pbInfo.Name, + Namespace: pbInfo.Namespace, + Version: config.Version, + Status: "success", + }); err != nil { + log.Fatalf("Failed to update parseable installer file, err: %v", err) } - return &valuesHolder, append(chartValues, storeConfigValues...) + ingestorURL, queryURL := getParseableSvcUrls(pbInfo.Name, pbInfo.Namespace) + + printSuccessBanner(*pbInfo, config.Version, ingestorURL, queryURL) + } -// promptStorageClass prompts the user to enter a Kubernetes storage class +// promptStorageClass fetches and prompts the user to select a Kubernetes storage class func promptStorageClass() (string, error) { - // Prompt user for storage class - fmt.Print(common.Yellow + "Enter the kubernetes storage class: " + common.Reset) - reader := bufio.NewReader(os.Stdin) - storageClass, err := reader.ReadString('\n') + // Load the kubeconfig from the default location + kubeconfig := clientcmd.NewDefaultClientConfigLoadingRules().GetDefaultFilename() + config, err := clientcmd.BuildConfigFromFlags("", kubeconfig) if err != nil { - return "", fmt.Errorf("failed to read storage class: %w", err) + return "", fmt.Errorf("failed to load kubeconfig: %w", err) } - storageClass = strings.TrimSpace(storageClass) + // Create a Kubernetes client + clientset, err := kubernetes.NewForConfig(config) + if err != nil { + return "", fmt.Errorf("failed to create Kubernetes client: %w", err) + } - // Validate that the storage class is not empty - if storageClass == "" { - return "", fmt.Errorf("storage class cannot be empty") + // Fetch the storage classes + storageClasses, err := clientset.StorageV1().StorageClasses().List(context.Background(), metav1.ListOptions{}) + if err != nil { + return "", fmt.Errorf("failed to fetch storage classes: %w", err) } - return storageClass, nil + // Extract the names of storage classes + var storageClassNames []string + for _, sc := range storageClasses.Items { + storageClassNames = append(storageClassNames, sc.Name) + } + + // Check if there are no storage classes available + if len(storageClassNames) == 0 { + return "", fmt.Errorf("no storage classes found in the cluster") + } + + // Use promptui to allow the user to select a storage class + prompt := promptui.Select{ + Label: "Select a Kubernetes storage class", + Items: storageClassNames, + } + + _, selectedStorageClass, err := prompt.Run() + if err != nil { + return "", fmt.Errorf("failed to select storage class: %w", err) + } + + return selectedStorageClass, nil } // promptNamespaceAndCredentials prompts the user for namespace and credentials -func promptNamespaceAndCredentials() (*ParseableSecret, error) { +func promptNamespaceAndCredentials() (*ParseableInfo, error) { + // Prompt user for release name + fmt.Print(common.Yellow + "Enter the Name for deployment: " + common.Reset) + reader := bufio.NewReader(os.Stdin) + name, err := reader.ReadString('\n') + if err != nil { + return nil, fmt.Errorf("failed to read namespace: %w", err) + } + name = strings.TrimSpace(name) + // Prompt user for namespace fmt.Print(common.Yellow + "Enter the Kubernetes namespace for deployment: " + common.Reset) - reader := bufio.NewReader(os.Stdin) namespace, err := reader.ReadString('\n') if err != nil { return nil, fmt.Errorf("failed to read namespace: %w", err) @@ -139,7 +261,8 @@ func promptNamespaceAndCredentials() (*ParseableSecret, error) { } password = strings.TrimSpace(password) - return &ParseableSecret{ + return &ParseableInfo{ + Name: name, Namespace: namespace, Username: username, Password: password, @@ -147,7 +270,7 @@ func promptNamespaceAndCredentials() (*ParseableSecret, error) { } // applyParseableSecret creates and applies the Kubernetes secret -func applyParseableSecret(ps *ParseableSecret, store ObjectStore, objectStoreConfig ObjectStoreConfig) error { +func applyParseableSecret(ps *ParseableInfo, store ObjectStore, objectStoreConfig ObjectStoreConfig) error { var secretManifest string if store == LocalStore { secretManifest = getParseableSecretLocal(ps) @@ -168,7 +291,7 @@ func applyParseableSecret(ps *ParseableSecret, store ObjectStore, objectStoreCon return nil } -func getParseableSecretBlob(ps *ParseableSecret, objectStore ObjectStoreConfig) string { +func getParseableSecretBlob(ps *ParseableInfo, objectStore ObjectStoreConfig) string { // Create the Secret manifest secretManifest := fmt.Sprintf(` apiVersion: v1 @@ -178,7 +301,6 @@ metadata: namespace: %s type: Opaque data: -- addr azr.access_key: %s azr.account: %s azr.container: %s @@ -191,7 +313,7 @@ data: `, ps.Namespace, base64.StdEncoding.EncodeToString([]byte(objectStore.BlobStore.AccessKey)), - base64.StdEncoding.EncodeToString([]byte(objectStore.BlobStore.AccountName)), + base64.StdEncoding.EncodeToString([]byte(objectStore.BlobStore.StorageAccountName)), base64.StdEncoding.EncodeToString([]byte(objectStore.BlobStore.Container)), base64.StdEncoding.EncodeToString([]byte(objectStore.BlobStore.URL)), base64.StdEncoding.EncodeToString([]byte(ps.Username)), @@ -203,7 +325,7 @@ data: return secretManifest } -func getParseableSecretS3(ps *ParseableSecret, objectStore ObjectStoreConfig) string { +func getParseableSecretS3(ps *ParseableInfo, objectStore ObjectStoreConfig) string { // Create the Secret manifest secretManifest := fmt.Sprintf(` apiVersion: v1 @@ -239,7 +361,7 @@ data: return secretManifest } -func getParseableSecretGcs(ps *ParseableSecret, objectStore ObjectStoreConfig) string { +func getParseableSecretGcs(ps *ParseableInfo, objectStore ObjectStoreConfig) string { // Create the Secret manifest secretManifest := fmt.Sprintf(` apiVersion: v1 @@ -275,7 +397,7 @@ data: return secretManifest } -func getParseableSecretLocal(ps *ParseableSecret) string { +func getParseableSecretLocal(ps *ParseableInfo) string { // Create the Secret manifest secretManifest := fmt.Sprintf(` apiVersion: v1 @@ -303,7 +425,7 @@ data: } // promptAgentDeployment prompts the user for agent deployment options -func promptAgentDeployment(chartValues []string, deployment deploymentType, namespace string) (string, []string, error) { +func promptAgentDeployment(chartValues []string, pbInfo ParseableInfo) (string, []string, error) { // Prompt for Agent Deployment type promptAgentSelect := promptui.Select{ Items: []string{string(fluentbit), string(vector), "I have my agent running / I'll set up later"}, @@ -319,15 +441,32 @@ func promptAgentDeployment(chartValues []string, deployment deploymentType, name return "", nil, fmt.Errorf("failed to prompt for agent deployment type: %w", err) } - if agentDeploymentType == string(vector) { - chartValues = append(chartValues, "vector.enabled=true") - } else if agentDeploymentType == string(fluentbit) { - if deployment == standalone { - chartValues = append(chartValues, "fluent-bit.serverHost=parseable."+namespace+".svc.cluster.local") - } else if deployment == distributed { - chartValues = append(chartValues, "fluent-bit.serverHost=parseable-ingestor-service."+namespace+".svc.cluster.local") + ingestorURL, _ := getParseableSvcUrls(pbInfo.Name, pbInfo.Namespace) + + if agentDeploymentType == string(fluentbit) { + chartValues = append(chartValues, "fluent-bit.serverHost="+ingestorURL) + chartValues = append(chartValues, "fluent-bit.serverUsername="+pbInfo.Username) + chartValues = append(chartValues, "fluent-bit.serverPassword="+pbInfo.Password) + chartValues = append(chartValues, "fluent-bit.serverStream="+"$NAMESPACE") + + // Prompt for namespaces to exclude + promptExcludeNamespaces := promptui.Prompt{ + Label: "Enter namespaces to exclude from collection (comma-separated, e.g., kube-system,default): ", + Templates: &promptui.PromptTemplates{ + Prompt: "{{ `Namespaces to exclude` | yellow }}: ", + Valid: "{{ `` | green }}: {{ . | yellow }}", + Invalid: "{{ `Invalid input` | red }}", + }, + } + excludeNamespaces, err := promptExcludeNamespaces.Run() + if err != nil { + return "", nil, fmt.Errorf("failed to prompt for exclude namespaces: %w", err) } + + chartValues = append(chartValues, "fluent-bit.excludeNamespaces="+strings.ReplaceAll(excludeNamespaces, ",", "\\,")) chartValues = append(chartValues, "fluent-bit.enabled=true") + } else if agentDeploymentType == string(vector) { + chartValues = append(chartValues, "vector.enabled=true") } return agentDeploymentType, chartValues, nil @@ -359,7 +498,14 @@ func promptStore(chartValues []string) (ObjectStore, []string, error) { } // promptStoreConfigs prompts for object store configurations and appends chart values -func promptStoreConfigs(store ObjectStore, chartValues []string) (ObjectStoreConfig, []string, error) { +func promptStoreConfigs(store ObjectStore, chartValues []string, plan Plan) (ObjectStoreConfig, []string, error) { + + cpuIngestors := "parseable.highAvailability.ingestor.resources.limits.cpu=" + plan.CPU + memoryIngestors := "parseable.highAvailability.ingestor.resources.limits.memory=" + plan.Memory + + cpuQuery := "parseable.resources.limits.cpu=" + plan.CPU + memoryQuery := "parseable.resources.limits.memory=" + plan.Memory + // Initialize a struct to hold store values var storeValues ObjectStoreConfig @@ -369,12 +515,18 @@ func promptStoreConfigs(store ObjectStore, chartValues []string) (ObjectStoreCon switch store { case S3Store: storeValues.S3Store = S3{ - URL: promptForInput(common.Yellow + " Enter S3 URL: " + common.Reset), - AccessKey: promptForInput(common.Yellow + " Enter S3 Access Key: " + common.Reset), - SecretKey: promptForInput(common.Yellow + " Enter S3 Secret Key: " + common.Reset), - Bucket: promptForInput(common.Yellow + " Enter S3 Bucket: " + common.Reset), - Region: promptForInput(common.Yellow + " Enter S3 Region: " + common.Reset), + Region: promptForInputWithDefault(common.Yellow+" Enter S3 Region (default: us-east-1): "+common.Reset, "us-east-1"), + AccessKey: promptForInputWithDefault(common.Yellow+" Enter S3 Access Key: "+common.Reset, ""), + SecretKey: promptForInputWithDefault(common.Yellow+" Enter S3 Secret Key: "+common.Reset, ""), + Bucket: promptForInputWithDefault(common.Yellow+" Enter S3 Bucket: "+common.Reset, ""), } + + // Dynamically construct the URL after Region is set + storeValues.S3Store.URL = promptForInputWithDefault( + common.Yellow+" Enter S3 URL (default: https://s3."+storeValues.S3Store.Region+".amazonaws.com): "+common.Reset, + "https://s3."+storeValues.S3Store.Region+".amazonaws.com", + ) + sc, err := promptStorageClass() if err != nil { log.Fatalf("Failed to prompt for storage class: %v", err) @@ -384,7 +536,13 @@ func promptStoreConfigs(store ObjectStore, chartValues []string) (ObjectStoreCon chartValues = append(chartValues, "parseable.store="+string(S3Store)) chartValues = append(chartValues, "parseable.s3ModeSecret.enabled=true") chartValues = append(chartValues, "parseable.persistence.staging.enabled=true") + chartValues = append(chartValues, "parseable.persistence.staging.size=5Gi") chartValues = append(chartValues, "parseable.persistence.staging.storageClass="+sc) + chartValues = append(chartValues, cpuIngestors) + chartValues = append(chartValues, memoryIngestors) + chartValues = append(chartValues, cpuQuery) + chartValues = append(chartValues, memoryQuery) + return storeValues, chartValues, nil case BlobStore: sc, err := promptStorageClass() @@ -392,15 +550,32 @@ func promptStoreConfigs(store ObjectStore, chartValues []string) (ObjectStoreCon log.Fatalf("Failed to prompt for storage class: %v", err) } storeValues.BlobStore = Blob{ - URL: promptForInput(common.Yellow + " Enter Blob URL: " + common.Reset), - Container: promptForInput(common.Yellow + " Enter Blob Container: " + common.Reset), + StorageAccountName: promptForInputWithDefault(common.Yellow+" Enter Blob Storage Account Name: "+common.Reset, ""), + Container: promptForInputWithDefault(common.Yellow+" Enter Blob Container: "+common.Reset, ""), + // ClientID: promptForInputWithDefault(common.Yellow+" Enter Client ID: "+common.Reset, ""), + // ClientSecret: promptForInputWithDefault(common.Yellow+" Enter Client Secret: "+common.Reset, ""), + // TenantID: promptForInputWithDefault(common.Yellow+" Enter Tenant ID: "+common.Reset, ""), + AccessKey: promptForInputWithDefault(common.Yellow+" Enter Access Keys: "+common.Reset, ""), } + + // Dynamically construct the URL after Region is set + storeValues.BlobStore.URL = promptForInputWithDefault( + common.Yellow+ + " Enter Blob URL (default: https://"+storeValues.BlobStore.StorageAccountName+".blob.core.windows.net): "+ + common.Reset, + "https://"+storeValues.BlobStore.StorageAccountName+".blob.core.windows.net") + storeValues.StorageClass = sc storeValues.ObjectStore = BlobStore chartValues = append(chartValues, "parseable.store="+string(BlobStore)) chartValues = append(chartValues, "parseable.blobModeSecret.enabled=true") chartValues = append(chartValues, "parseable.persistence.staging.enabled=true") + chartValues = append(chartValues, "parseable.persistence.staging.size=5Gi") chartValues = append(chartValues, "parseable.persistence.staging.storageClass="+sc) + chartValues = append(chartValues, cpuIngestors) + chartValues = append(chartValues, memoryIngestors) + chartValues = append(chartValues, cpuQuery) + chartValues = append(chartValues, memoryQuery) return storeValues, chartValues, nil case GcsStore: sc, err := promptStorageClass() @@ -408,18 +583,24 @@ func promptStoreConfigs(store ObjectStore, chartValues []string) (ObjectStoreCon log.Fatalf("Failed to prompt for storage class: %v", err) } storeValues.GCSStore = GCS{ - URL: promptForInput(common.Yellow + " Enter GCS URL: " + common.Reset), - AccessKey: promptForInput(common.Yellow + " Enter GCS Access Key: " + common.Reset), - SecretKey: promptForInput(common.Yellow + " Enter GCS Secret Key: " + common.Reset), - Bucket: promptForInput(common.Yellow + " Enter GCS Bucket: " + common.Reset), - Region: promptForInput(common.Yellow + " Enter GCS Region: " + common.Reset), + Bucket: promptForInputWithDefault(common.Yellow+" Enter GCS Bucket: "+common.Reset, ""), + Region: promptForInputWithDefault(common.Yellow+" Enter GCS Region (default: us-east1): "+common.Reset, "us-east1"), + URL: promptForInputWithDefault(common.Yellow+" Enter GCS URL (default: https://storage.googleapis.com):", "https://storage.googleapis.com"), + AccessKey: promptForInputWithDefault(common.Yellow+" Enter GCS Access Key: "+common.Reset, ""), + SecretKey: promptForInputWithDefault(common.Yellow+" Enter GCS Secret Key: "+common.Reset, ""), } + storeValues.StorageClass = sc storeValues.ObjectStore = GcsStore chartValues = append(chartValues, "parseable.store="+string(GcsStore)) chartValues = append(chartValues, "parseable.gcsModeSecret.enabled=true") chartValues = append(chartValues, "parseable.persistence.staging.enabled=true") + chartValues = append(chartValues, "parseable.persistence.staging.size=5Gi") chartValues = append(chartValues, "parseable.persistence.staging.storageClass="+sc) + chartValues = append(chartValues, cpuIngestors) + chartValues = append(chartValues, memoryIngestors) + chartValues = append(chartValues, cpuQuery) + chartValues = append(chartValues, memoryQuery) return storeValues, chartValues, nil } @@ -519,81 +700,316 @@ func getGVR(config *rest.Config, obj *unstructured.Unstructured) (schema.GroupVe return mapping.Resource, nil } -// Helper function to prompt for individual input values -func promptForInput(label string) string { +// Helper function to prompt for input with a default value +func promptForInputWithDefault(label, defaultValue string) string { fmt.Print(label) reader := bufio.NewReader(os.Stdin) input, _ := reader.ReadString('\n') - return strings.TrimSpace(input) + input = strings.TrimSpace(input) + + // Use default if input is empty + if input == "" { + return defaultValue + } + return input +} + +// printBanner displays a welcome banner +func printBanner() { + banner := ` + -------------------------------------- + Welcome to Parseable OSS Installation + -------------------------------------- +` + fmt.Println(common.Green + banner + common.Reset) +} + +type HelmDeploymentConfig struct { + ReleaseName string + Namespace string + RepoName string + RepoURL string + ChartName string + Version string + Values []string + Verbose bool } -func writeParseableConfig(valuesHolder *ValuesHolder) error { - // Create config directory - configDir := filepath.Join(os.Getenv("HOME"), ".parseable") - if err := os.MkdirAll(configDir, 0o755); err != nil { - return fmt.Errorf("failed to create config directory: %w", err) +// deployRelease handles the deployment of a Helm release using a configuration struct +func deployRelease(config HelmDeploymentConfig) error { + // Helm application configuration + app := helm.Helm{ + ReleaseName: config.ReleaseName, + Namespace: config.Namespace, + RepoName: config.RepoName, + RepoURL: config.RepoURL, + ChartName: config.ChartName, + Version: config.Version, + Values: config.Values, + } + + // Create a spinner + msg := fmt.Sprintf(" Deploying parseable release name [%s] namespace [%s] ", config.ReleaseName, config.Namespace) + spinner := common.CreateDeploymentSpinner(msg) + + // Redirect standard output if not in verbose mode + var oldStdout *os.File + if !config.Verbose { + oldStdout = os.Stdout + _, w, _ := os.Pipe() + os.Stdout = w + } + + spinner.Start() + + // Deploy using Helm + errCh := make(chan error, 1) + var wg sync.WaitGroup + wg.Add(1) + + go func() { + defer wg.Done() + if err := helm.Apply(app, config.Verbose); err != nil { + errCh <- err + } + }() + + wg.Wait() + close(errCh) + + // Stop the spinner and restore stdout + spinner.Stop() + if !config.Verbose { + os.Stdout = oldStdout } - // Define config file path - configPath := filepath.Join(configDir, valuesHolder.ParseableSecret.Namespace+".yaml") + // Check for errors + if err, ok := <-errCh; ok { + return err + } - // Marshal values to YAML - configBytes, err := yamlv2.Marshal(valuesHolder) + return nil +} + +// printSuccessBanner remains the same as in the original code +func printSuccessBanner(pbInfo ParseableInfo, version, ingestorURL, queryURL string) { + + // Encode credentials to Base64 + credentials := map[string]string{ + "username": pbInfo.Username, + "password": pbInfo.Password, + } + credentialsJSON, err := json.Marshal(credentials) if err != nil { - return fmt.Errorf("failed to marshal config to YAML: %w", err) + fmt.Printf("failed to marshal credentials: %v\n", err) + return } - // Write config file - if err := os.WriteFile(configPath, configBytes, 0o644); err != nil { - return fmt.Errorf("failed to write config file: %w", err) + base64EncodedString := base64.StdEncoding.EncodeToString(credentialsJSON) + + fmt.Println("\n" + common.Green + "🎉 Parseable Deployment Successful! 🎉" + common.Reset) + fmt.Println(strings.Repeat("=", 50)) + + fmt.Printf("%s Deployment Details:\n", common.Blue+"ℹ️ ") + fmt.Printf(" • Namespace: %s\n", common.Blue+pbInfo.Namespace) + fmt.Printf(" • Chart Version: %s\n", common.Blue+version) + fmt.Printf(" • Ingestion URL: %s\n", ingestorURL) + + fmt.Println("\n" + common.Blue + "🔗 Resources:" + common.Reset) + fmt.Println(common.Blue + " • Documentation: https://www.parseable.com/docs/server/introduction") + fmt.Println(common.Blue + " • Stream Management: https://www.parseable.com/docs/server/api") + + fmt.Println("\n" + common.Blue + "Happy Logging!" + common.Reset) + + // Port-forward the service + localPort := "8001" + fmt.Printf(common.Green+"Port-forwarding %s service on port %s in namespace %s...\n"+common.Reset, queryURL, localPort, pbInfo.Namespace) + + if err = startPortForward(pbInfo.Namespace, queryURL, "80", localPort, false); err != nil { + fmt.Printf(common.Red+"failed to port-forward service: %s", err.Error()) } - return nil + // Redirect to UI + localURL := fmt.Sprintf("http://localhost:%s/login?q=%s", localPort, base64EncodedString) + fmt.Printf(common.Green+"Opening Parseable UI at %s\n"+common.Reset, localURL) + openBrowser(localURL) } -// promptK8sContext retrieves Kubernetes contexts from kubeconfig. -func promptK8sContext() (clusterName string, err error) { - kubeconfigPath := os.Getenv("KUBECONFIG") - if kubeconfigPath == "" { - kubeconfigPath = os.Getenv("HOME") + "/.kube/config" +func startPortForward(namespace, serviceName, remotePort, localPort string, verbose bool) error { + // Build the port-forward command + cmd := exec.Command("kubectl", "port-forward", + fmt.Sprintf("svc/%s", serviceName), + fmt.Sprintf("%s:%s", localPort, remotePort), + "-n", namespace, + ) + + // Redirect the command's output to the standard output for debugging + if !verbose { + cmd.Stdout = nil // Suppress standard output + cmd.Stderr = nil // Suppress standard error + } else { + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + } + + // Run the command in the background + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start port-forward: %w", err) + } + + // Run in a goroutine to keep it alive + go func() { + _ = cmd.Wait() + }() + + // Check connection on the forwarded port + retries := 10 + for i := 0; i < retries; i++ { + conn, err := net.Dial("tcp", fmt.Sprintf("localhost:%s", localPort)) + if err == nil { + conn.Close() // Connection successful, break out of the loop + fmt.Println(common.Green + "Port-forwarding successfully established!") + time.Sleep(5 * time.Second) // some delay + return nil + } + time.Sleep(3 * time.Second) // Wait before retrying } - // Load kubeconfig file - config, err := clientcmd.LoadFromFile(kubeconfigPath) + // If we reach here, port-forwarding failed + cmd.Process.Kill() // Stop the kubectl process + return fmt.Errorf(common.Red+"failed to establish port-forward connection to localhost:%s", localPort) +} + +func openBrowser(url string) { + var cmd *exec.Cmd + switch os := runtime.GOOS; os { + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + case "darwin": + cmd = exec.Command("open", url) + case "linux": + cmd = exec.Command("xdg-open", url) + default: + fmt.Printf("Please open the following URL manually: %s\n", url) + return + } + cmd.Start() +} + +func updateInstallerConfigMap(entry common.InstallerEntry) error { + const ( + configMapName = "parseable-installer" + namespace = "pb-system" + dataKey = "installer-data" + ) + + // Load kubeconfig and create a Kubernetes client + config, err := loadKubeConfig() if err != nil { - fmt.Printf("\033[31mError loading kubeconfig: %v\033[0m\n", err) - os.Exit(1) + return fmt.Errorf("failed to load kubeconfig: %w", err) } - // Get current contexts - currentContext := config.Contexts - var contexts []string - for i := range currentContext { - contexts = append(contexts, i) + clientset, err := kubernetes.NewForConfig(config) + if err != nil { + return fmt.Errorf("failed to create Kubernetes client: %w", err) } - // Prompt user to select Kubernetes context - promptK8s := promptui.Select{ - Items: contexts, - Templates: &promptui.SelectTemplates{ - Label: "{{ `Select your Kubernetes context` | yellow }}", - Active: "▸ {{ . | yellow }} ", // Yellow arrow and context name for active selection - Inactive: " {{ . | yellow }}", // Default color for inactive items - Selected: "{{ `Selected Kubernetes context:` | green }} '{{ . | green }}' ✔", - }, + // Ensure the namespace exists + _, err = clientset.CoreV1().Namespaces().Get(context.TODO(), namespace, metav1.GetOptions{}) + if err != nil { + if apierrors.IsNotFound(err) { + _, err = clientset.CoreV1().Namespaces().Create(context.TODO(), &v1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespace, + }, + }, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("failed to create namespace: %v", err) + } + } else { + return fmt.Errorf("failed to check namespace existence: %v", err) + } + } + + // Create a dynamic Kubernetes client + dynamicClient, err := dynamic.NewForConfig(config) + if err != nil { + return fmt.Errorf("failed to create dynamic client: %w", err) + } + + // Define the ConfigMap resource + configMapResource := schema.GroupVersionResource{ + Group: "", // Core resources have an empty group + Version: "v1", + Resource: "configmaps", } - _, clusterName, err = promptK8s.Run() + // Fetch the existing ConfigMap or initialize a new one + cm, err := dynamicClient.Resource(configMapResource).Namespace(namespace).Get(context.TODO(), configMapName, metav1.GetOptions{}) + var data map[string]interface{} if err != nil { - return "", err + if !apierrors.IsNotFound(err) { + return fmt.Errorf("failed to fetch ConfigMap: %v", err) + } + // If not found, initialize a new ConfigMap + data = map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": configMapName, + "namespace": namespace, + }, + "data": map[string]interface{}{}, + } + } else { + data = cm.Object + } + + // Retrieve existing data and append the new entry + existingData := data["data"].(map[string]interface{}) + var entries []common.InstallerEntry + if raw, ok := existingData[dataKey]; ok { + if err := yaml.Unmarshal([]byte(raw.(string)), &entries); err != nil { + return fmt.Errorf("failed to parse existing ConfigMap data: %v", err) + } } + entries = append(entries, entry) - // Set current context as selected - config.CurrentContext = clusterName - err = clientcmd.WriteToFile(*config, kubeconfigPath) + // Marshal the updated data back to YAML + updatedData, err := yamling.Marshal(entries) if err != nil { - return "", err + return fmt.Errorf("failed to marshal updated data: %v", err) + } + + // Update the ConfigMap data + existingData[dataKey] = string(updatedData) + data["data"] = existingData + + // Apply the ConfigMap + if cm == nil { + _, err = dynamicClient.Resource(configMapResource).Namespace(namespace).Create(context.TODO(), &unstructured.Unstructured{ + Object: data, + }, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("failed to create ConfigMap: %v", err) + } + } else { + _, err = dynamicClient.Resource(configMapResource).Namespace(namespace).Update(context.TODO(), &unstructured.Unstructured{ + Object: data, + }, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("failed to update ConfigMap: %v", err) + } } - return clusterName, nil + return nil +} + +func getParseableSvcUrls(releaseName, namespace string) (ingestorURL, queryURL string) { + if releaseName == "parseable" { + ingestorURL = releaseName + "-ingestor-service." + namespace + ".svc.cluster.local" + queryURL = releaseName + "-querier-service" + return ingestorURL, queryURL + } + ingestorURL = releaseName + "-parseable-ingestor-service." + namespace + ".svc.cluster.local" + queryURL = releaseName + "-parseable-querier-service" + return ingestorURL, queryURL } diff --git a/pkg/installer/model.go b/pkg/installer/model.go index 7710e9f..d7364d0 100644 --- a/pkg/installer/model.go +++ b/pkg/installer/model.go @@ -15,16 +15,6 @@ package installer -// deploymentType represents the type of deployment for the application. -type deploymentType string - -const ( - // standalone is a single-node deployment. - standalone deploymentType = "standalone" - // distributed is a multi-node deployment. - distributed deploymentType = "distributed" -) - // loggingAgent represents the type of logging agent used. type loggingAgent string @@ -37,9 +27,10 @@ const ( _ loggingAgent = "I have my agent running / I'll set up later" ) -// ParseableSecret represents the secret used to authenticate with Parseable. -type ParseableSecret struct { - Namespace string // Namespace where the secret is located. +// ParseableInfo represents the info used to authenticate, metadata with Parseable. +type ParseableInfo struct { + Name string // Name for parseable + Namespace string // Namespace for parseable Username string // Username for authentication. Password string // Password for authentication. } @@ -87,16 +78,11 @@ type GCS struct { // Blob contains configuration details for an Azure Blob Storage backend. type Blob struct { - AccessKey string // Access key for authentication. - AccountName string // Account name for Azure Blob Storage. - Container string // Container name in the Azure Blob store. - URL string // URL of the Azure Blob store. -} - -// ValuesHolder holds the configuration values required for deployment. -type ValuesHolder struct { - DeploymentType deploymentType // Deployment type (standalone or distributed). - ObjectStoreConfig ObjectStoreConfig // Configuration for the object storage backend. - LoggingAgent loggingAgent // Logging agent to be used. - ParseableSecret ParseableSecret // Secret used to authenticate with Parseable. + AccessKey string // Access key for authentication. + StorageAccountName string // Account name for Azure Blob Storage. + Container string // Container name in the Azure Blob store. + ClientID string // Client ID to authenticate. + ClientSecret string // Client Secret to authenticate. + TenantID string // TenantID + URL string // URL of the Azure Blob store. } diff --git a/pkg/installer/plans.go b/pkg/installer/plans.go index d37481e..cb86976 100644 --- a/pkg/installer/plans.go +++ b/pkg/installer/plans.go @@ -29,20 +29,29 @@ type Plan struct { PerDayIngestion string QueryPerformance string CPUAndMemorySpecs string - CPU int - Memory int + CPU string + Memory string } // Plans define the plans with clear CPU and memory specs for consumption var Plans = map[string]Plan{ + "Playground": { + Name: "Playground", + IngestionSpeed: "100 events/sec", + PerDayIngestion: "~1GB", + QueryPerformance: "Basic performance", + CPUAndMemorySpecs: "1 CPUs, 1GB RAM", + CPU: "1", + Memory: "1Gi", + }, "Small": { Name: "Small", IngestionSpeed: "1000 events/sec", PerDayIngestion: "~10GB", QueryPerformance: "Basic performance", CPUAndMemorySpecs: "2 CPUs, 4GB RAM", - CPU: 2, - Memory: 4, + CPU: "2", + Memory: "4Gi", }, "Medium": { Name: "Medium", @@ -50,8 +59,8 @@ var Plans = map[string]Plan{ PerDayIngestion: "~100GB", QueryPerformance: "Moderate performance", CPUAndMemorySpecs: "4 CPUs, 16GB RAM", - CPU: 4, - Memory: 16, + CPU: "4", + Memory: "16Gi", }, "Large": { Name: "Large", @@ -59,13 +68,14 @@ var Plans = map[string]Plan{ PerDayIngestion: "~1TB", QueryPerformance: "High performance", CPUAndMemorySpecs: "8 CPUs, 32GB RAM", - CPU: 8, - Memory: 32, + CPU: "8", + Memory: "32Gi", }, } -func PromptUserPlanSelection() (Plan, error) { +func promptUserPlanSelection() (Plan, error) { planList := []Plan{ + Plans["Playground"], Plans["Small"], Plans["Medium"], Plans["Large"], @@ -80,14 +90,17 @@ func PromptUserPlanSelection() (Plan, error) { Details: ` --------- Plan Details ---------- {{ "Plan:" | faint }} {{ .Name }} - {{ "Ingestion Speed:" | faint }} {{ .IngestionSpeed }} - {{ "Per Day Ingestion:" | faint }} {{ .PerDayIngestion }} - {{ "Query Performance:" | faint }} {{ .QueryPerformance }} - {{ "CPU & Memory:" | faint }} {{ .CPUAndMemorySpecs }}`, +{{ "Ingestion Speed:" | faint }} {{ .IngestionSpeed }} +{{ "Per Day Ingestion:" | faint }} {{ .PerDayIngestion }} +{{ "Query Performance:" | faint }} {{ .QueryPerformance }} +{{ "CPU & Memory:" | faint }} {{ .CPUAndMemorySpecs }}`, } + // Add a note about the default plan in the label + label := fmt.Sprintf(common.Yellow + "Select deployment type:") + prompt := promptui.Select{ - Label: fmt.Sprintf(common.Yellow + "Select deployment type"), + Label: label, Items: planList, Templates: templates, } @@ -97,5 +110,14 @@ func PromptUserPlanSelection() (Plan, error) { return Plan{}, fmt.Errorf("failed to select deployment type: %w", err) } - return planList[index], nil + selectedPlan := planList[index] + fmt.Printf( + common.Cyan+" Ingestion Speed: %s\n"+ + common.Cyan+" Per Day Ingestion: %s\n"+ + common.Cyan+" Query Performance: %s\n"+ + common.Cyan+" CPU & Memory: %s\n"+ + common.Reset, selectedPlan.IngestionSpeed, selectedPlan.PerDayIngestion, + selectedPlan.QueryPerformance, selectedPlan.CPUAndMemorySpecs) + + return selectedPlan, nil } diff --git a/pkg/installer/spinner.go b/pkg/installer/spinner.go deleted file mode 100644 index 6d51d01..0000000 --- a/pkg/installer/spinner.go +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) 2024 Parseable, Inc -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package installer - -import ( - "fmt" - "time" - - "pb/pkg/common" - - "github.com/briandowns/spinner" -) - -func createDeploymentSpinner(namespace, infoMsg string) *spinner.Spinner { - // Custom spinner with multiple character sets for dynamic effect - spinnerChars := []string{ - "●", "○", "◉", "○", "◉", "○", "◉", "○", "◉", - } - - s := spinner.New( - spinnerChars, - 120*time.Millisecond, - spinner.WithColor(common.Yellow), - spinner.WithSuffix(" ..."), - ) - - s.Prefix = fmt.Sprintf(common.Yellow+infoMsg+" %s ", namespace) - - return s -} diff --git a/pkg/installer/uninstaller.go b/pkg/installer/uninstaller.go index 5f0cb8c..ddb9d86 100644 --- a/pkg/installer/uninstaller.go +++ b/pkg/installer/uninstaller.go @@ -21,57 +21,85 @@ import ( "fmt" "os" "path/filepath" - "strings" - "time" - "pb/pkg/common" "pb/pkg/helm" + "strings" + "time" - "gopkg.in/yaml.v2" + "github.com/manifoldco/promptui" + apierrors "k8s.io/apimachinery/pkg/api/errors" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/yaml" "k8s.io/client-go/kubernetes" ) +// Uninstaller uninstalls Parseable from the selected cluster func Uninstaller(verbose bool) error { - // Load configuration from the parseable.yaml file - configPath := filepath.Join(os.Getenv("HOME"), ".parseable", "parseable.yaml") - config, err := loadParseableConfig(configPath) + // Define the installer file path + homeDir, err := os.UserHomeDir() if err != nil { - return fmt.Errorf("failed to load configuration: %v", err) + return fmt.Errorf("failed to get user home directory: %w", err) + } + installerFilePath := filepath.Join(homeDir, ".parseable", "pb", "installer.yaml") + + // Read the installer file + data, err := os.ReadFile(installerFilePath) + if err != nil { + return fmt.Errorf("failed to read installer file: %w", err) + } + + // Unmarshal the installer file content + var entries []common.InstallerEntry + if err := yaml.Unmarshal(data, &entries); err != nil { + return fmt.Errorf("failed to parse installer file: %w", err) + } + + // Prompt the user to select a cluster + clusterNames := make([]string, len(entries)) + for i, entry := range entries { + clusterNames[i] = fmt.Sprintf("[Name: %s] [Namespace: %s]", entry.Name, entry.Namespace) } - if config == (&ValuesHolder{}) { - return fmt.Errorf("no existing configuration found in ~/.parseable/parseable.yaml") + promptClusterSelect := promptui.Select{ + Label: "Select a cluster to delete", + Items: clusterNames, + Templates: &promptui.SelectTemplates{ + Label: "{{ `Select Cluster` | yellow }}", + Active: "▸ {{ . | yellow }}", // Yellow arrow for active selection + Inactive: " {{ . | yellow }}", + Selected: "{{ `Selected:` | green }} {{ . | green }}", + }, } - // Prompt for Kubernetes context - _, err = promptK8sContext() + index, _, err := promptClusterSelect.Run() if err != nil { - return fmt.Errorf("failed to prompt for Kubernetes context: %v", err) + return fmt.Errorf("failed to prompt for cluster selection: %v", err) } - // Prompt user to confirm namespace - namespace := config.ParseableSecret.Namespace - confirm, err := promptUserConfirmation(fmt.Sprintf(common.Yellow+"Do you wish to uninstall Parseable from namespace '%s'?", namespace)) + selectedCluster := entries[index] + + // Confirm deletion + confirm, err := promptUserConfirmation(fmt.Sprintf(common.Yellow+"Do you still want to proceed with deleting the cluster '%s'?", selectedCluster.Name)) if err != nil { return fmt.Errorf("failed to get user confirmation: %v", err) } if !confirm { - return fmt.Errorf("Uninstall canceled.") + fmt.Println(common.Yellow + "Uninstall canceled." + common.Reset) + return nil } // Helm application configuration helmApp := helm.Helm{ - ReleaseName: "parseable", - Namespace: namespace, + ReleaseName: selectedCluster.Name, + Namespace: selectedCluster.Namespace, RepoName: "parseable", RepoURL: "https://charts.parseable.com", ChartName: "parseable", - Version: "1.6.5", + Version: selectedCluster.Version, } // Create a spinner - spinner := createDeploymentSpinner(namespace, "Uninstalling parseable in ") + spinner := common.CreateDeploymentSpinner("Uninstalling Parseable in ") // Redirect standard output if not in verbose mode var oldStdout *os.File @@ -96,15 +124,15 @@ func Uninstaller(verbose bool) error { return fmt.Errorf("failed to uninstall Parseable: %v", err) } - // Namespace cleanup using Kubernetes client - fmt.Printf(common.Yellow+"Cleaning up namespace '%s'...\n"+common.Reset, namespace) - cleanupErr := cleanupNamespaceWithClient(namespace) + // Call to clean up the secret instead of the namespace + fmt.Printf(common.Yellow+"Cleaning up 'parseable-env-secret' in namespace '%s'...\n"+common.Reset, selectedCluster.Namespace) + cleanupErr := cleanupParseableSecret(selectedCluster.Namespace) if cleanupErr != nil { - return fmt.Errorf("failed to clean up namespace '%s': %v", namespace, cleanupErr) + return fmt.Errorf("failed to clean up secret in namespace '%s': %v", selectedCluster.Namespace, cleanupErr) } // Print success banner - fmt.Printf(common.Green+"Successfully uninstalled Parseable from namespace '%s'.\n"+common.Reset, namespace) + fmt.Printf(common.Green+"Successfully uninstalled Parseable from namespace '%s'.\n"+common.Reset, selectedCluster.Namespace) return nil } @@ -121,21 +149,8 @@ func promptUserConfirmation(message string) (bool, error) { return response == "y" || response == "yes", nil } -// loadParseableConfig loads the configuration from the specified file -func loadParseableConfig(path string) (*ValuesHolder, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - var config ValuesHolder - if err := yaml.Unmarshal(data, &config); err != nil { - return nil, err - } - return &config, nil -} - -// cleanupNamespaceWithClient deletes the specified namespace using Kubernetes client-go -func cleanupNamespaceWithClient(namespace string) error { +// cleanupParseableSecret deletes the "parseable-env-secret" in the specified namespace using Kubernetes client-go +func cleanupParseableSecret(namespace string) error { // Load the kubeconfig config, err := loadKubeConfig() if err != nil { @@ -148,26 +163,25 @@ func cleanupNamespaceWithClient(namespace string) error { return fmt.Errorf("failed to create Kubernetes client: %v", err) } - // Create a context with a timeout for namespace deletion - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + // Create a context with a timeout for secret deletion + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - // Delete the namespace - err = clientset.CoreV1().Namespaces().Delete(ctx, namespace, v1.DeleteOptions{}) - if err != nil { - return fmt.Errorf("error deleting namespace: %v", err) - } + // Define the secret name + secretName := "parseable-env-secret" - // Wait for the namespace to be fully removed - fmt.Printf("Waiting for namespace '%s' to be deleted...\n", namespace) - for { - _, err := clientset.CoreV1().Namespaces().Get(ctx, namespace, v1.GetOptions{}) - if err != nil { - fmt.Printf("Namespace '%s' successfully deleted.\n", namespace) - break + // Delete the secret + err = clientset.CoreV1().Secrets(namespace).Delete(ctx, secretName, v1.DeleteOptions{}) + if err != nil { + if apierrors.IsNotFound(err) { + fmt.Printf("Secret '%s' not found in namespace '%s'. Nothing to delete.\n", secretName, namespace) + return nil } - time.Sleep(2 * time.Second) + return fmt.Errorf("error deleting secret '%s' in namespace '%s': %v", secretName, namespace, err) } + // Confirm the deletion + fmt.Printf("Secret '%s' successfully deleted from namespace '%s'.\n", secretName, namespace) + return nil }