Skip to content

Commit

Permalink
feat: add support for streaming container logs from services (#285)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
smlx and shreddedbacon authored Jun 20, 2024
1 parent adbc9a3 commit ad254f4
Show file tree
Hide file tree
Showing 5 changed files with 211 additions and 5 deletions.
8 changes: 3 additions & 5 deletions cmd/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions cmd/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
175 changes: 175 additions & 0 deletions cmd/logs.go
Original file line number Diff line number Diff line change
@@ -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
},
}
2 changes: 2 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// Package cmd implements the lagoon-cli command line interface.
package cmd

import (
Expand Down Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions pkg/lagoon/ssh/main.go
Original file line number Diff line number Diff line change
@@ -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)
Expand Down

0 comments on commit ad254f4

Please sign in to comment.