diff --git a/cmd/root.go b/cmd/root.go index fd96097d..ee070745 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -57,6 +57,9 @@ func Init(version string, commitHash string, buildDate string, pipelineVersion s buildDate, pipelineVersion, )) + + cli := cli.NewCli(os.Stdout, version) + command.AddCommands(rootCmd, cli) } // GetRootCommand returns the cli root command @@ -103,10 +106,6 @@ func init() { viper.SetDefault("cloudinfo.basepath", "https://try.pipeline.banzai.cloud/cloudinfo/api/v1") viper.BindEnv("cloudinfo.basepath", "BANZAI_CLOUDINFO_BASEPATH") viper.SetDefault("telescopes.basepath", "https://try.pipeline.banzai.cloud/recommender/api/v1") - - cli := cli.NewCli(os.Stdout) - - command.AddCommands(rootCmd, cli) } // initConfig reads in config file and ENV variables if set. diff --git a/go.mod b/go.mod index bacd07e9..0532a8b0 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/mitchellh/mapstructure v1.1.2 github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4 github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 // indirect + github.com/shirou/gopsutil v2.20.7+incompatible github.com/sirupsen/logrus v1.4.2 github.com/spf13/cast v1.3.0 github.com/spf13/cobra v1.0.0 @@ -28,6 +29,7 @@ require ( github.com/stretchr/testify v1.4.0 github.com/ttacon/chalk v0.0.0-20140724125006-76b3c8b611de golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 + golang.org/x/sys v0.0.0-20200819171115-d785dc25833f // indirect golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 // indirect gopkg.in/square/go-jose.v2 v2.3.1 // indirect gopkg.in/yaml.v2 v2.2.8 diff --git a/go.sum b/go.sum index d9f99046..b19387ab 100644 --- a/go.sum +++ b/go.sum @@ -211,6 +211,8 @@ github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40T github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shirou/gopsutil v2.20.7+incompatible h1:Ymv4OD12d6zm+2yONe39VSmp2XooJe8za7ngOLW/o/w= +github.com/shirou/gopsutil v2.20.7+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= @@ -307,6 +309,8 @@ golang.org/x/sys v0.0.0-20190530182044-ad28b68e88f1/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20191022100944-742c48ecaeb7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42 h1:vEOn+mP2zCOVzKckCZy6YsCtDblrpj/w7B9nxGNELpg= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200819171115-d785dc25833f h1:KJuwZVtZBVzDmEDtB2zro9CXkD9O0dpCv4o2LHbQIAw= +golang.org/x/sys v0.0.0-20200819171115-d785dc25833f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 7224007d..33c2e786 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -60,6 +60,7 @@ type Cli interface { Context() Context OutputFormat() string Home() string // Home is the path to the .banzai directory of the user + Version() string } type Context interface { @@ -77,11 +78,13 @@ type banzaiCli struct { cloudinfoClientOnce sync.Once telescopesClient *telescopes.APIClient telescopesClientOnce sync.Once + version string } -func NewCli(out io.Writer) Cli { +func NewCli(out io.Writer, version string) Cli { return &banzaiCli{ - out: out, + out: out, + version: version, } } @@ -411,3 +414,7 @@ func (c *banzaiCli) save() { log.Fatalf("failed to write config: %v", err) } } + +func (c *banzaiCli) Version() string { + return c.version +} diff --git a/internal/cli/command/controlplane/cmd.go b/internal/cli/command/controlplane/cmd.go index c6f14036..e54ccadd 100644 --- a/internal/cli/command/controlplane/cmd.go +++ b/internal/cli/command/controlplane/cmd.go @@ -31,6 +31,7 @@ func NewControlPlaneCommand(banzaiCli cli.Cli) *cobra.Command { NewUpCommand(banzaiCli), NewDownCommand(banzaiCli), NewInitCommand(banzaiCli), + NewDebugCommand(banzaiCli), ) return cmd diff --git a/internal/cli/command/controlplane/debug.go b/internal/cli/command/controlplane/debug.go new file mode 100644 index 00000000..77c11aa7 --- /dev/null +++ b/internal/cli/command/controlplane/debug.go @@ -0,0 +1,390 @@ +// Copyright © 2020 Banzai Cloud +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controlplane + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "fmt" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "emperror.dev/errors" + "github.com/banzaicloud/banzai-cli/internal/cli" + "github.com/shirou/gopsutil/host" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "gopkg.in/yaml.v2" +) + +type debugOptions struct { + outputFile string + *cpContext +} + +func (d *debugOptions) Init() error { + if err := d.cpContext.Init(); err != nil { + return err + } + return d.setOutputFilename() +} + +func (d *debugOptions) setOutputFilename() error { + value := d.outputFile + + base := d.cpContext.workspace + if filepath.IsAbs(value) { + base = "/" + } else if strings.HasPrefix(value, "./") { + base, _ = os.Getwd() + } + value = filepath.Clean(filepath.Join(base, value)) + + stat, err := os.Stat(value) + if err == nil { + if stat.IsDir() { + value = filepath.Join( + value, + fmt.Sprintf("pipeline-debug-bundle-%s.tgz", time.Now().Format("20060102-1504"))) + } else { + return errors.New(fmt.Sprintf("output file named %q already exists", value)) + } + } + d.outputFile = filepath.Clean(value) + return nil +} + +// NewDebugCommand creates a new cobra.Command for `banzai pipeline debug`. +func NewDebugCommand(banzaiCli cli.Cli) *cobra.Command { + options := debugOptions{} + + cmd := &cobra.Command{ + Use: "debug", + Short: "Generate debug bundle", + Long: "Collect and package status information and configuration data required by the Banzai Cloud support team to resolve issues on a remote Pipeline installation", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceErrors = true + cmd.SilenceUsage = true + + return runDebug(options, banzaiCli) + }, + } + + options.cpContext = NewContext(cmd, banzaiCli) + flags := cmd.Flags() + flags.StringVarP(&options.outputFile, "output-file", "O", "", "Name or path to output file relative to the workspace (prefix with ./ for current working directory; default: pipeline-debug-bundle-DATE.tgz)") + + return cmd +} + +type debugMetadata struct { + Timestamp time.Time + Host host.InfoStat + CLIVersion string + WorkspacePath string + Workspaces []string + InstallerImages []string + DockerVersion string +} + +type loggerHook struct { + *log.Logger +} + +func (l loggerHook) Fire(e *log.Entry) error { + l.Log(e.Level, e.Message) + return nil +} + +func (loggerHook) Levels() []log.Level { + return log.AllLevels +} + +func runDebug(options debugOptions, banzaiCli cli.Cli) error { + logger := log.StandardLogger() // TODO add logger to cli.Cli + logHandler := errorLogger{logger} // error handler for best-effort operations + + logBuffer := new(bytes.Buffer) + bufferLogger := log.New() + bufferLogger.SetOutput(logBuffer) + logger.AddHook(loggerHook{bufferLogger}) + + if err := options.Init(); err != nil { + return err + } + + if !options.valuesExists() { + return errors.New(fmt.Sprintf("%q is not an initialized workspace (no values file found)", options.workspace)) + } + + logger.Debugf("creating %q", options.outputFile) + f, err := os.Create(options.outputFile) + if err != nil { + return errors.WrapIff(err, "failed to create archive at %q", options.outputFile) + } + defer func() { logHandler.Handle(f.Close()) }() + + gzWriter := gzip.NewWriter(f) + defer func() { logHandler.Handle(gzWriter.Close()) }() + + tm := newTarManager(tar.NewWriter(gzWriter), logHandler, logger, "pipeline-debug-bundle") + defer tm.Close() + + meta := debugMetadata{ + Timestamp: time.Now(), + CLIVersion: banzaiCli.Version(), + WorkspacePath: options.workspace, + Workspaces: strings.Split(simpleCommand("ls ~/.banzai/pipeline"), "\n"), + InstallerImages: strings.Split(simpleCommand("docker image list | grep pipeline-installer"), "\n"), + DockerVersion: simpleCommand("docker --version"), + } + + if info, err := host.Info(); err == nil { + meta.Host = *info + } + + metaBytes, _ := yaml.Marshal(meta) + tm.AddFile("meta.yaml", metaBytes) + + tm.CopyFile("pipeline/values.yaml", options.valuesPath()) + tm.AddFile("pipeline/files.txt", simpleCommand("find", options.workspace, "-ls")) + + // run some terraform diagnostics commands to catch their output in the logs folder + var values map[string]interface{} + logHandler.Handle(options.readValues(&values)) + _, env, err := getImageMetadata(options.cpContext, values, true) + logHandler.Handle(err) + logHandler.Handle(runTerraform("plan", options.cpContext, env)) + logHandler.Handle(runTerraform("graph", options.cpContext, env)) + logHandler.Handle(runTerraform("state list", options.cpContext, env)) + + tm.Cd("/pipeline/installer-logs") + logDir, logFiles, err := options.listLogs() + if err != nil { + logHandler.Handle(errors.WrapIf(err, "listing log files failed")) + } else { + for _, file := range logFiles { + tm.CopyFile(file, filepath.Join(logDir, file)) + } + } + + if !options.kubeconfigExists() { + logger.Errorf("No kubeconfig found in the workspace. This means that the debug bundle will contain very limited information. If the cluster is running, please create the support bundle from a workspace where `banzai pipeline up` has been run.") + } else { + tm.Cd("/pipeline/resources/banzaicloud") + for _, resource := range []string{ + "pods", "services", "ingresses", "persistentvolumes", "persistentvolumeclaims", "events", + "clusterflows,clusteroutputs,flows,loggings,outputs", + } { + tm.AddFile(resource+".yaml", combineOutput(runContainerCommand(options.cpContext, []string{"kubectl", "get", resource, "-oyaml", "-nbanzaicloud"}, env))) + } + for _, resource := range []string{"secrets", "configmaps"} { + tm.AddFile(resource+".txt", combineOutput(runContainerCommand(options.cpContext, []string{"kubectl", "get", resource, "-owide", "-nbanzaicloud"}, env))) + } + + tm.AddFile("helm_list.txt", combineOutput(runContainerCommand(options.cpContext, []string{"helm", "list", "--namespace", "banzaicloud", "--all"}, env))) + + tm.Cd("/pipeline/logs/banzaicloud") + pods, err := runContainerCommand(options.cpContext, []string{"kubectl", "get", "pods", "-oname", "-nbanzaicloud"}, env) + if err != nil { + logHandler.Handle(errors.WrapIf(err, "failed to list pods")) + } else { + for _, pod := range strings.Split(strings.TrimSpace(pods), "\n") { + pod = strings.TrimPrefix(strings.TrimSpace(pod), "pod/") + tm.AddFile(fmt.Sprintf("%s.log", pod), combineOutput(runContainerCommand(options.cpContext, []string{"kubectl", "logs", "--namespace", "banzaicloud", pod, "--all-containers"}, env))) + } + } + } + + tm.Cd("/") + tm.AddFile("meta.log", logBuffer) + + log.Infof("debug bundle has been written to %q", options.outputFile) + return nil +} + +// simpleCommand runs the given shell command, and returns its outputs, an error message or both +func simpleCommand(command string, args ...string) string { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "sh", "-c", command) + if len(args) > 0 { + cmd = exec.CommandContext(ctx, command, args...) + } + out, err := cmd.CombinedOutput() + output := strings.TrimSpace(string(out)) + return combineOutput(output, err) +} + +type errorHandler interface { + Handle(err error) +} + +type errorLogger struct { + logger +} + +func (e errorLogger) Handle(err error) { + if err != nil { + e.logger.Error(err.Error()) + } +} + +type logger interface { + Error(args ...interface{}) + Errorf(format string, args ...interface{}) + Info(args ...interface{}) + Infof(format string, args ...interface{}) + Debug(args ...interface{}) + Debugf(format string, args ...interface{}) +} + +type tarManager struct { + tarWriter *tar.Writer + errorHandler errorHandler + logger logger + directories map[string]bool + baseDir string + cwd string + dirPerm int64 + filePerm int64 + time func() time.Time +} + +func newTarManager(tarWriter *tar.Writer, errorHandler errorHandler, logger logger, baseDir string) tarManager { + tm := tarManager{ + tarWriter: tarWriter, + errorHandler: errorHandler, + logger: logger, + directories: make(map[string]bool), + baseDir: filepath.Clean(filepath.Join("/", baseDir)), + dirPerm: 0777, + filePerm: 0666, + time: time.Now, + } + tm.Cd("/") + return tm +} + +// Cd changes the working directory to the given folder in the tar archive. +// Directories are created implicitly. You can give relative path to the +// current working directory, or an absolute path starting with a / +// (where / refers to the baseDir). +func (t *tarManager) Cd(dir string) { + path := t.path(dir) + t.Mkdir(path) + t.cwd = path +} + +func (t *tarManager) path(dir string) string { + if filepath.IsAbs(dir) { + return filepath.Clean(dir) + } + return filepath.Clean(filepath.Join(t.cwd, dir)) +} + +// Mkdir creates the given directory and its parents when any of them are missing +func (t *tarManager) Mkdir(dir string) { + path := t.path(dir) + if t.directories[path] { + return + } + parent := filepath.Clean(filepath.Join(dir, "..")) + if parent != "/" { + t.Mkdir(parent) + } + t.addDir(path) + t.directories[path] = true +} + +func (t tarManager) addDir(name string) { + // trailing slash makes this a directory, relative paths are normally preferred in the archive + name = strings.TrimLeft(filepath.Clean(filepath.Join(t.baseDir, name))+"/", "/") + err := t.tarWriter.WriteHeader(&tar.Header{Name: name, Mode: t.dirPerm, ModTime: t.time(), ChangeTime: t.time()}) + if err != nil { + t.errorHandler.Handle(errors.WrapIf(err, "failed to create directory in archive")) + } else { + t.logger.Debugf("added directory %q", name) + } +} + +func (t tarManager) addFile(name, content string) (err error) { + // relative paths are normally preferred in the archive + name = strings.TrimLeft(filepath.Clean(filepath.Join(t.baseDir, t.path(name))), "/") + bytes := []byte(content) + err = t.tarWriter.WriteHeader(&tar.Header{Name: name, Mode: t.filePerm, ModTime: t.time(), ChangeTime: t.time(), Size: int64(len(bytes))}) + if err != nil { + return err + } + + n, err := t.tarWriter.Write(bytes) + t.logger.Debugf("%d bytes written to %q", n, name) + return err +} + +// AddFile adds the file with the given name and content to the archive. The +// name is relative to the working directory, but it can also be an +// absolute path (starting from the base directory). +// Directories are created implicitly. +func (t *tarManager) AddFile(name string, content interface{}) { + path := t.path(name) + dir, _ := filepath.Split(path) + t.Mkdir(dir) + + var str string + switch v := content.(type) { + case string: + str = v + case []byte: + str = string(v) + case interface{ String() string }: + str = v.String() + default: + str = fmt.Sprint(v) + } + err := t.addFile(path, str) + if err != nil { + t.errorHandler.Handle(errors.WrapIff(err, "failed to add %q", path)) + } else { + t.logger.Infof("file added to archive: %q", path) + } +} + +// CopyFile copies the contents of the local file at the given path as name to the archive. +func (t tarManager) CopyFile(name, path string) { + content, err := ioutil.ReadFile(path) + if err != nil { + t.errorHandler.Handle(err) + } + + t.AddFile(name, content) +} + +func (t tarManager) Close() { + err := t.tarWriter.Close() + if err != nil { + t.errorHandler.Handle(errors.Wrap(err, "closing tar archive")) + } else { + t.logger.Debug("closed tar archive") + } +} diff --git a/internal/cli/command/controlplane/exec.go b/internal/cli/command/controlplane/exec.go index cf5b8a9c..4a7fe33f 100644 --- a/internal/cli/command/controlplane/exec.go +++ b/internal/cli/command/controlplane/exec.go @@ -48,27 +48,37 @@ func runTerraform(command string, options *cpContext, env map[string]string, tar cmdEnv[k] = v } - cmd := []string{"terraform", command} + cmd := []string{"terraform"} + for _, word := range strings.Split(command, " ") { + cmd = append(cmd, word) + } - if command != "init" { - cmd = append(cmd, []string{ - "-var", "workdir=/workspace", - fmt.Sprintf("-refresh=%v", options.refreshState), - }...) + switch command { + case "state list": // nop + case "graph": // nop + + case "init": + cmd = append(cmd, "-input=false", "-force-copy") + + if fileExists(filepath.Join(options.workspace, "state.tfvars")) { + cmd = append(cmd, "-backend-config", "/workspace/state.tfvars") + } + case "apply": if options.AutoApprove() { cmd = append(cmd, "-auto-approve") } + fallthrough + + default: + cmd = append(cmd, []string{ + "-var", "workdir=/workspace", + fmt.Sprintf("-refresh=%v", options.refreshState), + }...) for _, target := range targets { cmd = append(cmd, "-target", target) } - } else { - cmd = append(cmd, "-input=false", "-force-copy") - - if fileExists(filepath.Join(options.workspace, "state.tfvars")) { - cmd = append(cmd, "-backend-config", "/workspace/state.tfvars") - } } return runTerraformCommandGeneric(options, cmd, cmdEnv) @@ -188,6 +198,55 @@ func pullImage(options *cpContext, _ cli.Cli) error { return cmd.Run() } +func combineOutput(output string, err error) string { + if err != nil { + if output != "" { + output += "\n\n" + } + output += err.Error() + } + return output +} + +func runContainerCommand(options *cpContext, cmd []string, cmdEnv map[string]string) (string, error) { + buffer := new(bytes.Buffer) + cmdOpt := func(cmd *exec.Cmd) error { + cmd.Stdout = buffer + cmd.Stderr = buffer + cmd.Env = os.Environ() + for key, value := range cmdEnv { + cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", key, value)) + } + return nil + } + + var err error + switch options.containerRuntime { + case runtimeExec: + err = runLocally(cmd, cmdOpt) + case runtimeDocker: + args := []string{ + "-v", fmt.Sprintf("%s:/workspace", options.workspace), + } + for key := range cmdEnv { + args = append(args, "-e", key) + } + err = runDocker(cmd, options, args, cmdOpt) + case runtimeContainerd: + args := []string{ + "--mount", fmt.Sprintf("type=bind,src=%s,dst=/workspace,options=rbind:rw", options.workspace), + } + for key, value := range cmdEnv { + args = append(args, "--env", fmt.Sprintf("%s=%s", key, value)) // env propagation does not work with ctr + } + err = runContainer(cmd, options, args, cmdOpt) + default: + err = errors.Errorf("unknown container runtime: %q", options.containerRuntime) + } + + return buffer.String(), err +} + func readFilesFromContainerToMemory(options *cpContext, source string) (map[string][]byte, error) { // create gzipped archive (cz) and follow symlinks (h) cmd := []string{"sh", "-c", fmt.Sprintf("tar czh %s | base64", source)} @@ -242,14 +301,28 @@ func untarInMemory(reader io.Reader) (map[string][]byte, error) { } func runTerraformCommandGeneric(options *cpContext, cmd []string, cmdEnv map[string]string) error { + logFile, err := options.createLog(cmd...) + if err != nil { + return errors.WrapIf(err, "failed to write output logs") + } + if logFile != nil { + defer logFile.Close() + } + cmdOpt := func(cmd *exec.Cmd) error { cmd.Env = os.Environ() for key, value := range cmdEnv { cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", key, value)) } cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + if options.containerRuntime == runtimeContainerd || logFile == nil { + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + } else { + cmd.Stdout = io.MultiWriter(logFile, os.Stdout) + cmd.Stderr = io.MultiWriter(logFile, os.Stderr) + } + return nil } @@ -287,7 +360,26 @@ func runTerraformCommandGeneric(options *cpContext, cmd []string, cmdEnv map[str for key, value := range cmdEnv { args = append(args, "--env", fmt.Sprintf("%s=%s", key, value)) // env propagation does not work with ctr } - return runContainer(cmd, options, args, cmdOpt) + + var cmdLine string + for _, word := range cmd { + cmdLine += fmt.Sprintf("%q ", word) + } + cmdLine += "2>&1 | tee /workspace/.out" + cmd = []string{"sh", "-o", "pipefail", "-c", cmdLine} + + containerErr := runContainer(cmd, options, args, cmdOpt) + + outpath := filepath.Join(options.workspace, ".out") + if logFile != nil { + f, err := os.Open(outpath) + if err == nil { + _, _ = io.Copy(logFile, f) + _ = f.Close() + } + } + _ = os.Remove(outpath) + return containerErr default: return errors.Errorf("unknown container runtime: %q", options.containerRuntime) } diff --git a/internal/cli/command/controlplane/installer_options.go b/internal/cli/command/controlplane/installer_options.go index cadb46f6..289e12ca 100644 --- a/internal/cli/command/controlplane/installer_options.go +++ b/internal/cli/command/controlplane/installer_options.go @@ -16,11 +16,13 @@ package controlplane import ( "fmt" + "io" "io/ioutil" "os" "path/filepath" "strings" "sync" + "time" "emperror.dev/errors" "github.com/banzaicloud/banzai-cli/internal/cli" @@ -39,6 +41,7 @@ const ( traefikAddressFilename = "traefik-address" externalAddressFilename = "external-address" tfstateFilename = "terraform.tfstate" + logsDir = "logs" defaultImage = "docker.io/banzaicloud/pipeline-installer" latestTag = "latest" ) @@ -56,6 +59,7 @@ type cpContext struct { installerPulled *sync.Once installerPullResult error flags *pflag.FlagSet + logOutput bool } func NewContext(cmd *cobra.Command, banzaiCli cli.Cli) *cpContext { @@ -69,6 +73,7 @@ func NewContext(cmd *cobra.Command, banzaiCli cli.Cli) *cpContext { ctx.flags.StringVar(&ctx.installerImageRepo, "image", defaultImage, "Name of Docker image repository to use") ctx.flags.BoolVar(&ctx.pullInstaller, "image-pull", true, "Pull installer image even if it's present locally") ctx.flags.BoolVar(&ctx.autoApprove, "auto-approve", true, "Automatically approve the changes to deploy") + ctx.flags.BoolVar(&ctx.logOutput, "log-output", true, "Log output of terraform calls") ctx.flags.StringVar(&ctx.workspace, "workspace", "", "Name of directory for storing the applied configuration and deployment status") ctx.flags.StringVar(&ctx.containerRuntime, "container-runtime", "auto", `Run the terraform command with "docker", "containerd" (crictl) or "exec" (execute locally)`) ctx.flags.BoolVar(&ctx.refreshState, "refresh-state", true, "Refresh terraform state for each run (turn off to save time during development)") @@ -239,6 +244,47 @@ func (c *cpContext) readEc2Host() (string, error) { return strings.Trim(string(bytes), "\n"), nil } +func (c *cpContext) logDir() (string, error) { + dir := filepath.Join(c.workspace, logsDir) + if err := os.MkdirAll(dir, 0777); err != nil { + return "", errors.WrapIf(err, "failed to create log directory") + } + + return dir, nil +} + +func (c *cpContext) createLog(nameParts ...string) (io.WriteCloser, error) { + if !c.logOutput { + return nil, nil + } + + logdir, err := c.logDir() + if err != nil { + return nil, err + } + + ts, _ := time.Now().MarshalText() + name := fmt.Sprintf("%s-%s.log", ts, strings.ReplaceAll(strings.Join(nameParts, "-"), "/", "")) + return os.Create(filepath.Join(logdir, name)) +} + +func (c *cpContext) listLogs() (dir string, out []string, err error) { + dir, err = c.logDir() + if err != nil { + return "", nil, err + } + + files, err := ioutil.ReadDir(dir) + if err != nil { + return "", nil, errors.Wrap(err, "failed to list logs") + } + + for _, file := range files { + out = append(out, file.Name()) + } + return dir, out, nil +} + // Init completes the cp context from the options, env vars, and if possible from the user func (c *cpContext) Init() error { if c.workspace == "" {