From 1d4f417d22128b87d62c9a674999dca3f7c55359 Mon Sep 17 00:00:00 2001 From: Evans Mungai Date: Mon, 7 Oct 2024 07:57:33 -0500 Subject: [PATCH] feat: download and extract bundles that do not have cluster resources using shell subcommand (#187) * feat: download bundle and open shell into extracted bundle directory Signed-off-by: Evans Mungai * Remove commented out code Signed-off-by: Evans Mungai * User sbctl shell to download and interact with host bundles Signed-off-by: Evans Mungai * Make dir cleanup reliable Signed-off-by: Evans Mungai --------- Signed-off-by: Evans Mungai --- cli/download.go | 62 ++++++++++++++---------- cli/serve.go | 65 ++++--------------------- cli/shell.go | 126 ++++++++++++++++++++++++++++-------------------- 3 files changed, 119 insertions(+), 134 deletions(-) diff --git a/cli/download.go b/cli/download.go index fbb04ac..b1ad247 100644 --- a/cli/download.go +++ b/cli/download.go @@ -17,9 +17,12 @@ import ( func DownloadCmd() *cobra.Command { cmd := &cobra.Command{ Use: "download", - Short: "Download bundle from url", - Long: "Download bundle from url", + Short: "Download bundle from Vendor Portal url", + Long: "Download bundle from Vendor Portal url", Args: cobra.MaximumNArgs(1), + PreRunE: func(cmd *cobra.Command, args []string) error { + return viper.BindPFlags(cmd.Flags()) + }, RunE: func(cmd *cobra.Command, args []string) error { v := viper.GetViper() @@ -38,13 +41,11 @@ func DownloadCmd() *cobra.Command { } fmt.Println("Downloading bundle...") - file, err := downloadBundleToDisk(bundleLocation, token) if err != nil { return err } fmt.Println(file) - return nil }, } @@ -53,19 +54,40 @@ func DownloadCmd() *cobra.Command { } func downloadBundleToDisk(bundleUrl string, token string) (string, error) { + body, err := downloadBundleFromVendorPortal(bundleUrl, token) + if err != nil { + return "", errors.Wrap(err, "failed to download bundle") + } + defer body.Close() + + sbFile, err := os.Create("support-bundle.tgz") + if err != nil { + return "", errors.Wrap(err, "failed to create file") + } + defer sbFile.Close() + + _, err = io.Copy(sbFile, body) + if err != nil { + return "", errors.Wrap(err, "failed to copy bundle to file") + } + + return sbFile.Name(), nil +} + +func downloadBundleFromVendorPortal(bundleUrl, token string) (io.ReadCloser, error) { parsedUrl, err := url.Parse(bundleUrl) if err != nil { - return "", errors.Wrap(err, "failed to parse url") + return nil, errors.Wrap(err, "failed to parse url") } _, slug := path.Split(parsedUrl.Path) if slug == "" { - return "", errors.New("failed to extract slug from URL") + return nil, errors.New("failed to extract slug from URL") } sbEndpoint := fmt.Sprintf("https://api.replicated.com/vendor/v3/supportbundle/%s", slug) req, err := http.NewRequest("GET", sbEndpoint, nil) if err != nil { - return "", errors.Wrap(err, "failed to create HTTP request") + return nil, errors.Wrap(err, "failed to create HTTP request") } req.Header.Add("Authorization", token) @@ -73,17 +95,17 @@ func downloadBundleToDisk(bundleUrl string, token string) (string, error) { resp, err := http.DefaultClient.Do(req) if err != nil { - return "", errors.Wrap(err, "failed to execute request") + return nil, errors.Wrap(err, "failed to execute request") } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { - return "", errors.Wrap(err, "failed to read GQL response") + return nil, errors.Wrap(err, "failed to read GQL response") } if resp.StatusCode != http.StatusOK { - return "", errors.Errorf("unexpected status code: %v", resp.StatusCode) + return nil, errors.Errorf("unexpected status code: %v", resp.StatusCode) } bundleObj := struct { @@ -93,29 +115,17 @@ func downloadBundleToDisk(bundleUrl string, token string) (string, error) { }{} err = json.Unmarshal(body, &bundleObj) if err != nil { - return "", errors.Wrapf(err, "failed to unmarshal response: %s", body) + return nil, errors.Wrapf(err, "failed to unmarshal response: %s", body) } resp, err = http.Get(bundleObj.Bundle.SignedUri) if err != nil { - return "", errors.Wrap(err, "failed to execute signed URL request") + return nil, errors.Wrap(err, "failed to execute signed URL request") } - defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return "", errors.Errorf("unexpected status code: %v", resp.StatusCode) + return nil, errors.Errorf("unexpected status code: %v", resp.StatusCode) } - sbFile, err := os.Create("support-bundle.tgz") - if err != nil { - return "", errors.Wrap(err, "failed to create file") - } - defer sbFile.Close() - - _, err = io.Copy(sbFile, resp.Body) - if err != nil { - return "", errors.Wrap(err, "failed to copy bundle to file") - } - - return sbFile.Name(), nil + return resp.Body, nil } diff --git a/cli/serve.go b/cli/serve.go index 1f2980b..f6f8668 100644 --- a/cli/serve.go +++ b/cli/serve.go @@ -1,14 +1,10 @@ package cli import ( - "encoding/json" "fmt" "io" - "net/http" - "net/url" "os" "os/signal" - "path" "strings" "github.com/pkg/errors" @@ -100,6 +96,12 @@ func ServeCmd() *cobra.Command { return errors.Wrap(err, "failed to find cluster data") } + // If we did not find cluster data, just don't start the API server + if clusterData.ClusterResourcesDir == "" { + fmt.Println("No cluster resources found in bundle") + return nil + } + kubeConfig, err = api.StartAPIServer(clusterData, os.Stderr) if err != nil { return errors.Wrap(err, "failed to create api server") @@ -123,58 +125,11 @@ func ServeCmd() *cobra.Command { } func downloadAndExtractBundle(bundleUrl string, token string) (string, error) { - parsedUrl, err := url.Parse(bundleUrl) - if err != nil { - return "", errors.Wrap(err, "failed to parse url") - } - - _, slug := path.Split(parsedUrl.Path) - if slug == "" { - return "", errors.New("failed to extract slug from URL") - } - sbEndpoint := fmt.Sprintf("https://api.replicated.com/vendor/v3/supportbundle/%s", slug) - req, err := http.NewRequest("GET", sbEndpoint, nil) + body, err := downloadBundleFromVendorPortal(bundleUrl, token) if err != nil { - return "", errors.Wrap(err, "failed to create HTTP request") - } - - req.Header.Add("Authorization", token) - req.Header.Add("Content-Type", "application/json") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return "", errors.Wrap(err, "failed to execute request") - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", errors.Wrap(err, "failed to read GQL response") - } - - if resp.StatusCode != http.StatusOK { - return "", errors.Errorf("unexpected status code: %v", resp.StatusCode) - } - - bundleObj := struct { - Bundle struct { - SignedUri string `json:"signedUri"` - } `json:"bundle"` - }{} - err = json.Unmarshal(body, &bundleObj) - if err != nil { - return "", errors.Wrapf(err, "failed to unmarshal response: %s", body) - } - - resp, err = http.Get(bundleObj.Bundle.SignedUri) - if err != nil { - return "", errors.Wrap(err, "failed to execute signed URL request") - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return "", errors.Errorf("unexpected status code: %v", resp.StatusCode) + return "", errors.Wrap(err, "failed to download bundle") } + defer body.Close() tmpFile, err := os.CreateTemp("", "sbctl-bundle-") if err != nil { @@ -182,7 +137,7 @@ func downloadAndExtractBundle(bundleUrl string, token string) (string, error) { } defer tmpFile.Close() - _, err = io.Copy(tmpFile, resp.Body) + _, err = io.Copy(tmpFile, body) if err != nil { return "", errors.Wrap(err, "failed to copy bundle to tmp file") } diff --git a/cli/shell.go b/cli/shell.go index afc4b69..a45cfea 100644 --- a/cli/shell.go +++ b/cli/shell.go @@ -37,28 +37,38 @@ func ShellCmd() *cobra.Command { deleteBundleDir := false logOutput := os.Stderr - logFile, err := os.CreateTemp("", "sbctl-server-logs-") + logFile, err := os.CreateTemp("", "sbctl-server-*.log") if err == nil { - fmt.Printf("API server logs will be written to %s\n", logFile.Name()) defer logFile.Close() defer os.RemoveAll(logFile.Name()) + fmt.Printf("SBCTL logs will be written to %s\n", logFile.Name()) log.SetOutput(logFile) logOutput = logFile } - go func() { - signalChan := make(chan os.Signal, 1) - signal.Notify(signalChan, os.Interrupt) - <-signalChan + cleanup := func() { if kubeConfig != "" { _ = os.RemoveAll(kubeConfig) } if deleteBundleDir && bundleDir != "" { _ = os.RemoveAll(bundleDir) } + } + + go func() { + signalChan := make(chan os.Signal, 1) + // Handle Ctl-D to exit from shell + signal.Notify(signalChan, os.Interrupt) + <-signalChan + cleanup() os.Exit(0) }() + defer func() { + // exit from shell using "exit" command + cleanup() + }() + v := viper.GetViper() // This only works with generated config, so let's make sure we don't mess up user's real files. @@ -111,65 +121,27 @@ func ShellCmd() *cobra.Command { return errors.Wrap(err, "failed to find cluster data") } + // If we did not find cluster data, just don't start the API server + if clusterData.ClusterResourcesDir == "" { + fmt.Println("No cluster resources found in bundle") + fmt.Println("Starting new shell in downloaded bundle. Press Ctl-D when done to exit from the shell") + return startShellAndWait(fmt.Sprintf("cd %s", bundleDir)) + } + kubeConfig, err = api.StartAPIServer(clusterData, logOutput) if err != nil { return errors.Wrap(err, "failed to create api server") } defer os.RemoveAll(kubeConfig) - shellCmd := os.Getenv("SHELL") - if shellCmd == "" { - return errors.New("SHELL environment is required for shell command") - } - - shellExec := exec.Command(shellCmd) - shellExec.Env = os.Environ() - fmt.Printf("Starting new shell with KUBECONFIG. Press Ctl-D when done to end the shell and the sbctl server\n") - shellPty, err := pty.Start(shellExec) - if err != nil { - return errors.Wrap(err, "failed to start shell") - } - - // Handle pty size. - ch := make(chan os.Signal, 1) - signal.Notify(ch, syscall.SIGWINCH) - go func() { - for range ch { - if err := pty.InheritSize(os.Stdin, shellPty); err != nil { - log.Printf("error resizing pty: %s", err) - } - } - }() - ch <- syscall.SIGWINCH // Initial resize. - defer func() { signal.Stop(ch); close(ch) }() - - // Set stdin to raw mode. - oldState, err := term.MakeRaw(syscall.Stdin) - if err != nil { - panic(err) - } - defer func() { - _ = term.Restore(syscall.Stdin, oldState) - fmt.Printf("sbctl shell exited\n") - }() - cmds := []string{ fmt.Sprintf("export KUBECONFIG=%s", kubeConfig), } if v.GetBool("cd-bundle") { cmds = append(cmds, fmt.Sprintf("cd %s", bundleDir)) } - - // Setup the shell - setupCmd := strings.Join(cmds, "\n") + "\n" - _, _ = io.WriteString(shellPty, setupCmd) - _, _ = io.CopyN(io.Discard, shellPty, 2*int64(len(setupCmd))) // Don't print to screen, terminal will echo anyway - - // Copy stdin to the pty and the pty to stdout. - go func() { _, _ = io.Copy(shellPty, os.Stdin) }() - go func() { _, _ = io.Copy(os.Stdout, shellPty) }() - - return shellExec.Wait() + fmt.Printf("Starting new shell with KUBECONFIG. Press Ctl-D when done to exit from the shell and stop sbctl server\n") + return startShellAndWait(cmds...) }, } @@ -179,3 +151,51 @@ func ShellCmd() *cobra.Command { cmd.Flags().Bool("debug", false, "enable debug logging. This will include HTTP response bodies in logs.") return cmd } + +func startShellAndWait(cmds ...string) error { + shellCmd := os.Getenv("SHELL") + if shellCmd == "" { + return errors.New("SHELL environment is required for shell command") + } + + shellExec := exec.Command(shellCmd) + shellExec.Env = os.Environ() + shellPty, err := pty.Start(shellExec) + if err != nil { + return errors.Wrap(err, "failed to start shell") + } + + // Handle pty size. + ch := make(chan os.Signal, 1) + signal.Notify(ch, syscall.SIGWINCH) + go func() { + for range ch { + if err := pty.InheritSize(os.Stdin, shellPty); err != nil { + log.Printf("error resizing pty: %s", err) + } + } + }() + ch <- syscall.SIGWINCH // Initial resize. + defer func() { signal.Stop(ch); close(ch) }() + + // Set stdin to raw mode. + oldState, err := term.MakeRaw(syscall.Stdin) + if err != nil { + panic(err) + } + defer func() { + _ = term.Restore(syscall.Stdin, oldState) + fmt.Printf("sbctl shell exited\n") + }() + + // Setup the shell + setupCmd := strings.Join(cmds, "\n") + "\n" + _, _ = io.WriteString(shellPty, setupCmd) + _, _ = io.CopyN(io.Discard, shellPty, 2*int64(len(setupCmd))) // Don't print to screen, terminal will echo anyway + + // Copy stdin to the pty and the pty to stdout. + go func() { _, _ = io.Copy(shellPty, os.Stdin) }() + go func() { _, _ = io.Copy(os.Stdout, shellPty) }() + + return shellExec.Wait() +}