Skip to content

Commit

Permalink
feat: download and extract bundles that do not have cluster resources…
Browse files Browse the repository at this point in the history
… using shell subcommand (#187)

* feat: download bundle and open shell into extracted bundle directory

Signed-off-by: Evans Mungai <[email protected]>

* Remove commented out code

Signed-off-by: Evans Mungai <[email protected]>

* User sbctl shell to download and interact with host bundles

Signed-off-by: Evans Mungai <[email protected]>

* Make dir cleanup reliable

Signed-off-by: Evans Mungai <[email protected]>

---------

Signed-off-by: Evans Mungai <[email protected]>
  • Loading branch information
banjoh authored Oct 7, 2024
1 parent 76ed698 commit 1d4f417
Show file tree
Hide file tree
Showing 3 changed files with 119 additions and 134 deletions.
62 changes: 36 additions & 26 deletions cli/download.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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
},
}
Expand All @@ -53,37 +54,58 @@ 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)
req.Header.Add("Content-Type", "application/json")

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 {
Expand All @@ -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
}
65 changes: 10 additions & 55 deletions cli/serve.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
package cli

import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/signal"
"path"
"strings"

"github.com/pkg/errors"
Expand Down Expand Up @@ -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")
Expand All @@ -123,66 +125,19 @@ 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 {
return "", errors.Wrap(err, "failed to create temp file")
}
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")
}
Expand Down
126 changes: 73 additions & 53 deletions cli/shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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...)
},
}

Expand All @@ -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()
}

0 comments on commit 1d4f417

Please sign in to comment.