Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for streaming container logs from services #285

Merged
merged 5 commits into from
Jun 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading