From ad254f4003b830b8774bf7694ec755f73a927082 Mon Sep 17 00:00:00 2001 From: Scott Leggett Date: Thu, 20 Jun 2024 08:15:50 +0800 Subject: [PATCH] feat: add support for streaming container logs from services (#285) * chore: refactor unsafe regex for easier reuse * feat: add logs command * chore: add note about log endpoint in dial error for logstream * chore: default ssh-portal support * chore: use machinery --------- Co-authored-by: shreddedbacon --- cmd/helpers.go | 8 +- cmd/helpers_test.go | 5 ++ cmd/logs.go | 175 +++++++++++++++++++++++++++++++++++++++++ cmd/root.go | 2 + pkg/lagoon/ssh/main.go | 26 ++++++ 5 files changed, 211 insertions(+), 5 deletions(-) create mode 100644 cmd/logs.go diff --git a/cmd/helpers.go b/cmd/helpers.go index 8eb9433a..72cc9031 100644 --- a/cmd/helpers.go +++ b/cmd/helpers.go @@ -13,13 +13,11 @@ import ( "github.com/spf13/pflag" ) +var unsafeRegex = regexp.MustCompile(`[^0-9a-z-]`) + // makeSafe ensures that any string is dns safe func makeSafe(in string) string { - out := regexp.MustCompile(`[^0-9a-z-]`).ReplaceAllString( - strings.ToLower(in), - "$1-$2", - ) - return out + return unsafeRegex.ReplaceAllString(strings.ToLower(in), "$1-$2") } // shortenEnvironment shortens the environment name down the same way that Lagoon does diff --git a/cmd/helpers_test.go b/cmd/helpers_test.go index df1ffda6..762d632a 100644 --- a/cmd/helpers_test.go +++ b/cmd/helpers_test.go @@ -26,6 +26,11 @@ func Test_makeSafe(t *testing.T) { in: "Feature-Branch", want: "feature-branch", }, + { + name: "space in name", + in: "My Project", + want: "my-project", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/cmd/logs.go b/cmd/logs.go new file mode 100644 index 00000000..304835ee --- /dev/null +++ b/cmd/logs.go @@ -0,0 +1,175 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "path" + "time" + + "github.com/spf13/cobra" + lagoonssh "github.com/uselagoon/lagoon-cli/pkg/lagoon/ssh" + "github.com/uselagoon/lagoon-cli/pkg/output" + "github.com/uselagoon/machinery/api/lagoon" + lclient "github.com/uselagoon/machinery/api/lagoon/client" + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/knownhosts" +) + +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 +) + +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().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") +} + +func generateLogsCommand(service, container 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") + } + 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) + } + logsCmd := fmt.Sprintf("logs=tailLines=%d", lines) + if follow { + logsCmd += ",follow" + } + argv = append(argv, logsCmd) + return argv, nil +} + +func getSSHHostPort(environmentName string, debug bool) (string, string, error) { + current := lagoonCLIConfig.Current + // set the default ssh host and port to the core ssh endpoint + sshHost := lagoonCLIConfig.Lagoons[current].HostName + sshPort := lagoonCLIConfig.Lagoons[current].Port + token := lagoonCLIConfig.Lagoons[current].Token + + // get SSH Portal endpoint if required + lc := lclient.New( + lagoonCLIConfig.Lagoons[current].GraphQL, + lagoonCLIVersion, + lagoonCLIConfig.Lagoons[current].Version, + &token, + debug) + ctx, cancel := context.WithTimeout(context.Background(), connTimeout) + defer cancel() + project, err := lagoon.GetSSHEndpointsByProject(ctx, cmdProjectName, lc) + if err != nil { + return "", "", fmt.Errorf("couldn't get SSH endpoint by project: %v", err) + } + // check all the environments for this project + for _, env := range project.Environments { + // if the env name matches the requested environment then check if the deploytarget supports regional ssh endpoints + if env.Name == environmentName { + // if the deploytarget supports regional endpoints, then set these as the host and port for ssh + if env.DeployTarget.SSHHost != "" && env.DeployTarget.SSHPort != "" { + sshHost = env.DeployTarget.SSHHost + sshPort = env.DeployTarget.SSHPort + } + } + } + return sshHost, sshPort, nil +} + +func getSSHClientConfig(environmentName string) (*ssh.ClientConfig, + func() error, error) { + skipAgent := false + privateKey := fmt.Sprintf("%s/.ssh/id_rsa", userPath) + // check for user-defined key + if lagoonCLIConfig.Lagoons[lagoonCLIConfig.Current].SSHKey != "" { + privateKey = lagoonCLIConfig.Lagoons[lagoonCLIConfig.Current].SSHKey + skipAgent = true + } + // check for specified key + if cmdSSHKey != "" { + privateKey = cmdSSHKey + skipAgent = true + } + // parse known_hosts + kh, err := knownhosts.New(path.Join(userPath, ".ssh/known_hosts")) + if err != nil { + return nil, nil, fmt.Errorf("couldn't get ~/.ssh/known_hosts: %v", err) + } + // configure an SSH client session + authMethod, closeSSHAgent := publicKey(privateKey, skipAgent) + return &ssh.ClientConfig{ + User: cmdProjectName + "-" + environmentName, + Auth: []ssh.AuthMethod{authMethod}, + HostKeyCallback: kh, + Timeout: connTimeout, + }, closeSSHAgent, nil +} + +var logsCmd = &cobra.Command{ + Use: "logs", + Short: "Display logs for a service of an environment and project", + RunE: func(cmd *cobra.Command, args []string) error { + // validate/refresh token + validateToken(lagoonCLIConfig.Current) + // validate and parse arguments + if cmdProjectName == "" || cmdProjectEnvironment == "" { + return fmt.Errorf( + "missing arguments: Project name or environment name are not defined") + } + debug, err := cmd.Flags().GetBool("debug") + if err != nil { + return fmt.Errorf("couldn't get debug value: %v", err) + } + argv, err := generateLogsCommand(logsService, logsContainer, logsTailLines, + logsFollow) + if err != nil { + return fmt.Errorf("couldn't generate logs command: %v", err) + } + // replace characters in environment name to allow flexible referencing + environmentName := makeSafe( + shortenEnvironment(cmdProjectName, cmdProjectEnvironment)) + // query the Lagoon API for the environment's SSH endpoint + sshHost, sshPort, err := getSSHHostPort(environmentName, debug) + if err != nil { + return fmt.Errorf("couldn't get SSH endpoint: %v", err) + } + // configure SSH client session + sshConfig, closeSSHAgent, err := getSSHClientConfig(environmentName) + if err != nil { + return fmt.Errorf("couldn't get SSH client config: %v", err) + } + defer closeSSHAgent() + // start SSH log streaming session + err = lagoonssh.LogStream(sshConfig, sshHost, sshPort, argv) + if err != nil { + output.RenderError(err.Error(), outputOptions) + switch e := err.(type) { + case *ssh.ExitMissingError: + // https://github.com/openssh/openssh-portable/blob/ + // 6958f00acf3b9e0b3730f7287e69996bcf3ceda4/fatal.c#L45 + os.Exit(255) + case *ssh.ExitError: + os.Exit(e.ExitStatus()) + default: + os.Exit(254) // internal error + } + } + return nil + }, +} diff --git a/cmd/root.go b/cmd/root.go index 0c0254e8..924f147a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,3 +1,4 @@ +// Package cmd implements the lagoon-cli command line interface. package cmd import ( @@ -193,6 +194,7 @@ Use "{{.CommandPath}} [command] --help" for more information about a command.{{e rootCmd.AddCommand(uploadCmd) rootCmd.AddCommand(rawCmd) rootCmd.AddCommand(resetPasswordCmd) + rootCmd.AddCommand(logsCmd) } // version/build information command diff --git a/pkg/lagoon/ssh/main.go b/pkg/lagoon/ssh/main.go index 07f3a2f8..8dc05b7d 100644 --- a/pkg/lagoon/ssh/main.go +++ b/pkg/lagoon/ssh/main.go @@ -1,14 +1,40 @@ +// Package ssh implements an SSH client for Lagoon. package ssh import ( "bytes" "fmt" "os" + "strings" "golang.org/x/crypto/ssh" "golang.org/x/term" ) +// 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 { + // https://stackoverflow.com/a/37088088 + client, err := ssh.Dial("tcp", host+":"+port, config) + if err != nil { + return fmt.Errorf("couldn't dial SSH (maybe this service doesn't support logs?): %v", err) + } + session, err := client.NewSession() + if err != nil { + return fmt.Errorf("couldn't create SSH session: %v", err) + } + defer session.Close() + session.Stdout = os.Stdout + 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() +} + // InteractiveSSH . func InteractiveSSH(lagoon map[string]string, sshService string, sshContainer string, config *ssh.ClientConfig) error { client, err := ssh.Dial("tcp", lagoon["hostname"]+":"+lagoon["port"], config)