Skip to content

Commit 592cc71

Browse files
authored
Merge pull request #169 from flashcatcloud/fix/list-projection-exit-code
fix(cli): announce truncated structured list pages on stderr
2 parents 22a7f15 + 8a3a83c commit 592cc71

9 files changed

Lines changed: 232 additions & 12 deletions

File tree

cmd/flashduty/main_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ package main
33
import (
44
"bytes"
55
"fmt"
6+
"net/http"
7+
"net/http/httptest"
8+
"os"
69
"os/exec"
710
"path/filepath"
811
"runtime"
@@ -108,3 +111,49 @@ func TestSetVersionInfoBeforeExecute(t *testing.T) {
108111
}
109112
}
110113
}
114+
115+
// Test 79: When a compact list projection overflows its byte budget, the
116+
// binary exits non-zero, writes nothing to stdout, and reports the error on
117+
// stderr — a pipeline reading stdout must see a failed call, never an empty
118+
// page masquerading as "no data".
119+
func TestProjectionOverflowFailsHard(t *testing.T) {
120+
binPath := buildTestBinary(t, "")
121+
122+
// Stub the alert-event list endpoint with a page whose projection stays
123+
// over the 16 KiB budget even after value shortening.
124+
var body strings.Builder
125+
body.WriteString(`{"request_id":"r","error":{"code":"OK","message":""},"data":{"total":100,"items":[`)
126+
for i := 0; i < 100; i++ {
127+
if i > 0 {
128+
body.WriteByte(',')
129+
}
130+
fmt.Fprintf(&body, `{"event_id":"%024x","alert_id":"%024x","event_severity":"Warning","event_status":"Triggered","event_time":1712000000,"title":%q}`,
131+
i, i+1_000_000, strings.Repeat("x", 200))
132+
}
133+
body.WriteString(`]}}`)
134+
135+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
136+
w.Header().Set("Content-Type", "application/json")
137+
_, _ = w.Write([]byte(body.String()))
138+
}))
139+
defer srv.Close()
140+
141+
run := exec.Command(binPath, "alert-event", "list", "--limit", "100",
142+
"--output-format", "json", "--app-key", "test-key", "--base-url", srv.URL)
143+
// Isolate HOME so the test never reads the developer's real CLI config.
144+
run.Env = append(os.Environ(), "HOME="+t.TempDir())
145+
var stdout, stderr bytes.Buffer
146+
run.Stdout = &stdout
147+
run.Stderr = &stderr
148+
149+
err := run.Run()
150+
if err == nil {
151+
t.Fatalf("[#79] expected non-zero exit code for an over-budget projection, got success; stderr:\n%s", stderr.String())
152+
}
153+
if stdout.Len() != 0 {
154+
t.Errorf("[#79] a failed projection must write nothing to stdout, got %d bytes:\n%s", stdout.Len(), stdout.String())
155+
}
156+
if !strings.Contains(stderr.String(), "Error: projected list is") || !strings.Contains(stderr.String(), "exceeds the 16384-byte limit") {
157+
t.Errorf("[#79] stderr should report the byte-limit refusal, got:\n%s", stderr.String())
158+
}
159+
}

internal/cli/alert.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ func newAlertListCmd() *cobra.Command {
9393
if err != nil {
9494
return err
9595
}
96-
return ctx.PrintList(proj, nil, len(result.Items), page, int(result.Total))
96+
return ctx.PrintList(proj, nil, len(result.Items), page, limit, int(result.Total))
9797
}
9898

9999
cols := []output.Column{
@@ -106,7 +106,7 @@ func newAlertListCmd() *cobra.Command {
106106
{Header: "STARTED", Field: func(v any) string { return output.FormatTime(v.(flashduty.AlertItem).StartTime) }},
107107
}
108108

109-
return ctx.PrintList(result.Items, cols, len(result.Items), page, int(result.Total))
109+
return ctx.PrintList(result.Items, cols, len(result.Items), page, limit, int(result.Total))
110110
})
111111
},
112112
}

internal/cli/alert_event.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,10 @@ func newAlertEventListCmd() *cobra.Command {
102102
return err
103103
}
104104
noteProjectionShortening(cmd.ErrOrStderr(), note)
105-
return ctx.PrintList(proj, nil, len(result.Items), page, int(result.Total))
105+
return ctx.PrintList(proj, nil, len(result.Items), page, limit, int(result.Total))
106106
}
107107

108-
return ctx.PrintList(result.Items, cols, len(result.Items), page, int(result.Total))
108+
return ctx.PrintList(result.Items, cols, len(result.Items), page, limit, int(result.Total))
109109
})
110110
},
111111
}

internal/cli/audit.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ func newAuditSearchCmd() *cobra.Command {
9696
}},
9797
}
9898

99-
return ctx.PrintList(result.Docs, cols, len(result.Docs), page, int(result.Total))
99+
return ctx.PrintList(result.Docs, cols, len(result.Docs), page, limit, int(result.Total))
100100
})
101101
},
102102
}

internal/cli/change.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ func newChangeListCmd() *cobra.Command {
8686
{Header: "TIME", Field: func(v any) string { return output.FormatTime(v.(flashduty.ChangeItem).StartTime) }},
8787
}
8888

89-
return ctx.PrintList(result.Items, cols, len(result.Items), page, int(result.Total))
89+
return ctx.PrintList(result.Items, cols, len(result.Items), page, limit, int(result.Total))
9090
})
9191
},
9292
}

internal/cli/command.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,23 @@ func runCommand(cmd *cobra.Command, args []string, fn func(ctx *RunContext) erro
4848
}
4949

5050
// PrintList prints items as a table and appends a "Showing N results (page P, total T)." footer.
51-
func (ctx *RunContext) PrintList(items any, cols []output.Column, count, page, total int) error {
51+
// In structured mode the footer is suppressed to keep stdout byte-pure for
52+
// jq/toon pipelines, so a page with more rows beyond it is announced on
53+
// stderr instead — without it a consumer sees a partial page
54+
// (e.g. the default --limit 20 of a far larger total) as the whole set. The
55+
// judgment accounts for the page offset: on the last page
56+
// ((page-1)*limit+count reaches total) there is no rest, so no note.
57+
func (ctx *RunContext) PrintList(items any, cols []output.Column, count, page, limit, total int) error {
5258
if err := ctx.Printer.Print(items, cols); err != nil {
5359
return err
5460
}
55-
if !ctx.Structured() {
56-
_, _ = fmt.Fprintf(ctx.Writer, "Showing %d results (page %d, total %d).\n", count, page, total)
61+
if ctx.Structured() {
62+
if (page-1)*limit+count < total {
63+
_, _ = fmt.Fprintf(ctx.Cmd.ErrOrStderr(), "note: showing %d of %d total results (page %d); raise --limit or use --page for the rest\n", count, total, page)
64+
}
65+
return nil
5766
}
67+
_, _ = fmt.Fprintf(ctx.Writer, "Showing %d results (page %d, total %d).\n", count, page, total)
5868
return nil
5969
}
6070

internal/cli/command_test.go

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1599,6 +1599,167 @@ type readerFunc func([]byte) (int, error)
15991599

16001600
func (f readerFunc) Read(p []byte) (int, error) { return f(p) }
16011601

1602+
// ---------------------------------------------------------------------------
1603+
// Structured list truncation indicator
1604+
// ---------------------------------------------------------------------------
1605+
1606+
// TestCommandAlertListStructuredAnnouncesTruncation pins that a structured
1607+
// list page which doesn't cover the server-reported total says so on stderr:
1608+
// stdout is reserved for the jq/toon pipeline, so without the note a consumer
1609+
// sees the default --limit page as the whole set. Table mode keeps its
1610+
// "Showing N results" footer on stdout and emits no stderr note.
1611+
func TestCommandAlertListStructuredAnnouncesTruncation(t *testing.T) {
1612+
twoOfFive := map[string]any{"items": []any{alertRow(), alertRow()}, "total": 5}
1613+
1614+
t.Run("json page short of total", func(t *testing.T) {
1615+
saveAndResetGlobals(t)
1616+
stub := newGFStub(t)
1617+
stub.data = twoOfFive
1618+
1619+
out, stderrText, err := execCommandSplit("alert", "list", "--output-format", "json")
1620+
if err != nil {
1621+
t.Fatalf("execCommandSplit: %v", err)
1622+
}
1623+
var rows []map[string]any
1624+
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &rows); err != nil {
1625+
t.Fatalf("stdout must stay parseable JSON: %v\n%s", err, out)
1626+
}
1627+
if len(rows) != 2 {
1628+
t.Fatalf("got %d rows, want the 2 the page carries", len(rows))
1629+
}
1630+
if !strings.Contains(stderrText, "note: showing 2 of 5 total results (page 1)") {
1631+
t.Errorf("truncated structured page should announce itself on stderr, got:\n%s", stderrText)
1632+
}
1633+
})
1634+
1635+
t.Run("json page covers total", func(t *testing.T) {
1636+
saveAndResetGlobals(t)
1637+
stub := newGFStub(t)
1638+
stub.data = map[string]any{"items": []any{alertRow(), alertRow()}, "total": 2}
1639+
1640+
_, stderrText, err := execCommandSplit("alert", "list", "--output-format", "json")
1641+
if err != nil {
1642+
t.Fatalf("execCommandSplit: %v", err)
1643+
}
1644+
if strings.Contains(stderrText, "note: showing") {
1645+
t.Errorf("a page covering the total must not cry truncation, got:\n%s", stderrText)
1646+
}
1647+
})
1648+
1649+
t.Run("table keeps the footer on stdout", func(t *testing.T) {
1650+
saveAndResetGlobals(t)
1651+
stub := newGFStub(t)
1652+
stub.data = twoOfFive
1653+
1654+
out, stderrText, err := execCommandSplit("alert", "list")
1655+
if err != nil {
1656+
t.Fatalf("execCommandSplit: %v", err)
1657+
}
1658+
if !strings.Contains(out, "Showing 2 results (page 1, total 5).") {
1659+
t.Errorf("table mode should keep the stdout footer, got:\n%s", out)
1660+
}
1661+
if strings.Contains(stderrText, "note: showing") {
1662+
t.Errorf("table mode already footers the count; no stderr note wanted, got:\n%s", stderrText)
1663+
}
1664+
})
1665+
1666+
// The truncation judgment must account for the page offset: page 2 of a
1667+
// total 40 at --limit 20 IS the last page — there is no rest, so no note.
1668+
fullPage := make([]any, 20)
1669+
for i := range fullPage {
1670+
fullPage[i] = alertRow()
1671+
}
1672+
lastPage := map[string]any{"items": fullPage, "total": 40}
1673+
1674+
t.Run("json last page prints no note", func(t *testing.T) {
1675+
saveAndResetGlobals(t)
1676+
stub := newGFStub(t)
1677+
stub.data = lastPage
1678+
1679+
_, stderrText, err := execCommandSplit("alert", "list", "--limit", "20", "--page", "2", "--output-format", "json")
1680+
if err != nil {
1681+
t.Fatalf("execCommandSplit: %v", err)
1682+
}
1683+
if strings.Contains(stderrText, "note: showing") {
1684+
t.Errorf("the last page has no rest to page for; no note wanted, got:\n%s", stderrText)
1685+
}
1686+
})
1687+
1688+
t.Run("json first page of same total still notes", func(t *testing.T) {
1689+
saveAndResetGlobals(t)
1690+
stub := newGFStub(t)
1691+
stub.data = lastPage
1692+
1693+
_, stderrText, err := execCommandSplit("alert", "list", "--limit", "20", "--page", "1", "--output-format", "json")
1694+
if err != nil {
1695+
t.Fatalf("execCommandSplit: %v", err)
1696+
}
1697+
if !strings.Contains(stderrText, "note: showing 20 of 40 total results (page 1)") {
1698+
t.Errorf("page 1 with a page 2 beyond it should announce itself on stderr, got:\n%s", stderrText)
1699+
}
1700+
})
1701+
}
1702+
1703+
// ---------------------------------------------------------------------------
1704+
// Projection overflow is a hard failure
1705+
// ---------------------------------------------------------------------------
1706+
1707+
// TestCommandListProjectionOverflowFails pins that a compact list projection
1708+
// which cannot fit the byte budget fails the command instead of emitting
1709+
// anything: Execute returns the error and stdout stays empty, so a pipeline
1710+
// reading stdout sees a failed call, never an empty page masquerading as
1711+
// "no data".
1712+
func TestCommandListProjectionOverflowFails(t *testing.T) {
1713+
t.Run("incident list", func(t *testing.T) {
1714+
saveAndResetGlobals(t)
1715+
stub := newGFStub(t)
1716+
items := make([]any, 100)
1717+
for i := range items {
1718+
row := incidentRow()
1719+
row["incident_id"] = fmt.Sprintf("inc-%024d", i)
1720+
row["title"] = strings.Repeat("x", 200)
1721+
items[i] = row
1722+
}
1723+
stub.data = map[string]any{"items": items, "total": len(items)}
1724+
1725+
out, stderrText, err := execCommandSplit("incident", "list", "--limit", "100", "--output-format", "json")
1726+
if err == nil || !strings.Contains(err.Error(), "exceeds the 16384-byte limit") {
1727+
t.Fatalf("irreducible projection error = %v, want the byte-limit refusal", err)
1728+
}
1729+
if out != "" {
1730+
t.Errorf("a failed projection must write nothing to stdout, got %d bytes", len(out))
1731+
}
1732+
if strings.Contains(stderrText, "exceeds the") {
1733+
t.Errorf("the error is returned for the entrypoint to report, not printed mid-run, got:\n%s", stderrText)
1734+
}
1735+
})
1736+
1737+
t.Run("alert-event list", func(t *testing.T) {
1738+
saveAndResetGlobals(t)
1739+
stub := newGFStub(t)
1740+
items := make([]any, 100)
1741+
for i := range items {
1742+
items[i] = map[string]any{
1743+
"event_id": fmt.Sprintf("%024x", i),
1744+
"alert_id": fmt.Sprintf("%024x", i+1_000_000),
1745+
"event_severity": "Warning",
1746+
"event_status": "Triggered",
1747+
"event_time": 1712000000 + i,
1748+
"title": strings.Repeat("x", 200),
1749+
}
1750+
}
1751+
stub.data = map[string]any{"items": items, "total": len(items)}
1752+
1753+
out, _, err := execCommandSplit("alert-event", "list", "--limit", "100", "--output-format", "json")
1754+
if err == nil || !strings.Contains(err.Error(), "exceeds the 16384-byte limit") {
1755+
t.Fatalf("irreducible projection error = %v, want the byte-limit refusal", err)
1756+
}
1757+
if out != "" {
1758+
t.Errorf("a failed projection must write nothing to stdout, got %d bytes", len(out))
1759+
}
1760+
})
1761+
}
1762+
16021763
// ---------------------------------------------------------------------------
16031764
// Helpers
16041765
// ---------------------------------------------------------------------------

internal/cli/incident.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,10 +142,10 @@ func newIncidentListCmd() *cobra.Command {
142142
return err
143143
}
144144
noteProjectionShortening(cmd.ErrOrStderr(), note)
145-
return ctx.PrintList(proj, nil, len(result.Items), page, int(result.Total))
145+
return ctx.PrintList(proj, nil, len(result.Items), page, limit, int(result.Total))
146146
}
147147

148-
return ctx.PrintList(result.Items, incidentColumns(), len(result.Items), page, int(result.Total))
148+
return ctx.PrintList(result.Items, incidentColumns(), len(result.Items), page, limit, int(result.Total))
149149
})
150150
},
151151
}

internal/cli/insight.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ func newInsightIncidentsCmd() *cobra.Command {
131131
}},
132132
}
133133

134-
return ctx.PrintList(result.Items, cols, len(result.Items), page, int(result.Total))
134+
return ctx.PrintList(result.Items, cols, len(result.Items), page, limit, int(result.Total))
135135
})
136136
},
137137
}

0 commit comments

Comments
 (0)