From e95b279882cfb2537fa0ba1b4392b050fb9bccc3 Mon Sep 17 00:00:00 2001 From: Olof Mattsson Date: Fri, 24 Apr 2026 15:07:24 +0200 Subject: [PATCH] feat: stream deployment logs with --follow flag (#51) Adds real-time log streaming via WebSocket to deploy, stop, clean, rollback, and logs commands. The client subscribes to the instance log channel and prints lines to stdout until a terminal status arrives. Closes #51 Co-Authored-By: Claude Opus 4.6 --- cli/cmd/stack.go | 79 +++++++- cli/go.mod | 1 + cli/go.sum | 2 + cli/pkg/client/websocket.go | 131 ++++++++++++ cli/pkg/client/websocket_test.go | 329 +++++++++++++++++++++++++++++++ cli/pkg/types/types.go | 32 ++- 6 files changed, 567 insertions(+), 7 deletions(-) create mode 100644 cli/pkg/client/websocket.go create mode 100644 cli/pkg/client/websocket_test.go diff --git a/cli/cmd/stack.go b/cli/cmd/stack.go index 07e979b..b8bc04e 100644 --- a/cli/cmd/stack.go +++ b/cli/cmd/stack.go @@ -1,10 +1,13 @@ package cmd import ( + "context" "fmt" - "strings" + "os" + "os/signal" "sort" "strconv" + "strings" "time" "github.com/omattsson/stackctl/cli/pkg/client" @@ -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{ @@ -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, @@ -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 @@ -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 { @@ -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 @@ -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 { @@ -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 @@ -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, @@ -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 @@ -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, @@ -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 @@ -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") diff --git a/cli/go.mod b/cli/go.mod index a9c951f..3b9a50b 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -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 diff --git a/cli/go.sum b/cli/go.sum index 8e698cc..ab17413 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -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= diff --git a/cli/pkg/client/websocket.go b/cli/pkg/client/websocket.go new file mode 100644 index 0000000..c1e5e82 --- /dev/null +++ b/cli/pkg/client/websocket.go @@ -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 +} diff --git a/cli/pkg/client/websocket_test.go b/cli/pkg/client/websocket_test.go new file mode 100644 index 0000000..e830efc --- /dev/null +++ b/cli/pkg/client/websocket_test.go @@ -0,0 +1,329 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/omattsson/stackctl/cli/pkg/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var upgrader = websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} + +func wsServer(t *testing.T, handler func(conn *websocket.Conn)) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + require.NoError(t, err) + defer conn.Close() + handler(conn) + })) +} + +func writeWSMessage(t *testing.T, conn *websocket.Conn, msgType string, data interface{}) { + t.Helper() + raw, err := json.Marshal(data) + require.NoError(t, err) + msg := types.WSMessage{Type: msgType, Data: raw} + b, err := json.Marshal(msg) + require.NoError(t, err) + require.NoError(t, conn.WriteMessage(websocket.TextMessage, b)) +} + +// readSubscribe reads the first inbound message and asserts it is a subscribe +// for the expected instance ID. +func readSubscribe(t *testing.T, conn *websocket.Conn, expectedID string) { + t.Helper() + _, msg, err := conn.ReadMessage() + require.NoError(t, err) + var envelope struct { + Type string `json:"type"` + Payload json.RawMessage `json:"payload"` + } + require.NoError(t, json.Unmarshal(msg, &envelope)) + assert.Equal(t, "subscribe", envelope.Type) + var payload struct { + InstanceID string `json:"instance_id"` + } + require.NoError(t, json.Unmarshal(envelope.Payload, &payload)) + assert.Equal(t, expectedID, payload.InstanceID) +} + +func TestStreamDeploymentLogs_Success(t *testing.T) { + t.Parallel() + server := wsServer(t, func(conn *websocket.Conn) { + readSubscribe(t, conn, "42") + writeWSMessage(t, conn, "deployment.log", types.WSDeploymentLog{ + InstanceID: "42", LogID: "log-1", Line: "Installing chart...", + }) + writeWSMessage(t, conn, "deployment.log", types.WSDeploymentLog{ + InstanceID: "42", LogID: "log-1", Line: "Chart installed.", + }) + writeWSMessage(t, conn, "deployment.status", types.WSDeploymentStatus{ + InstanceID: "42", Status: "running", LogID: "log-1", + }) + }) + defer server.Close() + + c := New(server.URL) + var buf bytes.Buffer + result, err := c.StreamDeploymentLogs(context.Background(), "42", &buf) + require.NoError(t, err) + + assert.Equal(t, "running", result.Status) + assert.Contains(t, buf.String(), "Installing chart...") + assert.Contains(t, buf.String(), "Chart installed.") +} + +func TestStreamDeploymentLogs_ErrorStatus(t *testing.T) { + t.Parallel() + server := wsServer(t, func(conn *websocket.Conn) { + readSubscribe(t, conn, "42") + writeWSMessage(t, conn, "deployment.log", types.WSDeploymentLog{ + InstanceID: "42", LogID: "log-1", Line: "Error: timeout", + }) + writeWSMessage(t, conn, "deployment.status", types.WSDeploymentStatus{ + InstanceID: "42", Status: "error", LogID: "log-1", ErrorMessage: "helm install timed out", + }) + }) + defer server.Close() + + c := New(server.URL) + var buf bytes.Buffer + result, err := c.StreamDeploymentLogs(context.Background(), "42", &buf) + require.NoError(t, err) + + assert.Equal(t, "error", result.Status) + assert.Equal(t, "helm install timed out", result.ErrorMessage) + assert.Contains(t, buf.String(), "Error: timeout") +} + +func TestStreamDeploymentLogs_FiltersOtherInstances(t *testing.T) { + t.Parallel() + server := wsServer(t, func(conn *websocket.Conn) { + readSubscribe(t, conn, "42") + writeWSMessage(t, conn, "deployment.log", types.WSDeploymentLog{ + InstanceID: "99", LogID: "log-other", Line: "Should be ignored", + }) + writeWSMessage(t, conn, "deployment.log", types.WSDeploymentLog{ + InstanceID: "42", LogID: "log-1", Line: "My line", + }) + writeWSMessage(t, conn, "deployment.status", types.WSDeploymentStatus{ + InstanceID: "99", Status: "running", LogID: "log-other", + }) + writeWSMessage(t, conn, "deployment.status", types.WSDeploymentStatus{ + InstanceID: "42", Status: "stopped", LogID: "log-1", + }) + }) + defer server.Close() + + c := New(server.URL) + var buf bytes.Buffer + result, err := c.StreamDeploymentLogs(context.Background(), "42", &buf) + require.NoError(t, err) + + assert.Equal(t, "stopped", result.Status) + assert.Equal(t, "My line\n", buf.String()) +} + +func TestStreamDeploymentLogs_ContextCancelled(t *testing.T) { + t.Parallel() + server := wsServer(t, func(conn *websocket.Conn) { + readSubscribe(t, conn, "42") + writeWSMessage(t, conn, "deployment.log", types.WSDeploymentLog{ + InstanceID: "42", LogID: "log-1", Line: "Starting...", + }) + for { + _, _, err := conn.ReadMessage() + if err != nil { + return + } + } + }) + defer server.Close() + + c := New(server.URL) + var buf bytes.Buffer + + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + + _, err := c.StreamDeploymentLogs(ctx, "42", &buf) + require.Error(t, err) + assert.ErrorIs(t, err, context.DeadlineExceeded) +} + +func TestStreamDeploymentLogs_AuthHeaders(t *testing.T) { + t.Parallel() + var capturedHeader http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedHeader = r.Header.Clone() + conn, err := upgrader.Upgrade(w, r, nil) + require.NoError(t, err) + defer conn.Close() + readSubscribe(t, conn, "42") + writeWSMessage(t, conn, "deployment.status", types.WSDeploymentStatus{ + InstanceID: "42", Status: "running", LogID: "log-1", + }) + })) + defer server.Close() + + c := New(server.URL) + c.Token = "my-jwt-token" + var buf bytes.Buffer + _, err := c.StreamDeploymentLogs(context.Background(), "42", &buf) + require.NoError(t, err) + assert.Equal(t, "Bearer my-jwt-token", capturedHeader.Get("Authorization")) +} + +func TestStreamDeploymentLogs_APIKeyAuth(t *testing.T) { + t.Parallel() + var capturedHeader http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedHeader = r.Header.Clone() + conn, err := upgrader.Upgrade(w, r, nil) + require.NoError(t, err) + defer conn.Close() + readSubscribe(t, conn, "42") + writeWSMessage(t, conn, "deployment.status", types.WSDeploymentStatus{ + InstanceID: "42", Status: "running", LogID: "log-1", + }) + })) + defer server.Close() + + c := New(server.URL) + c.APIKey = "sk_test_123" + c.Token = "should-be-ignored" + var buf bytes.Buffer + _, err := c.StreamDeploymentLogs(context.Background(), "42", &buf) + require.NoError(t, err) + assert.Equal(t, "sk_test_123", capturedHeader.Get("X-API-Key")) + assert.Empty(t, capturedHeader.Get("Authorization")) +} + +func TestStreamDeploymentLogs_SkipsMalformedMessages(t *testing.T) { + t.Parallel() + server := wsServer(t, func(conn *websocket.Conn) { + readSubscribe(t, conn, "42") + conn.WriteMessage(websocket.TextMessage, []byte(`not json`)) + conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"deployment.log","payload":"not-an-object"}`)) + writeWSMessage(t, conn, "deployment.log", types.WSDeploymentLog{ + InstanceID: "42", LogID: "log-1", Line: "Valid line", + }) + writeWSMessage(t, conn, "deployment.status", types.WSDeploymentStatus{ + InstanceID: "42", Status: "running", LogID: "log-1", + }) + }) + defer server.Close() + + c := New(server.URL) + var buf bytes.Buffer + result, err := c.StreamDeploymentLogs(context.Background(), "42", &buf) + require.NoError(t, err) + assert.Equal(t, "running", result.Status) + assert.Equal(t, "Valid line\n", buf.String()) +} + +func TestStreamDeploymentLogs_DraftTerminal(t *testing.T) { + t.Parallel() + server := wsServer(t, func(conn *websocket.Conn) { + readSubscribe(t, conn, "42") + writeWSMessage(t, conn, "deployment.status", types.WSDeploymentStatus{ + InstanceID: "42", Status: "draft", LogID: "log-1", + }) + }) + defer server.Close() + + c := New(server.URL) + var buf bytes.Buffer + result, err := c.StreamDeploymentLogs(context.Background(), "42", &buf) + require.NoError(t, err) + assert.Equal(t, "draft", result.Status) +} + +func TestStreamDeploymentLogs_NonTerminalStatusIgnored(t *testing.T) { + t.Parallel() + server := wsServer(t, func(conn *websocket.Conn) { + readSubscribe(t, conn, "42") + writeWSMessage(t, conn, "deployment.status", types.WSDeploymentStatus{ + InstanceID: "42", Status: "deploying", LogID: "log-1", + }) + writeWSMessage(t, conn, "deployment.log", types.WSDeploymentLog{ + InstanceID: "42", LogID: "log-1", Line: "Still going...", + }) + writeWSMessage(t, conn, "deployment.status", types.WSDeploymentStatus{ + InstanceID: "42", Status: "running", LogID: "log-1", + }) + }) + defer server.Close() + + c := New(server.URL) + var buf bytes.Buffer + result, err := c.StreamDeploymentLogs(context.Background(), "42", &buf) + require.NoError(t, err) + assert.Equal(t, "running", result.Status) + assert.Contains(t, buf.String(), "Still going...") +} + +func TestStreamDeploymentLogs_ConnectionError(t *testing.T) { + t.Parallel() + c := New("http://localhost:1") + var buf bytes.Buffer + _, err := c.StreamDeploymentLogs(context.Background(), "42", &buf) + require.Error(t, err) + assert.Contains(t, err.Error(), "connecting to WebSocket") +} + +func TestStreamDeploymentLogs_BadScheme(t *testing.T) { + t.Parallel() + c := &Client{BaseURL: "ftp://example.com"} + var buf bytes.Buffer + _, err := c.StreamDeploymentLogs(context.Background(), "42", &buf) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported URL scheme") +} + +func TestWebsocketURL(t *testing.T) { + t.Parallel() + tests := []struct { + name string + baseURL string + path string + want string + wantErr bool + }{ + {"http", "http://localhost:8081", "/ws", "ws://localhost:8081/ws", false}, + {"https", "https://api.example.com", "/ws", "wss://api.example.com/ws", false}, + {"trailing slash", "http://localhost:8081/", "/ws", "ws://localhost:8081/ws", false}, + {"unsupported", "ftp://example.com", "/ws", "", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + c := &Client{BaseURL: tt.baseURL} + got, err := c.websocketURL(tt.path) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tt.want, got) + } + }) + } +} + +func TestWebsocketURL_StripTrailingSlash(t *testing.T) { + t.Parallel() + c := &Client{BaseURL: "https://example.com/"} + got, err := c.websocketURL("/ws") + require.NoError(t, err) + assert.False(t, strings.Contains(got, "//ws")) +} diff --git a/cli/pkg/types/types.go b/cli/pkg/types/types.go index 28f1355..37fa633 100644 --- a/cli/pkg/types/types.go +++ b/cli/pkg/types/types.go @@ -1,6 +1,9 @@ package types -import "time" +import ( + "encoding/json" + "time" +) // Base fields shared by all API resources. type Base struct { @@ -332,6 +335,33 @@ type SetSharedValuesRequest struct { Priority int `json:"priority,omitempty"` } +// WSMessage is the envelope for all WebSocket messages. +type WSMessage struct { + Type string `json:"type"` + Data json.RawMessage `json:"payload"` +} + +// WSDeploymentLog is the payload for "deployment.log" WebSocket messages. +type WSDeploymentLog struct { + InstanceID string `json:"instance_id"` + LogID string `json:"log_id"` + Line string `json:"line"` +} + +// WSDeploymentStatus is the payload for "deployment.status" WebSocket messages. +type WSDeploymentStatus struct { + InstanceID string `json:"instance_id"` + Status string `json:"status"` + LogID string `json:"log_id"` + ErrorMessage string `json:"error_message,omitempty"` +} + +// StreamResult is the outcome of a WebSocket log-streaming session. +type StreamResult struct { + Status string + ErrorMessage string +} + // CompareResult represents the comparison between two stack instances. type CompareResult struct { Left *StackInstance `json:"left" yaml:"left"`