diff --git a/cmd/root.go b/cmd/root.go index 51af5fb8..cc41c411 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -261,6 +261,7 @@ func isLoadSettings(cmd *cobra.Command) bool { "cre login": {}, "cre logout": {}, "cre whoami": {}, + "cre workflow logs": {}, "cre account list-key": {}, "cre init": {}, "cre generate-bindings": {}, diff --git a/cmd/workflow/logs/logs.go b/cmd/workflow/logs/logs.go new file mode 100644 index 00000000..87bb98b6 --- /dev/null +++ b/cmd/workflow/logs/logs.go @@ -0,0 +1,316 @@ +package logs + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/machinebox/graphql" + "github.com/rs/zerolog" + "github.com/spf13/cobra" + + "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/environments" + "github.com/smartcontractkit/cre-cli/internal/runtime" +) + +const pollInterval = 5 * time.Second + +func New(runtimeContext *runtime.Context) *cobra.Command { + var follow bool + var limit int + + logsCmd := &cobra.Command{ + Use: "logs ", + Short: "Show execution history for a workflow", + Long: "Fetches and displays recent execution history for the specified workflow from the CRE platform.", + Args: cobra.ExactArgs(1), + Example: ` cre workflow logs my-workflow + cre workflow logs my-workflow --follow + cre workflow logs my-workflow --limit 5`, + RunE: func(cmd *cobra.Command, args []string) error { + h := newHandler(runtimeContext, args[0], follow, limit) + return h.Execute(cmd.Context()) + }, + } + + logsCmd.Flags().BoolVarP(&follow, "follow", "f", false, "Keep polling for new executions") + logsCmd.Flags().IntVarP(&limit, "limit", "n", 10, "Number of recent executions to show") + + return logsCmd +} + +type handler struct { + log *zerolog.Logger + credentials *credentials.Credentials + environmentSet *environments.EnvironmentSet + workflowName string + follow bool + limit int +} + +func newHandler(ctx *runtime.Context, workflowName string, follow bool, limit int) *handler { + return &handler{ + log: ctx.Logger, + credentials: ctx.Credentials, + environmentSet: ctx.EnvironmentSet, + workflowName: workflowName, + follow: follow, + limit: limit, + } +} + +// GraphQL response types + +type workflowsResponse struct { + Workflows struct { + Data []workflowEntry `json:"data"` + Count int `json:"count"` + } `json:"workflows"` +} + +type workflowEntry struct { + UUID string `json:"uuid"` + Name string `json:"name"` + Status string `json:"status"` +} + +type executionsResponse struct { + WorkflowExecutions struct { + Data []execution `json:"data"` + Count int `json:"count"` + } `json:"workflowExecutions"` +} + +type execution struct { + UUID string `json:"uuid"` + Status string `json:"status"` + StartedAt time.Time `json:"startedAt"` + FinishedAt *time.Time `json:"finishedAt"` +} + +type eventsResponse struct { + WorkflowExecutionEvents struct { + Data []executionEvent `json:"data"` + } `json:"workflowExecutionEvents"` +} + +type executionEvent struct { + CapabilityID string `json:"capabilityID"` + Status string `json:"status"` + Errors []capError `json:"errors"` +} + +type capError struct { + Error string `json:"error"` + Count int `json:"count"` +} + +func (h *handler) Execute(ctx context.Context) error { + client := graphqlclient.New(h.credentials, h.environmentSet, h.log) + + workflowUUID, err := h.findWorkflow(ctx, client) + if err != nil { + return err + } + + fmt.Printf("\nWorkflow: %s\n\n", h.workflowName) + + executions, err := h.fetchExecutions(ctx, client, workflowUUID) + if err != nil { + return err + } + + headerPrinted := false + if len(executions) == 0 && !h.follow { + fmt.Println("No executions found.") + return nil + } + + if len(executions) > 0 { + printHeader() + headerPrinted = true + h.printExecutions(ctx, client, executions) + } + + if !h.follow { + return nil + } + + if !headerPrinted { + fmt.Println("Waiting for executions...") + } + + lastSeenUUID := "" + if len(executions) > 0 { + lastSeenUUID = executions[0].UUID + } + + for { + select { + case <-time.After(pollInterval): + case <-ctx.Done(): + return nil + } + + executions, err = h.fetchExecutions(ctx, client, workflowUUID) + if err != nil { + h.log.Error().Err(err).Msg("failed to fetch executions, retrying") + continue + } + + newExecs := filterNew(executions, lastSeenUUID) + if len(newExecs) > 0 { + if !headerPrinted { + printHeader() + headerPrinted = true + } + h.printExecutions(ctx, client, newExecs) + lastSeenUUID = executions[0].UUID + } + } +} + +func (h *handler) findWorkflow(ctx context.Context, client *graphqlclient.Client) (string, error) { + req := graphql.NewRequest(`query FindWorkflow($input: WorkflowsInput!) { + workflows(input: $input) { + data { uuid name status } + count + } + }`) + req.Var("input", map[string]any{ + "search": h.workflowName, + "page": map[string]int{"number": 0, "size": 20}, + }) + + var resp workflowsResponse + if err := client.Execute(ctx, req, &resp); err != nil { + return "", fmt.Errorf("failed to search for workflow: %w", err) + } + + for _, w := range resp.Workflows.Data { + if w.Name == h.workflowName { + return w.UUID, nil + } + } + + if len(resp.Workflows.Data) == 0 { + return "", fmt.Errorf("no workflow found matching %q", h.workflowName) + } + + names := make([]string, len(resp.Workflows.Data)) + for i, w := range resp.Workflows.Data { + names[i] = w.Name + } + return "", fmt.Errorf("no exact match for %q; found: %s", h.workflowName, strings.Join(names, ", ")) +} + +func (h *handler) fetchExecutions(ctx context.Context, client *graphqlclient.Client, workflowUUID string) ([]execution, error) { + req := graphql.NewRequest(`query GetExecutions($input: WorkflowExecutionsInput!) { + workflowExecutions(input: $input) { + data { uuid status startedAt finishedAt } + count + } + }`) + req.Var("input", map[string]any{ + "workflowUuid": workflowUUID, + "orderBy": map[string]string{"field": "STARTED_AT", "order": "DESC"}, + "page": map[string]int{"number": 0, "size": h.limit}, + }) + + var resp executionsResponse + if err := client.Execute(ctx, req, &resp); err != nil { + return nil, fmt.Errorf("failed to fetch executions: %w", err) + } + + return resp.WorkflowExecutions.Data, nil +} + +// filterNew returns executions that are newer than lastSeenUUID. +// Executions are expected in DESC order (newest first). +func filterNew(executions []execution, lastSeenUUID string) []execution { + if lastSeenUUID == "" { + return executions + } + for i, e := range executions { + if e.UUID == lastSeenUUID { + return executions[:i] + } + } + // lastSeenUUID not found in current page, all are new + return executions +} + +func printHeader() { + fmt.Printf("%-24s %-12s %-10s %s\n", "TIMESTAMP", "STATUS", "DURATION", "EXECUTION ID") +} + +func (h *handler) printExecutions(ctx context.Context, client *graphqlclient.Client, executions []execution) { + // Print oldest first (executions are in DESC order) + for i := len(executions) - 1; i >= 0; i-- { + e := executions[i] + duration := "running" + if e.FinishedAt != nil { + duration = formatDuration(e.FinishedAt.Sub(e.StartedAt)) + } + + fmt.Printf("%-24s %-12s %-10s %s\n", + e.StartedAt.Format(time.RFC3339), + strings.ToLower(e.Status), + duration, + shortUUID(e.UUID), + ) + + if e.Status == "FAILURE" { + h.printErrors(ctx, client, e.UUID) + } + } +} + +func (h *handler) printErrors(ctx context.Context, client *graphqlclient.Client, executionUUID string) { + req := graphql.NewRequest(`query GetEvents($input: WorkflowExecutionEventsInput!) { + workflowExecutionEvents(input: $input) { + data { capabilityID status errors { error count } } + } + }`) + req.Var("input", map[string]any{ + "workflowExecutionUUID": executionUUID, + }) + + var resp eventsResponse + if err := client.Execute(ctx, req, &resp); err != nil { + h.log.Debug().Err(err).Msg("failed to fetch execution events") + return + } + + for _, ev := range resp.WorkflowExecutionEvents.Data { + if ev.Status == "failure" && len(ev.Errors) > 0 { + errMsg := ev.Errors[0].Error + if len(errMsg) > 120 { + tail := errMsg[len(errMsg)-len(errMsg)*2/5:] // last 40% + head := 120 - len(tail) - 3 + if head < 0 { + head = 0 + } + errMsg = errMsg[:head] + "..." + tail + } + fmt.Printf(" -> %s: %s\n", ev.CapabilityID, errMsg) + } + } +} + +func formatDuration(d time.Duration) string { + if d < time.Second { + return fmt.Sprintf("%dms", d.Milliseconds()) + } + return fmt.Sprintf("%.1fs", d.Seconds()) +} + +func shortUUID(uuid string) string { + if len(uuid) >= 8 { + return uuid[:8] + } + return uuid +} diff --git a/cmd/workflow/logs/logs_test.go b/cmd/workflow/logs/logs_test.go new file mode 100644 index 00000000..c87ba2fd --- /dev/null +++ b/cmd/workflow/logs/logs_test.go @@ -0,0 +1,378 @@ +package logs + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/environments" +) + +func TestFilterNew(t *testing.T) { + t.Parallel() + + execs := []execution{ + {UUID: "aaa"}, + {UUID: "bbb"}, + {UUID: "ccc"}, + } + + tests := []struct { + name string + executions []execution + lastSeenUUID string + wantUUIDs []string + }{ + { + name: "no last seen returns all", + executions: execs, + lastSeenUUID: "", + wantUUIDs: []string{"aaa", "bbb", "ccc"}, + }, + { + name: "last seen is newest returns empty", + executions: execs, + lastSeenUUID: "aaa", + wantUUIDs: nil, + }, + { + name: "last seen in middle returns newer", + executions: execs, + lastSeenUUID: "bbb", + wantUUIDs: []string{"aaa"}, + }, + { + name: "last seen is oldest returns all but last", + executions: execs, + lastSeenUUID: "ccc", + wantUUIDs: []string{"aaa", "bbb"}, + }, + { + name: "last seen not in page returns all", + executions: execs, + lastSeenUUID: "zzz", + wantUUIDs: []string{"aaa", "bbb", "ccc"}, + }, + { + name: "empty executions", + executions: nil, + lastSeenUUID: "aaa", + wantUUIDs: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := filterNew(tt.executions, tt.lastSeenUUID) + var gotUUIDs []string + for _, e := range result { + gotUUIDs = append(gotUUIDs, e.UUID) + } + assert.Equal(t, tt.wantUUIDs, gotUUIDs) + }) + } +} + +func TestFormatDuration(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + duration time.Duration + want string + }{ + {"zero", 0, "0ms"}, + {"sub-second", 500 * time.Millisecond, "500ms"}, + {"one second", time.Second, "1.0s"}, + {"fractional seconds", 2300 * time.Millisecond, "2.3s"}, + {"large", 90 * time.Second, "90.0s"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, formatDuration(tt.duration)) + }) + } +} + +func TestShortUUID(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + uuid string + want string + }{ + {"full uuid", "67e9ddbb-6531-4621-b990-3fdb7b518846", "67e9ddbb"}, + {"exactly 8", "12345678", "12345678"}, + {"short", "abc", "abc"}, + {"empty", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, shortUUID(tt.uuid)) + }) + } +} + +// TestExecute tests are not parallel because they capture os.Stdout. +func TestExecute(t *testing.T) { + t.Run("shows executions with error details", func(t *testing.T) { + ts := newMockGraphQL(t, mockConfig{ + workflows: []map[string]any{ + {"uuid": "wf-1", "name": "test-workflow", "status": "ACTIVE"}, + }, + executions: []map[string]any{ + { + "uuid": "exec-2", + "status": "FAILURE", + "startedAt": "2026-02-12T16:01:00Z", + "finishedAt": "2026-02-12T16:01:01Z", + }, + { + "uuid": "exec-1", + "status": "SUCCESS", + "startedAt": "2026-02-12T16:00:00Z", + "finishedAt": "2026-02-12T16:00:02Z", + }, + }, + events: []map[string]any{ + { + "capabilityID": "confidential-http@1.0.0", + "status": "failure", + "errors": []map[string]any{{"error": "connection refused", "count": 7}}, + }, + }, + }) + defer ts.Close() + + output := captureStdout(t, func() { + h := newTestHandler(ts.URL, "test-workflow", false, 10) + err := h.Execute(context.Background()) + require.NoError(t, err) + }) + + assert.Contains(t, output, "test-workflow") + assert.Contains(t, output, "TIMESTAMP") + assert.Contains(t, output, "success") + assert.Contains(t, output, "failure") + assert.Contains(t, output, "connection refused") + assert.Contains(t, output, "confidential-http@1.0.0") + + // Verify chronological order (oldest first) + successIdx := strings.Index(output, "success") + failureIdx := strings.Index(output, "failure") + assert.Greater(t, failureIdx, successIdx, "oldest execution should appear first") + }) + + t.Run("long error is truncated keeping tail", func(t *testing.T) { + longErr := "failed to execute enclave request. enclave ID: abc123, error: attestation validation failed for ExecuteBatch: expected PCR0 deadbeef, got cafebabe" + ts := newMockGraphQL(t, mockConfig{ + workflows: []map[string]any{ + {"uuid": "wf-1", "name": "test-workflow", "status": "ACTIVE"}, + }, + executions: []map[string]any{ + { + "uuid": "exec-1", + "status": "FAILURE", + "startedAt": "2026-02-12T16:00:00Z", + "finishedAt": "2026-02-12T16:00:01Z", + }, + }, + events: []map[string]any{ + { + "capabilityID": "confidential-http@1.0.0", + "status": "failure", + "errors": []map[string]any{{"error": longErr, "count": 1}}, + }, + }, + }) + defer ts.Close() + + output := captureStdout(t, func() { + h := newTestHandler(ts.URL, "test-workflow", false, 10) + err := h.Execute(context.Background()) + require.NoError(t, err) + }) + + // Head (beginning) should be present + assert.Contains(t, output, "failed to execute enclave") + // Tail (last 40%) should survive truncation + assert.Contains(t, output, "expected PCR0 deadbeef, got cafebabe") + // Middle should be elided + assert.Contains(t, output, "...") + }) + + t.Run("workflow not found", func(t *testing.T) { + ts := newMockGraphQL(t, mockConfig{}) + defer ts.Close() + + h := newTestHandler(ts.URL, "nonexistent", false, 10) + err := h.Execute(context.Background()) + + require.Error(t, err) + assert.Contains(t, err.Error(), "no workflow found") + }) + + t.Run("partial name match shows suggestions", func(t *testing.T) { + ts := newMockGraphQL(t, mockConfig{ + workflows: []map[string]any{ + {"uuid": "wf-1", "name": "my-workflow-staging", "status": "ACTIVE"}, + {"uuid": "wf-2", "name": "my-workflow-prod", "status": "ACTIVE"}, + }, + }) + defer ts.Close() + + h := newTestHandler(ts.URL, "my-workflow", false, 10) + err := h.Execute(context.Background()) + + require.Error(t, err) + assert.Contains(t, err.Error(), "no exact match") + assert.Contains(t, err.Error(), "my-workflow-staging") + assert.Contains(t, err.Error(), "my-workflow-prod") + }) + + t.Run("no executions", func(t *testing.T) { + ts := newMockGraphQL(t, mockConfig{ + workflows: []map[string]any{ + {"uuid": "wf-1", "name": "test-workflow", "status": "ACTIVE"}, + }, + }) + defer ts.Close() + + output := captureStdout(t, func() { + h := newTestHandler(ts.URL, "test-workflow", false, 10) + err := h.Execute(context.Background()) + require.NoError(t, err) + }) + + assert.Contains(t, output, "No executions found") + }) + + t.Run("graphql error", func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "errors": []map[string]any{{"message": "unauthorized"}}, + }) + })) + defer ts.Close() + + h := newTestHandler(ts.URL, "test-workflow", false, 10) + err := h.Execute(context.Background()) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to search for workflow") + }) +} + +// Test helpers + +type mockConfig struct { + workflows []map[string]any + executions []map[string]any + events []map[string]any +} + +func newMockGraphQL(t *testing.T, cfg mockConfig) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + bodyStr := string(body) + w.Header().Set("Content-Type", "application/json") + + switch { + case strings.Contains(bodyStr, "FindWorkflow"): + wfs := cfg.workflows + if wfs == nil { + wfs = []map[string]any{} + } + json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "workflows": map[string]any{ + "data": wfs, + "count": len(wfs), + }, + }, + }) + + case strings.Contains(bodyStr, "GetExecutions"): + execs := cfg.executions + if execs == nil { + execs = []map[string]any{} + } + json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "workflowExecutions": map[string]any{ + "data": execs, + "count": len(execs), + }, + }, + }) + + case strings.Contains(bodyStr, "GetEvents"): + evts := cfg.events + if evts == nil { + evts = []map[string]any{} + } + json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "workflowExecutionEvents": map[string]any{ + "data": evts, + }, + }, + }) + + default: + http.Error(w, "unexpected query", http.StatusBadRequest) + } + })) +} + +func newTestHandler(serverURL, workflowName string, follow bool, limit int) *handler { + logger := zerolog.New(io.Discard) + return &handler{ + log: &logger, + credentials: &credentials.Credentials{}, + environmentSet: &environments.EnvironmentSet{ + GraphQLURL: serverURL, + }, + workflowName: workflowName, + follow: follow, + limit: limit, + } +} + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + oldStdout := os.Stdout + r, w, err := os.Pipe() + require.NoError(t, err) + + os.Stdout = w + t.Cleanup(func() { os.Stdout = oldStdout }) + + fn() + + w.Close() + os.Stdout = oldStdout + + var buf strings.Builder + _, _ = io.Copy(&buf, r) + return buf.String() +} diff --git a/cmd/workflow/workflow.go b/cmd/workflow/workflow.go index 72e5b699..7828d575 100644 --- a/cmd/workflow/workflow.go +++ b/cmd/workflow/workflow.go @@ -6,6 +6,7 @@ import ( "github.com/smartcontractkit/cre-cli/cmd/workflow/activate" "github.com/smartcontractkit/cre-cli/cmd/workflow/delete" "github.com/smartcontractkit/cre-cli/cmd/workflow/deploy" + "github.com/smartcontractkit/cre-cli/cmd/workflow/logs" "github.com/smartcontractkit/cre-cli/cmd/workflow/pause" "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate" "github.com/smartcontractkit/cre-cli/cmd/workflow/test" @@ -21,10 +22,11 @@ func New(runtimeContext *runtime.Context) *cobra.Command { workflowCmd.AddCommand(activate.New(runtimeContext)) workflowCmd.AddCommand(delete.New(runtimeContext)) - workflowCmd.AddCommand(pause.New(runtimeContext)) - workflowCmd.AddCommand(test.New(runtimeContext)) workflowCmd.AddCommand(deploy.New(runtimeContext)) + workflowCmd.AddCommand(logs.New(runtimeContext)) + workflowCmd.AddCommand(pause.New(runtimeContext)) workflowCmd.AddCommand(simulate.New(runtimeContext)) + workflowCmd.AddCommand(test.New(runtimeContext)) return workflowCmd } diff --git a/docs/cre_workflow.md b/docs/cre_workflow.md index a5b83833..51cb3629 100644 --- a/docs/cre_workflow.md +++ b/docs/cre_workflow.md @@ -31,6 +31,7 @@ cre workflow [optional flags] * [cre workflow activate](cre_workflow_activate.md) - Activates workflow on the Workflow Registry contract * [cre workflow delete](cre_workflow_delete.md) - Deletes all versions of a workflow from the Workflow Registry * [cre workflow deploy](cre_workflow_deploy.md) - Deploys a workflow to the Workflow Registry contract +* [cre workflow logs](cre_workflow_logs.md) - Show execution history for a workflow * [cre workflow pause](cre_workflow_pause.md) - Pauses workflow on the Workflow Registry contract * [cre workflow simulate](cre_workflow_simulate.md) - Simulates a workflow diff --git a/docs/cre_workflow_logs.md b/docs/cre_workflow_logs.md new file mode 100644 index 00000000..6b234a4a --- /dev/null +++ b/docs/cre_workflow_logs.md @@ -0,0 +1,41 @@ +## cre workflow logs + +Show execution history for a workflow + +### Synopsis + +Fetches and displays recent execution history for the specified workflow from the CRE platform. + +``` +cre workflow logs [optional flags] +``` + +### Examples + +``` + cre workflow logs my-workflow + cre workflow logs my-workflow --follow + cre workflow logs my-workflow --limit 5 +``` + +### Options + +``` + -f, --follow Keep polling for new executions + -h, --help help for logs + -n, --limit int Number of recent executions to show (default 10) +``` + +### Options inherited from parent commands + +``` + -e, --env string Path to .env file which contains sensitive info (default ".env") + -R, --project-root string Path to the project root + -T, --target string Use target settings from YAML config + -v, --verbose Run command in VERBOSE mode +``` + +### SEE ALSO + +* [cre workflow](cre_workflow.md) - Manages workflows +