Skip to content
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
79 changes: 73 additions & 6 deletions cli/cmd/stack.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package cmd

import (
"context"
"fmt"
"strings"
"os"
"os/signal"
"sort"
"strconv"
"strings"
"time"

"github.com/omattsson/stackctl/cli/pkg/client"
Expand All @@ -13,6 +16,29 @@ import (
"github.com/spf13/cobra"
)

// followLogs streams deployment logs via WebSocket until a terminal status is
// received. Returns an error if the deployment ended in error status.
func followLogs(c *client.Client, instanceID string) error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()

result, err := c.StreamDeploymentLogs(ctx, instanceID, os.Stdout)
if err != nil {
if ctx.Err() != nil {
return nil
}
return err
}

if result.Status == "error" {
if result.ErrorMessage != "" {
return fmt.Errorf("deployment failed: %s", result.ErrorMessage)
}
return fmt.Errorf("deployment failed")
}
return nil
}

const flagPageSize = "page-size"

var stackCmd = &cobra.Command{
Expand Down Expand Up @@ -207,8 +233,11 @@ var stackDeployCmd = &cobra.Command{
Short: "Deploy a stack instance",
Long: `Trigger a deployment for a stack instance.

Use --follow to stream deployment logs in real-time until completion.

Examples:
stackctl stack deploy my-stack
stackctl stack deploy my-stack --follow
stackctl stack deploy 550e8400-e29b-41d4-a716-446655440000`,
Args: cobra.ExactArgs(1),
SilenceUsage: true,
Expand All @@ -228,6 +257,11 @@ Examples:
return err
}

follow, _ := cmd.Flags().GetBool("follow")
if follow {
return followLogs(c, id)
}

if printer.Quiet {
fmt.Fprintln(printer.Writer, resp.LogID)
return nil
Expand All @@ -243,9 +277,11 @@ var stackStopCmd = &cobra.Command{
Short: "Stop a stack instance",
Long: `Stop a running stack instance.

Use --follow to stream logs in real-time until completion.

Examples:
stackctl stack stop my-stack
stackctl stack stop 550e8400-e29b-41d4-a716-446655440000`,
stackctl stack stop my-stack --follow`,
Args: cobra.ExactArgs(1),
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
Expand All @@ -264,6 +300,11 @@ Examples:
return err
}

follow, _ := cmd.Flags().GetBool("follow")
if follow {
return followLogs(c, id)
}

if printer.Quiet {
fmt.Fprintln(printer.Writer, resp.LogID)
return nil
Expand All @@ -280,11 +321,12 @@ var stackCleanCmd = &cobra.Command{
Long: `Undeploy a stack instance and remove its namespace.

This is a destructive operation. You will be prompted for confirmation
unless --yes is specified.
unless --yes is specified. Use --follow to stream logs in real-time.

Examples:
stackctl stack clean my-stack
stackctl stack clean my-stack --yes`,
stackctl stack clean my-stack --yes
stackctl stack clean my-stack --yes --follow`,
Args: cobra.ExactArgs(1),
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
Expand Down Expand Up @@ -312,6 +354,11 @@ Examples:
return err
}

follow, _ := cmd.Flags().GetBool("follow")
if follow {
return followLogs(c, id)
}

if printer.Quiet {
fmt.Fprintln(printer.Writer, resp.LogID)
return nil
Expand Down Expand Up @@ -412,8 +459,11 @@ var stackLogsCmd = &cobra.Command{
Short: "Show latest deployment log for a stack instance",
Long: `Show the latest deployment log for a stack instance.

Use --follow to stream logs from an active deployment in real-time.

Examples:
stackctl stack logs my-stack
stackctl stack logs my-stack --follow
stackctl stack logs my-stack -o json`,
Args: cobra.ExactArgs(1),
SilenceUsage: true,
Expand All @@ -428,6 +478,11 @@ Examples:
return err
}

follow, _ := cmd.Flags().GetBool("follow")
if follow {
return followLogs(c, id)
}

log, err := c.GetStackLogs(id)
if err != nil {
return err
Expand Down Expand Up @@ -724,13 +779,13 @@ var stackRollbackCmd = &cobra.Command{
Long: `Rollback all Helm releases in a stack instance to their previous revision.

This is a potentially disruptive operation. You will be prompted for
confirmation unless --yes is specified.
confirmation unless --yes is specified. Use --follow to stream logs in real-time.

Optionally specify --target-log to rollback to a specific past deployment.

Examples:
stackctl stack rollback my-stack
stackctl stack rollback my-stack --yes
stackctl stack rollback my-stack --yes --follow
stackctl stack rollback my-stack --target-log abc-123`,
Args: cobra.ExactArgs(1),
SilenceUsage: true,
Expand Down Expand Up @@ -762,6 +817,11 @@ Examples:
return err
}

follow, _ := cmd.Flags().GetBool("follow")
if follow {
return followLogs(c, id)
}

if printer.Quiet {
fmt.Fprintln(printer.Writer, resp.LogID)
return nil
Expand Down Expand Up @@ -839,6 +899,13 @@ func init() {
_ = stackCreateCmd.MarkFlagRequired("name")
_ = stackCreateCmd.MarkFlagRequired("definition")

// --follow flags
stackDeployCmd.Flags().BoolP("follow", "f", false, "Stream deployment logs until completion")
stackStopCmd.Flags().BoolP("follow", "f", false, "Stream logs until completion")
stackCleanCmd.Flags().BoolP("follow", "f", false, "Stream logs until completion")
stackLogsCmd.Flags().BoolP("follow", "f", false, "Stream logs from active deployment")
stackRollbackCmd.Flags().BoolP("follow", "f", false, "Stream logs until completion")

// stack clean flags
stackCleanCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")

Expand Down
1 change: 1 addition & 0 deletions cli/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ require (

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
golang.org/x/sys v0.43.0 // indirect
Expand Down
2 changes: 2 additions & 0 deletions cli/go.sum
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
Expand Down
131 changes: 131 additions & 0 deletions cli/pkg/client/websocket.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package client

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"

"github.com/gorilla/websocket"
"github.com/omattsson/stackctl/cli/pkg/types"
)

var terminalStatuses = map[string]bool{
"running": true,
"stopped": true,
"error": true,
"draft": true,
}

// StreamDeploymentLogs connects to the backend WebSocket and streams deployment
// log lines for the given instance to w. It blocks until a terminal status is
// received, the context is cancelled, or the connection drops.
func (c *Client) StreamDeploymentLogs(ctx context.Context, instanceID string, w io.Writer) (*types.StreamResult, error) {
wsURL, err := c.websocketURL("/ws")
if err != nil {
return nil, err
}

header := http.Header{}
if c.APIKey != "" {
header.Set("X-API-Key", c.APIKey)
} else if c.Token != "" {
header.Set("Authorization", "Bearer "+c.Token)
}

dialer := websocket.DefaultDialer
if c.HTTPClient != nil && c.HTTPClient.Transport != nil {
if t, ok := c.HTTPClient.Transport.(*http.Transport); ok {
dialer = &websocket.Dialer{
TLSClientConfig: t.TLSClientConfig,
}
}
}

conn, _, err := dialer.DialContext(ctx, wsURL, header)
if err != nil {
return nil, fmt.Errorf("connecting to WebSocket: %w", err)
}
defer conn.Close()

// Subscribe to this instance so we receive deployment.log events
// (the hub only sends log lines to subscribed clients).
sub, _ := json.Marshal(map[string]interface{}{
"type": "subscribe",
"payload": map[string]string{"instance_id": instanceID},
})
if err := conn.WriteMessage(websocket.TextMessage, sub); err != nil {
return nil, fmt.Errorf("subscribing to instance: %w", err)
}

done := make(chan struct{})
go func() {
select {
case <-ctx.Done():
conn.WriteMessage(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
case <-done:
}
}()
defer close(done)

for {
_, message, err := conn.ReadMessage()
if err != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
if websocket.IsCloseError(err, websocket.CloseNormalClosure) {
return &types.StreamResult{Status: "unknown"}, nil
}
return nil, fmt.Errorf("reading WebSocket message: %w", err)
}

var msg types.WSMessage
if err := json.Unmarshal(message, &msg); err != nil {
continue
}

switch msg.Type {
case "deployment.log":
var logLine types.WSDeploymentLog
if err := json.Unmarshal(msg.Data, &logLine); err != nil {
continue
}
if logLine.InstanceID != instanceID {
continue
}
fmt.Fprintln(w, logLine.Line)

case "deployment.status":
var status types.WSDeploymentStatus
if err := json.Unmarshal(msg.Data, &status); err != nil {
continue
}
if status.InstanceID != instanceID {
continue
}
if terminalStatuses[status.Status] {
return &types.StreamResult{
Status: status.Status,
ErrorMessage: status.ErrorMessage,
}, nil
}
}
}
}

func (c *Client) websocketURL(path string) (string, error) {
base := c.BaseURL
switch {
case strings.HasPrefix(base, "https://"):
base = "wss://" + strings.TrimPrefix(base, "https://")
case strings.HasPrefix(base, "http://"):
base = "ws://" + strings.TrimPrefix(base, "http://")
default:
return "", fmt.Errorf("unsupported URL scheme in %q", c.BaseURL)
}
return strings.TrimRight(base, "/") + path, nil
}
Loading
Loading