diff --git a/cmd/deploy.go b/cmd/deploy.go index 48543185..d3dfb765 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -3,13 +3,17 @@ package cmd import ( "context" "fmt" + "os" "strconv" + "time" + "github.com/logrusorgru/aurora" "github.com/uselagoon/lagoon-cli/pkg/output" lclient "github.com/uselagoon/machinery/api/lagoon/client" "github.com/spf13/cobra" + lagoonssh "github.com/uselagoon/lagoon-cli/pkg/lagoon/ssh" "github.com/uselagoon/machinery/api/lagoon" "github.com/uselagoon/machinery/api/schema" ) @@ -47,6 +51,18 @@ use 'lagoon deploy latest' instead`, if err != nil { return err } + follow, err := cmd.Flags().GetBool("follow") + if err != nil { + return err + } + showPod, err := cmd.Flags().GetBool("show-pod") + if err != nil { + return err + } + showTimestamp, err := cmd.Flags().GetBool("show-timestamp") + if err != nil { + return err + } if err := requiredInputCheck("Project name", cmdProjectName, "Branch name", branch); err != nil { return err } @@ -85,6 +101,10 @@ use 'lagoon deploy latest' instead`, resultData := output.Result{Result: result.DeployEnvironmentBranch} r := output.RenderResult(resultData, outputOptions) fmt.Fprintf(cmd.OutOrStdout(), "%s", r) + + if follow { + return followDeployLogs(cmd, cmdProjectName, branch, resultData.Result, debug, showPod, showTimestamp) + } } return nil }, @@ -115,6 +135,18 @@ var deployPromoteCmd = &cobra.Command{ if err != nil { return err } + follow, err := cmd.Flags().GetBool("follow") + if err != nil { + return err + } + showPod, err := cmd.Flags().GetBool("show-pod") + if err != nil { + return err + } + showTimestamp, err := cmd.Flags().GetBool("show-timestamp") + if err != nil { + return err + } if err := requiredInputCheck("Project name", cmdProjectName, "Source environment", sourceEnvironment, "Destination environment", destinationEnvironment); err != nil { return err } @@ -150,6 +182,10 @@ var deployPromoteCmd = &cobra.Command{ resultData := output.Result{Result: result.DeployEnvironmentPromote} r := output.RenderResult(resultData, outputOptions) fmt.Fprintf(cmd.OutOrStdout(), "%s", r) + + if follow { + return followDeployLogs(cmd, cmdProjectName, destinationEnvironment, resultData.Result, debug, showPod, showTimestamp) + } } return nil }, @@ -175,6 +211,18 @@ This environment should already exist in lagoon. It is analogous with the 'Deplo if err != nil { return err } + follow, err := cmd.Flags().GetBool("follow") + if err != nil { + return err + } + showPod, err := cmd.Flags().GetBool("show-pod") + if err != nil { + return err + } + showTimestamp, err := cmd.Flags().GetBool("show-timestamp") + if err != nil { + return err + } buildVarStrings, err := cmd.Flags().GetStringArray("buildvar") if err != nil { @@ -213,6 +261,10 @@ This environment should already exist in lagoon. It is analogous with the 'Deplo resultData := output.Result{Result: result.DeployEnvironmentLatest} r := output.RenderResult(resultData, outputOptions) fmt.Fprintf(cmd.OutOrStdout(), "%s", r) + + if follow { + return followDeployLogs(cmd, cmdProjectName, cmdProjectEnvironment, resultData.Result, debug, showPod, showTimestamp) + } } return nil }, @@ -273,6 +325,19 @@ This pullrequest may not already exist as an environment in lagoon.`, if err != nil { return err } + follow, err := cmd.Flags().GetBool("follow") + if err != nil { + return err + } + showPod, err := cmd.Flags().GetBool("show-pod") + if err != nil { + return err + } + showTimestamp, err := cmd.Flags().GetBool("show-timestamp") + if err != nil { + return err + } + if yesNo(fmt.Sprintf("You are attempting to deploy pull request '%v' for project '%s', are you sure?", prNumber, cmdProjectName)) { current := lagoonCLIConfig.Current token := lagoonCLIConfig.Lagoons[current].Token @@ -302,6 +367,10 @@ This pullrequest may not already exist as an environment in lagoon.`, resultData := output.Result{Result: result.DeployEnvironmentPullrequest} r := output.RenderResult(resultData, outputOptions) fmt.Fprintf(cmd.OutOrStdout(), "%s", r) + + if follow { + return followDeployLogs(cmd, cmdProjectName, fmt.Sprintf("pr-%d", prNumber), resultData.Result, debug, showPod, showTimestamp) + } } return nil }, @@ -315,16 +384,25 @@ func init() { const returnDataUsageText = "Returns the build name instead of success text" deployLatestCmd.Flags().Bool("returndata", false, returnDataUsageText) + deployLatestCmd.Flags().Bool("follow", false, "Follow the deploy logs") + deployLatestCmd.Flags().Bool("show-pod", true, "show pod/container name prefix on log lines") + deployLatestCmd.Flags().Bool("show-timestamp", true, "show timestamp prefix on log lines") deployLatestCmd.Flags().StringArray("buildvar", []string{}, "Add one or more build variables to deployment (--buildvar KEY1=VALUE1 [--buildvar KEY2=VALUE2])") deployBranchCmd.Flags().StringP("branch", "b", "", "Branch name to deploy") deployBranchCmd.Flags().StringP("branch-ref", "r", "", "Branch ref to deploy") deployBranchCmd.Flags().Bool("returndata", false, returnDataUsageText) + deployBranchCmd.Flags().Bool("follow", false, "Follow the deploy logs") + deployBranchCmd.Flags().Bool("show-pod", true, "show pod/container name prefix on log lines") + deployBranchCmd.Flags().Bool("show-timestamp", true, "show timestamp prefix on log lines") deployBranchCmd.Flags().StringArray("buildvar", []string{}, "Add one or more build variables to deployment (--buildvar KEY1=VALUE1 [--buildvar KEY2=VALUE2])") deployPromoteCmd.Flags().StringP("destination", "d", "", "Destination environment name to create") deployPromoteCmd.Flags().StringP("source", "s", "", "Source environment name to use as the base to deploy from") deployPromoteCmd.Flags().Bool("returndata", false, returnDataUsageText) + deployPromoteCmd.Flags().Bool("follow", false, "Follow the deploy logs") + deployPromoteCmd.Flags().Bool("show-pod", true, "show pod/container name prefix on log lines") + deployPromoteCmd.Flags().Bool("show-timestamp", true, "show timestamp prefix on log lines") deployPromoteCmd.Flags().StringArray("buildvar", []string{}, "Add one or more build variables to deployment (--buildvar KEY1=VALUE1 [--buildvar KEY2=VALUE2])") deployPullrequestCmd.Flags().StringP("title", "t", "", "Pullrequest title") @@ -334,5 +412,91 @@ func init() { deployPullrequestCmd.Flags().StringP("head-branch-name", "H", "", "Pullrequest head branch name") deployPullrequestCmd.Flags().StringP("head-branch-ref", "M", "", "Pullrequest head branch reference hash") deployPullrequestCmd.Flags().Bool("returndata", false, returnDataUsageText) + deployPullrequestCmd.Flags().Bool("follow", false, "Follow the deploy logs") + deployPullrequestCmd.Flags().Bool("show-pod", true, "show pod/container name prefix on log lines") + deployPullrequestCmd.Flags().Bool("show-timestamp", true, "show timestamp prefix on log lines") deployPullrequestCmd.Flags().StringArray("buildvar", []string{}, "Add one or more build variables to deployment (--buildvar KEY1=VALUE1 [--buildvar KEY2=VALUE2])") } + +func followDeployLogs( + cmd *cobra.Command, + projectName, + environmentName, + buildName string, + debug, + showPod, + showTimestamp bool, +) error { + safeEnvName := makeSafe(shortenEnvironment(projectName, environmentName)) + sshHost, sshPort, username, _, err := getSSHHostPort(safeEnvName, debug) + if err != nil { + return fmt.Errorf("couldn't get SSH endpoint: %v", err) + } + ignoreHostKey, acceptNewHostKey := + lagoonssh.CheckStrictHostKey(strictHostKeyCheck) + sshConfig, closeSSHAgent, err := getSSHClientConfig( + username, + fmt.Sprintf("%s:%s", sshHost, sshPort), + ignoreHostKey, + acceptNewHostKey) + if err != nil { + return fmt.Errorf("couldn't get SSH client config: %v", err) + } + defer func() { + err = closeSSHAgent() + if err != nil { + fmt.Fprintf(os.Stderr, "error closing ssh agent:%v\n", err) + } + }() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + // start background ticker to close session when deploy completes + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + go func() { + defer cancel() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + validateToken(lagoonCLIConfig.Current) + current := lagoonCLIConfig.Current + token := lagoonCLIConfig.Lagoons[current].Token + lc := lclient.New( + lagoonCLIConfig.Lagoons[current].GraphQL, + lagoonCLIVersion, + lagoonCLIConfig.Lagoons[current].Version, + &token, + debug) + // ignore errors here since we can't really do anything about them + deployment, _ := lagoon.GetDeploymentByName( + ctx, cmdProjectName, cmdProjectEnvironment, buildName, false, lc) + if deployment.Completed != "" && deployment.Status != "running" { + var status string + switch deployment.Status { + case "complete": + status = "complete ✅" + case "failed": + status = "failed ❌" + case "cancelled": + status = "cancelled 🛑" + default: + status = deployment.Status + } + fmt.Fprintf( + cmd.OutOrStdout(), + "Deployment %s finished with status: %s\n", + aurora.Yellow(buildName), + status) + return + } + } + } + }() + fmt.Fprintf(cmd.OutOrStdout(), "Streaming deploy logs...\n") + return lagoonssh.LogStream(ctx, sshConfig, sshHost, sshPort, []string{ + "lagoonSystem=build", + "logs=tailLines=32,follow", + }, showPod, showTimestamp) +} diff --git a/cmd/logs.go b/cmd/logs.go index e69993ea..474b624c 100644 --- a/cmd/logs.go +++ b/cmd/logs.go @@ -16,40 +16,80 @@ import ( "golang.org/x/crypto/ssh" ) +const ( + buildNoOptDefVal = "all_builds" + taskNoOptDefVal = "all_tasks" +) + var ( // connTimeout is the network connection timeout used for SSH connections and // calls to the Lagoon API. connTimeout = 8 * time.Second // these variables are assigned in init() to flag values - logsService string - logsContainer string - logsTailLines uint - logsFollow bool + logsService string + logsContainer string + logsBuild string + logsTask string + logsTailLines uint + logsFollow bool + logsShowPod bool + logsShowTimestamp bool ) func init() { logsCmd.Flags().StringVarP(&logsService, "service", "s", "", "specify a specific service name") logsCmd.Flags().StringVarP(&logsContainer, "container", "c", "", "specify a specific container name") + logsCmd.Flags().StringVarP(&logsBuild, "build", "b", "", "specify build logs, with an optional specific build name") + logsCmd.Flags().Lookup("build").NoOptDefVal = buildNoOptDefVal + logsCmd.Flags().StringVarP(&logsTask, "task", "t", "", "specify task logs, with an optional specific task name") + logsCmd.Flags().Lookup("task").NoOptDefVal = taskNoOptDefVal logsCmd.Flags().UintVarP(&logsTailLines, "lines", "n", 32, "the number of lines to return for each container") logsCmd.Flags().BoolVarP(&logsFollow, "follow", "f", false, "continue outputting new lines as they are logged") + logsCmd.Flags().BoolVarP(&logsShowPod, "show-pod", "", true, "show pod/container name prefix on log lines") + logsCmd.Flags().BoolVarP(&logsShowTimestamp, "show-timestamp", "", true, "show timestamp prefix on log lines") + logsCmd.MarkFlagsMutuallyExclusive("service", "build", "task") + logsCmd.MarkFlagsMutuallyExclusive("container", "build", "task") } -func generateLogsCommand(service, container string, lines uint, - follow bool) ([]string, error) { +func generateLogsCommand( + service, + container, + build, + task string, + lines uint, + follow bool, +) ([]string, error) { var argv []string - if service == "" { - return nil, fmt.Errorf("empty service name") - } - if unsafeRegex.MatchString(service) { - return nil, fmt.Errorf("service name contains invalid characters") + if service != "" { + if unsafeRegex.MatchString(service) { + return nil, fmt.Errorf("service name contains invalid characters") + } + argv = append(argv, "service="+service) } - argv = append(argv, "service="+service) if container != "" { if unsafeRegex.MatchString(container) { return nil, fmt.Errorf("container name contains invalid characters") } argv = append(argv, "container="+container) } + if build != "" { + argv = append(argv, "lagoonSystem=build") + if build != buildNoOptDefVal { + if unsafeRegex.MatchString(build) { + return nil, fmt.Errorf("build name contains invalid characters") + } + argv = append(argv, "name="+build) + } + } + if task != "" { + argv = append(argv, "lagoonSystem=task") + if task != taskNoOptDefVal { + if unsafeRegex.MatchString(task) { + return nil, fmt.Errorf("task name contains invalid characters") + } + argv = append(argv, "name="+task) + } + } logsCmd := fmt.Sprintf("logs=tailLines=%d", lines) if follow { logsCmd += ",follow" @@ -155,8 +195,8 @@ var logsCmd = &cobra.Command{ return fmt.Errorf("couldn't get debug value: %v", err) } ignoreHostKey, acceptNewHostKey := lagoonssh.CheckStrictHostKey(strictHostKeyCheck) - argv, err := generateLogsCommand(logsService, logsContainer, logsTailLines, - logsFollow) + argv, err := generateLogsCommand( + logsService, logsContainer, logsBuild, logsTask, logsTailLines, logsFollow) if err != nil { return fmt.Errorf("couldn't generate logs command: %v", err) } @@ -179,8 +219,11 @@ var logsCmd = &cobra.Command{ fmt.Fprintf(os.Stderr, "error closing ssh agent:%v\n", err) } }() + ctx, cancel := context.WithCancel(context.TODO()) + defer cancel() // start SSH log streaming session - err = lagoonssh.LogStream(sshConfig, sshHost, sshPort, argv) + err = lagoonssh.LogStream( + ctx, sshConfig, sshHost, sshPort, argv, logsShowPod, logsShowTimestamp) if err != nil { output.RenderError(err.Error(), outputOptions) switch e := err.(type) { diff --git a/pkg/lagoon/ssh/main.go b/pkg/lagoon/ssh/main.go index e6b5a680..0b194a90 100644 --- a/pkg/lagoon/ssh/main.go +++ b/pkg/lagoon/ssh/main.go @@ -4,7 +4,9 @@ package ssh import ( "bufio" "bytes" + "context" "fmt" + "io" "net" "os" "path" @@ -15,10 +17,61 @@ import ( "golang.org/x/term" ) +// processLogs filters log components based on the show* flags. +func processLogs( + ctx context.Context, + w io.Writer, + r io.Reader, + showPod, + showTimestamp bool, +) { + scanner := bufio.NewScanner(r) + for scanner.Scan() { + select { + case <-ctx.Done(): + return + default: + line := scanner.Text() + if showPod && showTimestamp { + // no formatting required: print whole line + fmt.Fprintln(w, line) + continue + } + // split log line: [pod/container] timestamp message + parts := strings.SplitN(line, " ", 3) + if len(parts) < 3 { + // unexpected log format: ignore format directives and print whole line + fmt.Fprintln(w, line) + continue + } + // format line based on flags + var sb strings.Builder + if showPod { + sb.WriteString(parts[0]) + sb.WriteByte(' ') + } + if showTimestamp { + sb.WriteString(parts[1]) + sb.WriteByte(' ') + } + sb.WriteString(parts[2]) + fmt.Fprintln(w, sb.String()) + } + } +} + // LogStream connects to host:port using the given config, and executes the // argv command. It does not request a PTY, and instead just streams the // response to the attached terminal. argv should contain a logs=... argument. -func LogStream(config *ssh.ClientConfig, host, port string, argv []string) error { +func LogStream( + ctx context.Context, + config *ssh.ClientConfig, + host, + port string, + argv []string, + showPod, + showTimestamp bool, +) error { // https://stackoverflow.com/a/37088088 client, err := ssh.Dial("tcp", host+":"+port, config) if err != nil { @@ -29,14 +82,27 @@ func LogStream(config *ssh.ClientConfig, host, port string, argv []string) error return fmt.Errorf("couldn't create SSH session: %v", err) } defer session.Close() - session.Stdout = os.Stdout + // close the session when the context is cancelled + go func() { + <-ctx.Done() + session.Close() + }() + sessStdout, err := session.StdoutPipe() + if err != nil { + return fmt.Errorf("couldn't set up session stdout pipe: %v", err) + } + go processLogs(ctx, os.Stdout, sessStdout, showPod, showTimestamp) session.Stderr = os.Stderr - session.Stdin = os.Stdin err = session.Start(strings.Join(argv, " ")) if err != nil { return fmt.Errorf("couldn't start SSH session: %v", err) } - return session.Wait() + err = session.Wait() + if ctx.Err() == nil { + // context not done, so return session.Wait() error + return err + } + return nil } // InteractiveSSH . diff --git a/pkg/output/main.go b/pkg/output/main.go index d4beceff..402282d8 100644 --- a/pkg/output/main.go +++ b/pkg/output/main.go @@ -66,7 +66,7 @@ func RenderError(errorMsg string, opts Options) { } fmt.Fprintf(os.Stderr, "%s", RenderJSON(jsonData, opts)) } else { - fmt.Fprintf(os.Stderr, "Error: %s", trimQuotes(errorMsg)) + fmt.Fprintf(os.Stderr, "Error: %s\n", trimQuotes(errorMsg)) } } diff --git a/pkg/output/main_test.go b/pkg/output/main_test.go index b6d97b13..f5aaf24f 100644 --- a/pkg/output/main_test.go +++ b/pkg/output/main_test.go @@ -44,7 +44,7 @@ func TestRenderJSON(t *testing.T) { func TestRenderError(t *testing.T) { var testData = `Error Message` - var testSuccess = `Error: Error Message` + var testSuccess = "Error: Error Message\n" outputOptions := Options{ Header: false,