From c4f21891847e713d0d28ddfe80ae5615b39dfb59 Mon Sep 17 00:00:00 2001 From: Olof Mattsson Date: Mon, 20 Apr 2026 09:09:38 +0200 Subject: [PATCH 1/2] feat: add stack history, rollback, and history-values commands Add CLI commands for deployment history (with pagination), one-click rollback, and viewing historical deploy values. Updates DeploymentLog type to include values snapshot and target log fields, and adds corresponding client SDK methods. Companion to k8s-stack-manager#114. Co-Authored-By: Claude Opus 4.6 --- cli/cmd/stack.go | 174 ++++++++++++++++++ cli/cmd/stack_test.go | 22 +-- cli/pkg/client/client.go | 33 ++++ cli/pkg/client/client_test.go | 8 +- cli/pkg/output/output_test.go | 24 +-- cli/pkg/types/types.go | 33 +++- .../integration/stack_integration_test.go | 8 +- 7 files changed, 260 insertions(+), 42 deletions(-) diff --git a/cli/cmd/stack.go b/cli/cmd/stack.go index 2d750ea..cdb6d6a 100644 --- a/cli/cmd/stack.go +++ b/cli/cmd/stack.go @@ -627,6 +627,170 @@ Examples: }, } +var stackHistoryCmd = &cobra.Command{ + Use: "history ", + Short: "Show deployment history for a stack instance", + Long: `Show the deployment history for a stack instance. + +Examples: + stackctl stack history 42 + stackctl stack history 42 --limit 20 + stackctl stack history 42 -o json`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + id, err := parseID(args[0]) + if err != nil { + return err + } + + limit, _ := cmd.Flags().GetInt("limit") + + c, err := newClient() + if err != nil { + return err + } + + params := map[string]string{} + if limit > 0 { + params["limit"] = strconv.Itoa(limit) + } + + resp, err := c.GetDeploymentHistory(id, params) + if err != nil { + return err + } + + if printer.Quiet { + ids := make([]string, len(resp.Data)) + for i, d := range resp.Data { + ids[i] = d.ID + } + printer.PrintIDs(ids) + return nil + } + + switch printer.Format { + case output.FormatJSON: + return printer.PrintJSON(resp) + case output.FormatYAML: + return printer.PrintYAML(resp) + default: + if len(resp.Data) == 0 { + printer.PrintMessage("No deployment history for stack %s", id) + return nil + } + headers := []string{"LOG ID", "ACTION", "STATUS", "STARTED", "COMPLETED"} + rows := make([][]string, len(resp.Data)) + for i, d := range resp.Data { + rows[i] = []string{ + d.ID, + d.Action, + printer.StatusColor(d.Status), + formatTime(d.StartedAt), + formatTime(d.CompletedAt), + } + } + return printer.PrintTable(headers, rows) + } + }, +} + +var stackRollbackCmd = &cobra.Command{ + Use: "rollback ", + Short: "Rollback a stack instance to the previous deployment", + 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. + +Optionally specify --target-log to rollback to a specific past deployment. + +Examples: + stackctl stack rollback 42 + stackctl stack rollback 42 --yes + stackctl stack rollback 42 --target-log abc-123`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + id, err := parseID(args[0]) + if err != nil { + return err + } + + confirmed, err := confirmAction(cmd, fmt.Sprintf("This will rollback stack %s. Continue? (y/n): ", id)) + if err != nil { + return err + } + if !confirmed { + printer.PrintMessage("Aborted.") + return nil + } + + c, err := newClient() + if err != nil { + return err + } + + targetLog, _ := cmd.Flags().GetString("target-log") + req := &types.RollbackRequest{TargetLogID: targetLog} + + log, err := c.RollbackStack(id, req) + if err != nil { + return err + } + + if printer.Quiet { + fmt.Fprintln(printer.Writer, log.ID) + return nil + } + + printer.PrintMessage("Rollback started for stack %s (log ID: %s)", id, log.ID) + return nil + }, +} + +var stackHistoryValuesCmd = &cobra.Command{ + Use: "history-values ", + Short: "Show values used in a past deployment", + Long: `Show the merged Helm values that were used in a specific deployment. + +Examples: + stackctl stack history-values 42 abc-123 + stackctl stack history-values 42 abc-123 -o yaml`, + Args: cobra.ExactArgs(2), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + instanceID, err := parseID(args[0]) + if err != nil { + return err + } + logID, err := parseID(args[1]) + if err != nil { + return err + } + + c, err := newClient() + if err != nil { + return err + } + + resp, err := c.GetDeployLogValues(instanceID, logID) + if err != nil { + return err + } + + switch printer.Format { + case output.FormatJSON: + return printer.PrintJSON(resp) + case output.FormatYAML: + return printer.PrintYAML(resp) + default: + return printer.PrintJSON(resp) + } + }, +} + func init() { // stack list flags stackListCmd.Flags().Bool("mine", false, "Show only my stacks") @@ -660,6 +824,13 @@ func init() { // stack values flags stackValuesCmd.Flags().String("chart", "", "Filter by chart name") + // stack history flags + stackHistoryCmd.Flags().Int("limit", 20, "Maximum number of entries to show") + + // stack rollback flags + stackRollbackCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + stackRollbackCmd.Flags().String("target-log", "", "Target deployment log ID to rollback to") + // Wire up subcommands stackCmd.AddCommand(stackListCmd) stackCmd.AddCommand(stackGetCmd) @@ -674,6 +845,9 @@ func init() { stackCmd.AddCommand(stackExtendCmd) stackCmd.AddCommand(stackValuesCmd) stackCmd.AddCommand(stackCompareCmd) + stackCmd.AddCommand(stackHistoryCmd) + stackCmd.AddCommand(stackRollbackCmd) + stackCmd.AddCommand(stackHistoryValuesCmd) rootCmd.AddCommand(stackCmd) } diff --git a/cli/cmd/stack_test.go b/cli/cmd/stack_test.go index c190971..aae4496 100644 --- a/cli/cmd/stack_test.go +++ b/cli/cmd/stack_test.go @@ -365,7 +365,7 @@ func TestStackDeployCmd_Success(t *testing.T) { require.Equal(t, http.MethodPost, r.Method) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(types.DeploymentLog{Base: types.Base{ID: "100"}, InstanceID: "42", Action: "deploy", Status: "started"}) + json.NewEncoder(w).Encode(types.DeploymentLog{ID: "100", InstanceID: "42", Action: "deploy", Status: "started"}) })) defer server.Close() @@ -382,7 +382,7 @@ func TestStackDeployCmd_QuietOutput(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(types.DeploymentLog{Base: types.Base{ID: "100"}}) + json.NewEncoder(w).Encode(types.DeploymentLog{ID: "100"}) })) defer server.Close() @@ -401,7 +401,7 @@ func TestStackStopCmd_Success(t *testing.T) { require.Equal(t, http.MethodPost, r.Method) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(types.DeploymentLog{Base: types.Base{ID: "101"}, InstanceID: "42", Action: "stop", Status: "started"}) + json.NewEncoder(w).Encode(types.DeploymentLog{ID: "101", InstanceID: "42", Action: "stop", Status: "started"}) })) defer server.Close() @@ -423,7 +423,7 @@ func TestStackCleanCmd_WithConfirmation(t *testing.T) { require.Equal(t, "/api/v1/stack-instances/42/clean", r.URL.Path) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(types.DeploymentLog{Base: types.Base{ID: "102"}}) + json.NewEncoder(w).Encode(types.DeploymentLog{ID: "102"}) })) defer server.Close() @@ -474,7 +474,7 @@ func TestStackCleanCmd_WithYesFlag(t *testing.T) { called = true w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(types.DeploymentLog{Base: types.Base{ID: "103"}}) + json.NewEncoder(w).Encode(types.DeploymentLog{ID: "103"}) })) defer server.Close() @@ -643,7 +643,7 @@ func TestStackLogsCmd_Success(t *testing.T) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(types.DeploymentLog{ - Base: types.Base{ID: "200"}, + ID: "200", InstanceID: "42", Action: "deploy", Status: "completed", @@ -665,7 +665,7 @@ func TestStackLogsCmd_Success(t *testing.T) { func TestStackLogsCmd_JSONOutput(t *testing.T) { logEntry := types.DeploymentLog{ - Base: types.Base{ID: "200"}, + ID: "200", InstanceID: "42", Action: "deploy", Status: "completed", @@ -822,7 +822,7 @@ func TestStackLogsCmd_QuietOutput(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(types.DeploymentLog{Base: types.Base{ID: "200"}}) + json.NewEncoder(w).Encode(types.DeploymentLog{ID: "200"}) })) defer server.Close() @@ -885,7 +885,7 @@ func TestStackStopCmd_QuietOutput(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(types.DeploymentLog{Base: types.Base{ID: "101"}}) + json.NewEncoder(w).Encode(types.DeploymentLog{ID: "101"}) })) defer server.Close() @@ -919,7 +919,7 @@ func TestStackCleanCmd_QuietOutput(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(types.DeploymentLog{Base: types.Base{ID: "102"}}) + json.NewEncoder(w).Encode(types.DeploymentLog{ID: "102"}) })) defer server.Close() @@ -1002,7 +1002,7 @@ func TestStackLogsCmd_YAMLOutput(t *testing.T) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(types.DeploymentLog{ - Base: types.Base{ID: "200"}, Action: "deploy", Status: "completed", Output: "OK", + ID: "200", Action: "deploy", Status: "completed", Output: "OK", }) })) defer server.Close() diff --git a/cli/pkg/client/client.go b/cli/pkg/client/client.go index 93b4dfa..f798963 100644 --- a/cli/pkg/client/client.go +++ b/cli/pkg/client/client.go @@ -350,6 +350,39 @@ func (c *Client) GetStackLogs(id string) (*types.DeploymentLog, error) { return &log, nil } +// GetDeploymentHistory returns paginated deployment history for a stack instance. +func (c *Client) GetDeploymentHistory(id string, params map[string]string) (*types.DeploymentLogResult, error) { + var result types.DeploymentLogResult + err := c.GetWithQuery(fmt.Sprintf("/api/v1/stack-instances/%s/deploy-log", id), params, &result) + if err != nil { + return nil, err + } + return &result, nil +} + +// RollbackStack triggers a rollback for a stack instance. +func (c *Client) RollbackStack(id string, req *types.RollbackRequest) (*types.DeploymentLog, error) { + var resp struct { + LogID string `json:"log_id"` + Message string `json:"message"` + } + err := c.Post(fmt.Sprintf("/api/v1/stack-instances/%s/rollback", id), req, &resp) + if err != nil { + return nil, err + } + return &types.DeploymentLog{ID: resp.LogID, Action: "rollback", Status: "running"}, nil +} + +// GetDeployLogValues returns the values snapshot for a specific deployment log entry. +func (c *Client) GetDeployLogValues(instanceID, logID string) (*types.DeployLogValuesResponse, error) { + var resp types.DeployLogValuesResponse + err := c.Get(fmt.Sprintf("/api/v1/stack-instances/%s/deploy-log/%s/values", instanceID, logID), &resp) + if err != nil { + return nil, err + } + return &resp, nil +} + // CloneStack clones a stack instance and returns the new instance. func (c *Client) CloneStack(id string) (*types.StackInstance, error) { var instance types.StackInstance diff --git a/cli/pkg/client/client_test.go b/cli/pkg/client/client_test.go index 8c8a238..31de40a 100644 --- a/cli/pkg/client/client_test.go +++ b/cli/pkg/client/client_test.go @@ -709,7 +709,7 @@ func TestDeployStack_Success(t *testing.T) { assert.Equal(t, "/api/v1/stack-instances/42/deploy", r.URL.Path) w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(types.DeploymentLog{ - Base: types.Base{ID: "100"}, + ID: "100", InstanceID: "42", Action: "deploy", Status: "started", @@ -732,7 +732,7 @@ func TestStopStack_Success(t *testing.T) { assert.Equal(t, "/api/v1/stack-instances/42/stop", r.URL.Path) w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(types.DeploymentLog{ - Base: types.Base{ID: "101"}, + ID: "101", InstanceID: "42", Action: "stop", Status: "started", @@ -754,7 +754,7 @@ func TestCleanStack_Success(t *testing.T) { assert.Equal(t, "/api/v1/stack-instances/42/clean", r.URL.Path) w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(types.DeploymentLog{ - Base: types.Base{ID: "102"}, + ID: "102", InstanceID: "42", Action: "clean", Status: "started", @@ -802,7 +802,7 @@ func TestGetStackLogs_Success(t *testing.T) { assert.Equal(t, "/api/v1/stack-instances/42/deploy-log", r.URL.Path) w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(types.DeploymentLog{ - Base: types.Base{ID: "200"}, + ID: "200", InstanceID: "42", Action: "deploy", Status: "completed", diff --git a/cli/pkg/output/output_test.go b/cli/pkg/output/output_test.go index 5103d5c..a0290fc 100644 --- a/cli/pkg/output/output_test.go +++ b/cli/pkg/output/output_test.go @@ -534,14 +534,8 @@ func TestListResponse_EmptyData_JSON(t *testing.T) { func TestDeploymentLog_AllFormats(t *testing.T) { t.Parallel() - now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC) log := types.DeploymentLog{ - Base: types.Base{ - ID: "101", - CreatedAt: now, - UpdatedAt: now, - Version: "1", - }, + ID: "101", InstanceID: "42", Action: "deploy", Status: "success", @@ -560,7 +554,7 @@ func TestDeploymentLog_AllFormats(t *testing.T) { var result map[string]interface{} require.NoError(t, json.Unmarshal([]byte(output), &result)) assert.Equal(t, "101", result["id"]) - assert.Equal(t, "42", result["instance_id"]) + assert.Equal(t, "42", result["stack_instance_id"]) assert.Equal(t, "deploy", result["action"]) assert.Equal(t, "success", result["status"]) assert.Equal(t, "Deployed 3 charts successfully", result["output"]) @@ -626,10 +620,7 @@ func TestDeploymentLog_EmptyOutput(t *testing.T) { p := &Printer{Writer: &buf, Format: FormatJSON} log := types.DeploymentLog{ - Base: types.Base{ - ID: "102", - Version: "1", - }, + ID: "102", InstanceID: "42", Action: "stop", Status: "pending", @@ -864,10 +855,7 @@ func TestNilAndZeroValueHandling(t *testing.T) { { name: "deployment_log_empty_output_field", data: types.DeploymentLog{ - Base: types.Base{ - ID: "200", - Version: "1", - }, + ID: "200", InstanceID: "50", Action: "clean", Status: "success", @@ -986,8 +974,8 @@ func TestListResponse_MultiplePages_JSON(t *testing.T) { listResp := types.ListResponse[types.DeploymentLog]{ Data: []types.DeploymentLog{ - {Base: types.Base{ID: "1"}, InstanceID: "10", Action: "deploy", Status: "success", Output: "ok"}, - {Base: types.Base{ID: "2"}, InstanceID: "10", Action: "stop", Status: "success", Output: "stopped"}, + {ID: "1", InstanceID: "10", Action: "deploy", Status: "success", Output: "ok"}, + {ID: "2", InstanceID: "10", Action: "stop", Status: "success", Output: "stopped"}, }, Total: 50, Page: 3, diff --git a/cli/pkg/types/types.go b/cli/pkg/types/types.go index 38af85d..b86b8ec 100644 --- a/cli/pkg/types/types.go +++ b/cli/pkg/types/types.go @@ -102,11 +102,34 @@ type LoginResponse struct { // DeploymentLog represents a deployment log entry. type DeploymentLog struct { - Base - InstanceID string `json:"instance_id" yaml:"instance_id"` - Action string `json:"action" yaml:"action"` - Status string `json:"status" yaml:"status"` - Output string `json:"output,omitempty" yaml:"output,omitempty"` + StartedAt *time.Time `json:"started_at,omitempty" yaml:"started_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty" yaml:"completed_at,omitempty"` + ID string `json:"id" yaml:"id"` + InstanceID string `json:"stack_instance_id" yaml:"stack_instance_id"` + Action string `json:"action" yaml:"action"` + Status string `json:"status" yaml:"status"` + Output string `json:"output,omitempty" yaml:"output,omitempty"` + ErrorMessage string `json:"error_message,omitempty" yaml:"error_message,omitempty"` + ValuesSnapshot string `json:"values_snapshot,omitempty" yaml:"values_snapshot,omitempty"` + TargetLogID string `json:"target_log_id,omitempty" yaml:"target_log_id,omitempty"` +} + +// RollbackRequest is the request body for POST /api/v1/stack-instances/:id/rollback. +type RollbackRequest struct { + TargetLogID string `json:"target_log_id,omitempty" yaml:"target_log_id,omitempty"` +} + +// DeploymentLogResult holds paginated deployment log results from the backend. +type DeploymentLogResult struct { + Data []DeploymentLog `json:"data"` + Total int64 `json:"total"` + NextCursor string `json:"next_cursor,omitempty"` +} + +// DeployLogValuesResponse holds values snapshot for a deployment log entry. +type DeployLogValuesResponse struct { + LogID string `json:"log_id" yaml:"log_id"` + Values interface{} `json:"values" yaml:"values"` } // ListResponse wraps paginated API responses. diff --git a/cli/test/integration/stack_integration_test.go b/cli/test/integration/stack_integration_test.go index 289d78c..025aae5 100644 --- a/cli/test/integration/stack_integration_test.go +++ b/cli/test/integration/stack_integration_test.go @@ -149,7 +149,7 @@ func startStackMockServer(t *testing.T, state *stackMockState) *httptest.Server state.mu.Unlock() w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(types.DeploymentLog{ - Base: types.Base{ID: "deploy-" + id}, + ID: "deploy-" + id, InstanceID: id, Action: "deploy", Status: "started", @@ -162,7 +162,7 @@ func startStackMockServer(t *testing.T, state *stackMockState) *httptest.Server state.mu.Unlock() w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(types.DeploymentLog{ - Base: types.Base{ID: "stop-" + id}, + ID: "stop-" + id, InstanceID: id, Action: "stop", Status: "started", @@ -175,7 +175,7 @@ func startStackMockServer(t *testing.T, state *stackMockState) *httptest.Server state.mu.Unlock() w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(types.DeploymentLog{ - Base: types.Base{ID: "clean-" + id}, + ID: "clean-" + id, InstanceID: id, Action: "clean", Status: "started", @@ -195,7 +195,7 @@ func startStackMockServer(t *testing.T, state *stackMockState) *httptest.Server case action == "deploy-log" && r.Method == http.MethodGet: w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(types.DeploymentLog{ - Base: types.Base{ID: "log-" + id}, + ID: "log-" + id, InstanceID: id, Action: "deploy", Status: "completed", From dfa723ab8c592f0c712e033a1a2951ac9ac9548a Mon Sep 17 00:00:00 2001 From: Olof Mattsson Date: Mon, 20 Apr 2026 09:35:13 +0200 Subject: [PATCH 2/2] fix: address code review findings for history/rollback commands - Add --quiet support to stackHistoryValuesCmd (prints log ID only) - Replace anonymous struct in RollbackStack with named RollbackResponse type - Fix GetStackLogs to decode DeploymentLogResult (matches backend pagination) - Change DeployLogValuesResponse.Values from interface{} to map[string]interface{} - Add 6 client tests, 8 command tests, and integration test for rollback flow - Update existing test mocks to return DeploymentLogResult wrapper Co-Authored-By: Claude Opus 4.6 --- cli/cmd/stack.go | 11 +- cli/cmd/stack_test.go | 218 +++++++++++++++++- cli/pkg/client/client.go | 18 +- cli/pkg/client/client_test.go | 156 ++++++++++++- cli/pkg/types/types.go | 10 +- .../integration/stack_integration_test.go | 88 ++++++- 6 files changed, 465 insertions(+), 36 deletions(-) diff --git a/cli/cmd/stack.go b/cli/cmd/stack.go index cdb6d6a..57dab9b 100644 --- a/cli/cmd/stack.go +++ b/cli/cmd/stack.go @@ -735,17 +735,17 @@ Examples: targetLog, _ := cmd.Flags().GetString("target-log") req := &types.RollbackRequest{TargetLogID: targetLog} - log, err := c.RollbackStack(id, req) + resp, err := c.RollbackStack(id, req) if err != nil { return err } if printer.Quiet { - fmt.Fprintln(printer.Writer, log.ID) + fmt.Fprintln(printer.Writer, resp.LogID) return nil } - printer.PrintMessage("Rollback started for stack %s (log ID: %s)", id, log.ID) + printer.PrintMessage("Rollback started for stack %s (log ID: %s)", id, resp.LogID) return nil }, } @@ -780,6 +780,11 @@ Examples: return err } + if printer.Quiet { + fmt.Fprintln(printer.Writer, resp.LogID) + return nil + } + switch printer.Format { case output.FormatJSON: return printer.PrintJSON(resp) diff --git a/cli/cmd/stack_test.go b/cli/cmd/stack_test.go index aae4496..9124209 100644 --- a/cli/cmd/stack_test.go +++ b/cli/cmd/stack_test.go @@ -642,12 +642,15 @@ func TestStackLogsCmd_Success(t *testing.T) { require.Equal(t, http.MethodGet, r.Method) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(types.DeploymentLog{ - ID: "200", - InstanceID: "42", - Action: "deploy", - Status: "completed", - Output: "Deployment succeeded.\nAll charts installed.", + json.NewEncoder(w).Encode(types.DeploymentLogResult{ + Data: []types.DeploymentLog{{ + ID: "200", + InstanceID: "42", + Action: "deploy", + Status: "completed", + Output: "Deployment succeeded.\nAll charts installed.", + }}, + Total: 1, }) })) defer server.Close() @@ -674,7 +677,10 @@ func TestStackLogsCmd_JSONOutput(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(logEntry) + json.NewEncoder(w).Encode(types.DeploymentLogResult{ + Data: []types.DeploymentLog{logEntry}, + Total: 1, + }) })) defer server.Close() @@ -822,7 +828,10 @@ func TestStackLogsCmd_QuietOutput(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(types.DeploymentLog{ID: "200"}) + json.NewEncoder(w).Encode(types.DeploymentLogResult{ + Data: []types.DeploymentLog{{ID: "200"}}, + Total: 1, + }) })) defer server.Close() @@ -1001,8 +1010,11 @@ func TestStackLogsCmd_YAMLOutput(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(types.DeploymentLog{ - ID: "200", Action: "deploy", Status: "completed", Output: "OK", + json.NewEncoder(w).Encode(types.DeploymentLogResult{ + Data: []types.DeploymentLog{{ + ID: "200", Action: "deploy", Status: "completed", Output: "OK", + }}, + Total: 1, }) })) defer server.Close() @@ -1609,3 +1621,189 @@ func TestStackDeployCmd_Forbidden(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "Permission denied") } + +// ========== stack history ========== + +func TestStackHistoryCmd_Success(t *testing.T) { + now := time.Date(2025, 6, 15, 10, 0, 0, 0, time.UTC) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v1/stack-instances/42/deploy-log", r.URL.Path) + require.Equal(t, http.MethodGet, r.Method) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(types.DeploymentLogResult{ + Data: []types.DeploymentLog{ + {ID: "300", InstanceID: "42", Action: "deploy", Status: "completed", StartedAt: &now, CompletedAt: &now}, + {ID: "299", InstanceID: "42", Action: "rollback", Status: "completed", StartedAt: &now, CompletedAt: &now}, + }, + Total: 2, + }) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + err := stackHistoryCmd.RunE(stackHistoryCmd, []string{"42"}) + require.NoError(t, err) + + out := buf.String() + assert.Contains(t, out, "LOG ID") + assert.Contains(t, out, "ACTION") + assert.Contains(t, out, "STATUS") + assert.Contains(t, out, "300") + assert.Contains(t, out, "deploy") + assert.Contains(t, out, "299") + assert.Contains(t, out, "rollback") +} + +func TestStackHistoryCmd_JSONOutput(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(types.DeploymentLogResult{ + Data: []types.DeploymentLog{{ID: "300", Action: "deploy", Status: "completed"}}, + Total: 1, + }) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + printer.Format = output.FormatJSON + err := stackHistoryCmd.RunE(stackHistoryCmd, []string{"42"}) + require.NoError(t, err) + + var result types.DeploymentLogResult + require.NoError(t, json.Unmarshal(buf.Bytes(), &result)) + assert.Equal(t, int64(1), result.Total) + assert.Len(t, result.Data, 1) + assert.Equal(t, "300", result.Data[0].ID) +} + +func TestStackHistoryCmd_EmptyHistory(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(types.DeploymentLogResult{Data: nil, Total: 0}) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + err := stackHistoryCmd.RunE(stackHistoryCmd, []string{"42"}) + require.NoError(t, err) + + out := buf.String() + assert.Contains(t, out, "No deployment history for stack 42") +} + +// ========== stack rollback ========== + +func TestStackRollbackCmd_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v1/stack-instances/42/rollback", r.URL.Path) + require.Equal(t, http.MethodPost, r.Method) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(types.RollbackResponse{LogID: "400", Message: "Rollback started"}) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + + stackRollbackCmd.Flags().Set("yes", "true") + t.Cleanup(func() { + stackRollbackCmd.Flags().Set("yes", "false") + stackRollbackCmd.Flags().Set("target-log", "") + }) + + err := stackRollbackCmd.RunE(stackRollbackCmd, []string{"42"}) + require.NoError(t, err) + + out := buf.String() + assert.Contains(t, out, "Rollback started for stack 42") + assert.Contains(t, out, "log ID: 400") +} + +func TestStackRollbackCmd_QuietOutput(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(types.RollbackResponse{LogID: "400", Message: "Rollback started"}) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + printer.Quiet = true + + stackRollbackCmd.Flags().Set("yes", "true") + t.Cleanup(func() { + stackRollbackCmd.Flags().Set("yes", "false") + stackRollbackCmd.Flags().Set("target-log", "") + }) + + err := stackRollbackCmd.RunE(stackRollbackCmd, []string{"42"}) + require.NoError(t, err) + assert.Equal(t, "400\n", buf.String()) +} + +// ========== stack history-values ========== + +func TestStackHistoryValuesCmd_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v1/stack-instances/42/deploy-log/300/values", r.URL.Path) + require.Equal(t, http.MethodGet, r.Method) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(types.DeployLogValuesResponse{ + LogID: "300", + Values: map[string]interface{}{"frontend": map[string]interface{}{"replicas": float64(3)}}, + }) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + printer.Format = output.FormatJSON + err := stackHistoryValuesCmd.RunE(stackHistoryValuesCmd, []string{"42", "300"}) + require.NoError(t, err) + + var result types.DeployLogValuesResponse + require.NoError(t, json.Unmarshal(buf.Bytes(), &result)) + assert.Equal(t, "300", result.LogID) + assert.Contains(t, result.Values, "frontend") +} + +func TestStackHistoryValuesCmd_YAMLOutput(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(types.DeployLogValuesResponse{ + LogID: "300", + Values: map[string]interface{}{"api": map[string]interface{}{"tag": "v1.2"}}, + }) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + printer.Format = output.FormatYAML + err := stackHistoryValuesCmd.RunE(stackHistoryValuesCmd, []string{"42", "300"}) + require.NoError(t, err) + + out := buf.String() + assert.Contains(t, out, "log_id: \"300\"") +} + +func TestStackHistoryValuesCmd_QuietOutput(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(types.DeployLogValuesResponse{ + LogID: "300", + Values: map[string]interface{}{}, + }) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + printer.Quiet = true + err := stackHistoryValuesCmd.RunE(stackHistoryValuesCmd, []string{"42", "300"}) + require.NoError(t, err) + assert.Equal(t, "300\n", buf.String()) +} diff --git a/cli/pkg/client/client.go b/cli/pkg/client/client.go index f798963..b6070c5 100644 --- a/cli/pkg/client/client.go +++ b/cli/pkg/client/client.go @@ -342,12 +342,15 @@ func (c *Client) GetStackStatus(id string) (*types.InstanceStatus, error) { // GetStackLogs returns the latest deployment log for a stack instance. func (c *Client) GetStackLogs(id string) (*types.DeploymentLog, error) { - var log types.DeploymentLog - err := c.Get(fmt.Sprintf("/api/v1/stack-instances/%s/deploy-log", id), &log) + var result types.DeploymentLogResult + err := c.Get(fmt.Sprintf("/api/v1/stack-instances/%s/deploy-log", id), &result) if err != nil { return nil, err } - return &log, nil + if len(result.Data) == 0 { + return nil, fmt.Errorf("no deployment logs found for instance %s", id) + } + return &result.Data[0], nil } // GetDeploymentHistory returns paginated deployment history for a stack instance. @@ -361,16 +364,13 @@ func (c *Client) GetDeploymentHistory(id string, params map[string]string) (*typ } // RollbackStack triggers a rollback for a stack instance. -func (c *Client) RollbackStack(id string, req *types.RollbackRequest) (*types.DeploymentLog, error) { - var resp struct { - LogID string `json:"log_id"` - Message string `json:"message"` - } +func (c *Client) RollbackStack(id string, req *types.RollbackRequest) (*types.RollbackResponse, error) { + var resp types.RollbackResponse err := c.Post(fmt.Sprintf("/api/v1/stack-instances/%s/rollback", id), req, &resp) if err != nil { return nil, err } - return &types.DeploymentLog{ID: resp.LogID, Action: "rollback", Status: "running"}, nil + return &resp, nil } // GetDeployLogValues returns the values snapshot for a specific deployment log entry. diff --git a/cli/pkg/client/client_test.go b/cli/pkg/client/client_test.go index 31de40a..72119e1 100644 --- a/cli/pkg/client/client_test.go +++ b/cli/pkg/client/client_test.go @@ -801,12 +801,15 @@ func TestGetStackLogs_Success(t *testing.T) { assert.Equal(t, http.MethodGet, r.Method) assert.Equal(t, "/api/v1/stack-instances/42/deploy-log", r.URL.Path) w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(types.DeploymentLog{ - ID: "200", - InstanceID: "42", - Action: "deploy", - Status: "completed", - Output: "All charts installed successfully.", + json.NewEncoder(w).Encode(types.DeploymentLogResult{ + Data: []types.DeploymentLog{{ + ID: "200", + InstanceID: "42", + Action: "deploy", + Status: "completed", + Output: "All charts installed successfully.", + }}, + Total: 1, }) })) defer server.Close() @@ -2167,3 +2170,144 @@ func TestClient_EmptyResponseBody(t *testing.T) { assert.Nil(t, stack) assert.Contains(t, err.Error(), "unexpected empty response body") } + +// ---------- Deployment history, rollback, and history-values client methods ---------- + +func TestGetDeploymentHistory_Success(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/api/v1/stack-instances/42/deploy-log", r.URL.Path) + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(types.DeploymentLogResult{ + Data: []types.DeploymentLog{ + {ID: "300", InstanceID: "42", Action: "deploy", Status: "completed"}, + {ID: "299", InstanceID: "42", Action: "rollback", Status: "completed"}, + }, + Total: 2, + NextCursor: "", + }) + })) + defer server.Close() + + c := New(server.URL) + result, err := c.GetDeploymentHistory("42", nil) + require.NoError(t, err) + assert.Len(t, result.Data, 2) + assert.Equal(t, int64(2), result.Total) + assert.Equal(t, "300", result.Data[0].ID) + assert.Equal(t, "rollback", result.Data[1].Action) +} + +func TestGetDeploymentHistory_WithParams(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/stack-instances/42/deploy-log", r.URL.Path) + assert.Equal(t, "10", r.URL.Query().Get("limit")) + assert.Equal(t, "cursor-abc", r.URL.Query().Get("cursor")) + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(types.DeploymentLogResult{ + Data: []types.DeploymentLog{{ID: "301", Action: "deploy", Status: "started"}}, + Total: 1, + NextCursor: "cursor-def", + }) + })) + defer server.Close() + + c := New(server.URL) + result, err := c.GetDeploymentHistory("42", map[string]string{ + "limit": "10", + "cursor": "cursor-abc", + }) + require.NoError(t, err) + assert.Len(t, result.Data, 1) + assert.Equal(t, "cursor-def", result.NextCursor) +} + +func TestRollbackStack_Success(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/v1/stack-instances/42/rollback", r.URL.Path) + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(types.RollbackResponse{ + LogID: "400", + Message: "Rollback started", + }) + })) + defer server.Close() + + c := New(server.URL) + resp, err := c.RollbackStack("42", &types.RollbackRequest{}) + require.NoError(t, err) + assert.Equal(t, "400", resp.LogID) + assert.Equal(t, "Rollback started", resp.Message) +} + +func TestRollbackStack_WithTargetLog(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/v1/stack-instances/42/rollback", r.URL.Path) + + var body types.RollbackRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "prev-log-123", body.TargetLogID) + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(types.RollbackResponse{ + LogID: "401", + Message: "Rollback to prev-log-123 started", + }) + })) + defer server.Close() + + c := New(server.URL) + resp, err := c.RollbackStack("42", &types.RollbackRequest{TargetLogID: "prev-log-123"}) + require.NoError(t, err) + assert.Equal(t, "401", resp.LogID) +} + +func TestGetDeployLogValues_Success(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/api/v1/stack-instances/42/deploy-log/300/values", r.URL.Path) + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(types.DeployLogValuesResponse{ + LogID: "300", + Values: map[string]interface{}{ + "frontend": map[string]interface{}{"replicas": float64(3)}, + "backend": map[string]interface{}{"replicas": float64(1)}, + }, + }) + })) + defer server.Close() + + c := New(server.URL) + resp, err := c.GetDeployLogValues("42", "300") + require.NoError(t, err) + assert.Equal(t, "300", resp.LogID) + assert.Contains(t, resp.Values, "frontend") + assert.Contains(t, resp.Values, "backend") +} + +func TestGetDeployLogValues_NotFound(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/stack-instances/42/deploy-log/999/values", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(types.ErrorResponse{Error: "log entry not found"}) + })) + defer server.Close() + + c := New(server.URL) + resp, err := c.GetDeployLogValues("42", "999") + require.Error(t, err) + assert.Nil(t, resp) + + apiErr, ok := err.(*APIError) + require.True(t, ok) + assert.Equal(t, http.StatusNotFound, apiErr.StatusCode) + assert.Equal(t, "log entry not found", apiErr.Message) +} diff --git a/cli/pkg/types/types.go b/cli/pkg/types/types.go index b86b8ec..8aadabe 100644 --- a/cli/pkg/types/types.go +++ b/cli/pkg/types/types.go @@ -119,6 +119,12 @@ type RollbackRequest struct { TargetLogID string `json:"target_log_id,omitempty" yaml:"target_log_id,omitempty"` } +// RollbackResponse is the response from POST /api/v1/stack-instances/:id/rollback. +type RollbackResponse struct { + LogID string `json:"log_id" yaml:"log_id"` + Message string `json:"message" yaml:"message"` +} + // DeploymentLogResult holds paginated deployment log results from the backend. type DeploymentLogResult struct { Data []DeploymentLog `json:"data"` @@ -128,8 +134,8 @@ type DeploymentLogResult struct { // DeployLogValuesResponse holds values snapshot for a deployment log entry. type DeployLogValuesResponse struct { - LogID string `json:"log_id" yaml:"log_id"` - Values interface{} `json:"values" yaml:"values"` + LogID string `json:"log_id" yaml:"log_id"` + Values map[string]interface{} `json:"values" yaml:"values"` } // ListResponse wraps paginated API responses. diff --git a/cli/test/integration/stack_integration_test.go b/cli/test/integration/stack_integration_test.go index 025aae5..35eb972 100644 --- a/cli/test/integration/stack_integration_test.go +++ b/cli/test/integration/stack_integration_test.go @@ -194,12 +194,15 @@ func startStackMockServer(t *testing.T, state *stackMockState) *httptest.Server // Logs case action == "deploy-log" && r.Method == http.MethodGet: w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(types.DeploymentLog{ - ID: "log-" + id, - InstanceID: id, - Action: "deploy", - Status: "completed", - Output: "Deployment completed successfully.", + json.NewEncoder(w).Encode(types.DeploymentLogResult{ + Data: []types.DeploymentLog{{ + ID: "log-" + id, + InstanceID: id, + Action: "deploy", + Status: "completed", + Output: "Deployment completed successfully.", + }}, + Total: 1, }) // Clone @@ -231,6 +234,38 @@ func startStackMockServer(t *testing.T, state *stackMockState) *httptest.Server w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(inst) + // Rollback + case action == "rollback" && r.Method == http.MethodPost: + var req types.RollbackRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(types.ErrorResponse{Error: "invalid body"}) + return + } + state.mu.Lock() + inst.Status = "rolling-back" + state.mu.Unlock() + + logID := "rollback-" + id + if req.TargetLogID != "" { + logID = "rollback-to-" + req.TargetLogID + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(types.RollbackResponse{ + LogID: logID, + Message: "Rollback started", + }) + + // History (deploy-log with subresource values) + case strings.HasPrefix(action, "deploy-log/") && r.Method == http.MethodGet: + logID := strings.TrimPrefix(action, "deploy-log/") + logID = strings.TrimSuffix(logID, "/values") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(types.DeployLogValuesResponse{ + LogID: logID, + Values: map[string]interface{}{"chart": map[string]interface{}{"replicas": float64(1)}}, + }) + default: w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(types.ErrorResponse{Error: "not found"}) @@ -468,3 +503,44 @@ func TestStackWorkflow_PaginationParams(t *testing.T) { assert.Equal(t, 2, resp.Page) assert.Equal(t, 3, resp.TotalPages) } + +func TestStackWorkflow_RollbackFlow(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + state := newStackMockState() + server := startStackMockServer(t, state) + defer server.Close() + + c := client.New(server.URL) + + // 1. Create and deploy a stack + created, err := c.CreateStack(&types.CreateStackRequest{ + Name: "rollback-stack", + StackDefinitionID: "1", + Branch: "main", + }) + require.NoError(t, err) + id := created.ID + + _, err = c.DeployStack(id) + require.NoError(t, err) + + // 2. Rollback without specifying a target log — previous revision + rollbackResp, err := c.RollbackStack(id, &types.RollbackRequest{}) + require.NoError(t, err) + assert.NotEmpty(t, rollbackResp.LogID) + assert.Equal(t, "Rollback started", rollbackResp.Message) + assert.Equal(t, "rollback-"+id, rollbackResp.LogID) + + // 3. Verify the instance status was updated to rolling-back + status, err := c.GetStackStatus(id) + require.NoError(t, err) + assert.Equal(t, "rolling-back", status.Status) + + // 4. Rollback to a specific deployment log + rollbackResp, err = c.RollbackStack(id, &types.RollbackRequest{TargetLogID: "deploy-" + id}) + require.NoError(t, err) + assert.Equal(t, "rollback-to-deploy-"+id, rollbackResp.LogID) +}