console: GET /api/cli/apps/{account}/{app}/logs — the carlos logs read
applog.ReadWindow (hour-clipped, cross-box merge) + Cursor for follow; handler shares instanceGate (app tokens may read their own logs), tails the window by default, pages strictly-after with an opaque cursor, 1h default window, truncation always stated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
7 files changed,
+594
−0
internal/applog/cursor.go+49 −0internal/applog/cursor_test.go+35 −0internal/applog/read.go+97 −0internal/applog/read_test.go+106 −0internal/console/apilogs.go+153 −0internal/console/apilogs_test.go+151 −0internal/console/authcli.go+3 −0
diff --git a/internal/applog/cursor.go b/internal/applog/cursor.go| new file mode 100644 |
| index 000000000..3748ca849 |
| --- /dev/null |
| +++ b/internal/applog/cursor.go |
| @@ -0,0 +1,49 @@ |
| +package applog |
| + |
| +import ( |
| + "encoding/base64" |
| + "encoding/json" |
| + "time" |
| +) |
| + |
| +// Cursor names a position in the merged timeline — the last record a reader |
| +// has seen, by exactly the (TS, Box, Seq) triple MergeRecords orders by. |
| +// Opaque base64 on the wire so its shape is the server's to evolve. |
| +type Cursor struct { |
| + TS time.Time `json:"ts"` |
| + Box string `json:"box"` |
| + Seq uint64 `json:"seq"` |
| +} |
| + |
| +// Before reports whether c sits strictly before r in merge order — the |
| +// follow test: records with c.Before(r) are new since the cursor. |
| +func (c Cursor) Before(r Record) bool { |
| + if !c.TS.Equal(r.TS) { |
| + return c.TS.Before(r.TS) |
| + } |
| + if c.Box != r.Box { |
| + return c.Box < r.Box |
| + } |
| + return c.Seq < r.Seq |
| +} |
| + |
| +// EncodeCursor renders the position just after r. |
| +func EncodeCursor(r Record) string { |
| + b, _ := json.Marshal(Cursor{TS: r.TS, Box: r.Box, Seq: r.Seq}) // cannot fail on this shape |
| + return base64.RawURLEncoding.EncodeToString(b) |
| +} |
| + |
| +// DecodeCursor parses what EncodeCursor produced; ok=false for anything |
| +// else (including future shapes this build cannot read — the caller treats |
| +// that as "no cursor", which re-tails rather than erroring a follow). |
| +func DecodeCursor(s string) (Cursor, bool) { |
| + b, err := base64.RawURLEncoding.DecodeString(s) |
| + if err != nil { |
| + return Cursor{}, false |
| + } |
| + var c Cursor |
| + if json.Unmarshal(b, &c) != nil || c.TS.IsZero() { |
| + return Cursor{}, false |
| + } |
| + return c, true |
| +} |
diff --git a/internal/applog/cursor_test.go b/internal/applog/cursor_test.go| new file mode 100644 |
| index 000000000..433bdd818 |
| --- /dev/null |
| +++ b/internal/applog/cursor_test.go |
| @@ -0,0 +1,35 @@ |
| +package applog |
| + |
| +import ( |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestCursorRoundTripAndOrder(t *testing.T) { |
| + r := Record{TS: time.Date(2026, 8, 10, 12, 0, 30, 500, time.UTC), Box: "b1", Seq: 42, Msg: "m"} |
| + c, ok := DecodeCursor(EncodeCursor(r)) |
| + if !ok || !c.TS.Equal(r.TS) || c.Box != "b1" || c.Seq != 42 { |
| + t.Fatalf("round trip: %+v %v", c, ok) |
| + } |
| + // Before mirrors MergeRecords' (ts, box, seq) order exactly. |
| + if c.Before(r) { |
| + t.Fatal("a cursor at r is not before r") |
| + } |
| + for _, after := range []Record{ |
| + {TS: r.TS.Add(time.Nanosecond), Box: "a0", Seq: 1}, |
| + {TS: r.TS, Box: "b2", Seq: 1}, |
| + {TS: r.TS, Box: "b1", Seq: 43}, |
| + } { |
| + if !c.Before(after) { |
| + t.Fatalf("cursor should be before %+v", after) |
| + } |
| + } |
| + if c.Before(Record{TS: r.TS, Box: "b1", Seq: 41}) { |
| + t.Fatal("cursor before an earlier seq") |
| + } |
| + for _, bad := range []string{"", "!!!", "bm90IGpzb24"} { |
| + if _, ok := DecodeCursor(bad); ok { |
| + t.Errorf("DecodeCursor(%q) ok", bad) |
| + } |
| + } |
| +} |
diff --git a/internal/applog/read.go b/internal/applog/read.go| new file mode 100644 |
| index 000000000..d0c41ea83 |
| --- /dev/null |
| +++ b/internal/applog/read.go |
| @@ -0,0 +1,97 @@ |
| +package applog |
| + |
| +import ( |
| + "context" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// ReadWindow assembles one app's merged timeline from the bucket: every |
| +// box's chunks whose hour directories intersect [f.Since, f.Until], decoded, |
| +// filtered through f, merged in (ts, box, seq) order. Hour pruning happens |
| +// at the PREFIX level — an hour outside the window is never listed, which |
| +// is what makes a 1h read over 7d of history cheap (spec §3). |
| +// |
| +// collectCap bounds memory against a pathological window: past it the |
| +// OLDEST records go (the tail is what a log reader came for), and the |
| +// second return says so. Store errors fail the read — a partial answer |
| +// that looks whole is the one thing this must never produce. |
| +func ReadWindow(ctx context.Context, s release.Store, acct, app string, f Filter, collectCap int) ([]Record, bool, error) { |
| + boxDirs, err := s.ListDirs(ctx, Prefix(acct, app)) |
| + if err != nil { |
| + return nil, false, err |
| + } |
| + trimmed := false |
| + perBox := make([][]Record, 0, len(boxDirs)) |
| + for _, boxDir := range boxDirs { |
| + hourDirs, err := s.ListDirs(ctx, boxDir) |
| + if err != nil { |
| + return nil, false, err |
| + } |
| + var recs []Record |
| + for _, hourDir := range hourDirs { // lexically sorted = chronological |
| + if !hourIntersects(hourNameOfDir(hourDir), f.Since, f.Until) { |
| + continue |
| + } |
| + keys, err := s.List(ctx, hourDir) |
| + if err != nil { |
| + return nil, false, err |
| + } |
| + for _, key := range keys { // lexically sorted = seq order |
| + body, _, err := s.Get(ctx, key) |
| + if err != nil { |
| + return nil, false, err |
| + } |
| + chunk, err := DecodeChunk(body) |
| + if err != nil { |
| + return nil, false, err |
| + } |
| + for _, r := range chunk { |
| + if f.Match(r) { |
| + recs = append(recs, r) |
| + } |
| + } |
| + if over := len(recs) - collectCap; over > 0 { |
| + recs = recs[over:] |
| + trimmed = true |
| + } |
| + } |
| + } |
| + perBox = append(perBox, recs) |
| + } |
| + merged := MergeRecords(perBox...) |
| + if over := len(merged) - collectCap; over > 0 { |
| + merged = merged[over:] |
| + trimmed = true |
| + } |
| + return merged, trimmed, nil |
| +} |
| + |
| +// hourIntersects reports whether the hour directory named hour overlaps |
| +// [since, until] (zero = unbounded). Unparsable names never match — an |
| +// alien directory is not silently read as data. |
| +func hourIntersects(hour string, since, until time.Time) bool { |
| + start, err := time.ParseInLocation(hourFormat, hour, time.UTC) |
| + if err != nil { |
| + return false |
| + } |
| + end := start.Add(time.Hour) |
| + if !since.IsZero() && !end.After(since) { |
| + return false |
| + } |
| + if !until.IsZero() && start.After(until) { |
| + return false |
| + } |
| + return true |
| +} |
| + |
| +// hourNameOfDir extracts the final path segment of a "/"-terminated prefix. |
| +func hourNameOfDir(dir string) string { |
| + trimmed := strings.TrimSuffix(dir, "/") |
| + if i := strings.LastIndexByte(trimmed, '/'); i >= 0 { |
| + return trimmed[i+1:] |
| + } |
| + return trimmed |
| +} |
diff --git a/internal/applog/read_test.go b/internal/applog/read_test.go| new file mode 100644 |
| index 000000000..17883a1a8 |
| --- /dev/null |
| +++ b/internal/applog/read_test.go |
| @@ -0,0 +1,106 @@ |
| +package applog |
| + |
| +import ( |
| + "context" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// seedWindow writes chunks for two boxes across three hours. |
| +func seedWindow(t *testing.T, s release.Store) { |
| + t.Helper() |
| + ctx := context.Background() |
| + write := func(box, hour string, seq uint64, recs []Record) { |
| + t.Helper() |
| + body, err := EncodeChunk(recs) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.Put(ctx, ChunkKey("bde", "lpay", box, hour, seq, len(body)), body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + ts := func(h, m int) time.Time { return time.Date(2026, 8, 10, h, m, 0, 0, time.UTC) } |
| + write("box1", "2026-08-10-10", 100, []Record{ |
| + {TS: ts(10, 5), Box: "box1", Seq: 100, Stream: StreamApp, Host: "h1", Msg: "old line"}, |
| + }) |
| + write("box1", "2026-08-10-11", 200, []Record{ |
| + {TS: ts(11, 10), Box: "box1", Seq: 200, Stream: StreamApp, Host: "h1", Msg: "b1 app"}, |
| + {TS: ts(11, 30), Box: "box1", Seq: 201, Stream: StreamPlatform, Level: LevelError, Host: "h1", Msg: "wake failed: x"}, |
| + }) |
| + write("box2", "2026-08-10-11", 500, []Record{ |
| + {TS: ts(11, 20), Box: "box2", Seq: 500, Stream: StreamApp, Host: "h2", Msg: "b2 app"}, |
| + }) |
| +} |
| + |
| +func TestReadWindowMergesAndClipsHours(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + seedWindow(t, s) |
| + ctx := context.Background() |
| + |
| + // Window covering hour 11 only: the hour-10 chunk is never even listed. |
| + f := Filter{Since: time.Date(2026, 8, 10, 11, 0, 0, 0, time.UTC)} |
| + recs, trimmed, err := ReadWindow(ctx, s, "bde", "lpay", f, 1000) |
| + if err != nil || trimmed { |
| + t.Fatalf("read: %v trimmed=%v", err, trimmed) |
| + } |
| + want := []string{"b1 app", "b2 app", "wake failed: x"} // (ts, box, seq) order |
| + if len(recs) != len(want) { |
| + t.Fatalf("got %d records: %+v", len(recs), recs) |
| + } |
| + for i, m := range want { |
| + if recs[i].Msg != m { |
| + t.Fatalf("order[%d] = %q, want %q", i, recs[i].Msg, m) |
| + } |
| + } |
| + |
| + // Stream filter composes. |
| + recs, _, err = ReadWindow(ctx, s, "bde", "lpay", Filter{Stream: StreamPlatform}, 1000) |
| + if err != nil || len(recs) != 1 || recs[0].Level != LevelError { |
| + t.Fatalf("platform filter: %v %+v", err, recs) |
| + } |
| + |
| + // Unbounded window sees all three hours. |
| + recs, _, err = ReadWindow(ctx, s, "bde", "lpay", Filter{}, 1000) |
| + if err != nil || len(recs) != 4 { |
| + t.Fatalf("unbounded: %v, %d records", err, len(recs)) |
| + } |
| + |
| + // collectCap keeps the TAIL and says it trimmed. |
| + recs, trimmed, err = ReadWindow(ctx, s, "bde", "lpay", Filter{}, 2) |
| + if err != nil || !trimmed || len(recs) != 2 { |
| + t.Fatalf("capped: %v trimmed=%v n=%d", err, trimmed, len(recs)) |
| + } |
| + if recs[len(recs)-1].Msg != "wake failed: x" { |
| + t.Fatalf("cap dropped the tail: %+v", recs) |
| + } |
| + |
| + // An app with no logs at all is an empty answer, not an error. |
| + recs, _, err = ReadWindow(ctx, s, "bde", "ghost", Filter{}, 1000) |
| + if err != nil || len(recs) != 0 { |
| + t.Fatalf("empty app: %v %+v", err, recs) |
| + } |
| +} |
| + |
| +func TestHourIntersects(t *testing.T) { |
| + since := time.Date(2026, 8, 10, 11, 0, 0, 0, time.UTC) |
| + for hour, want := range map[string]bool{ |
| + "2026-08-10-10": false, // ends exactly at since — nothing at/after since lives there |
| + "2026-08-10-11": true, |
| + "2026-08-10-12": true, |
| + "not-an-hour": false, |
| + } { |
| + if got := hourIntersects(hour, since, time.Time{}); got != want { |
| + t.Errorf("hourIntersects(%q, since) = %v, want %v", hour, got, want) |
| + } |
| + } |
| + until := time.Date(2026, 8, 10, 11, 30, 0, 0, time.UTC) |
| + if hourIntersects("2026-08-10-12", time.Time{}, until) { |
| + t.Error("an hour starting after until intersects") |
| + } |
| + if !hourIntersects("2026-08-10-11", since, until) { |
| + t.Error("the window's own hour should intersect") |
| + } |
| +} |
diff --git a/internal/console/apilogs.go b/internal/console/apilogs.go| new file mode 100644 |
| index 000000000..ae7d177e5 |
| --- /dev/null |
| +++ b/internal/console/apilogs.go |
| @@ -0,0 +1,153 @@ |
| +// The CLI's logs API — the read side of `carlos logs` (spec |
| +// 2026-08-10-carlos-logs-design.md §6): one owner-gated GET that merges the |
| +// app's shipped chunks across boxes, filters, and pages. It shares the |
| +// instance family's gate (instanceGate) deliberately: same principal proof, |
| +// same byte-identical 404, same rate limiter — and an APP token may read |
| +// its own app's logs, which is the same trust the instance verbs already |
| +// extend it. |
| +package console |
| + |
| +import ( |
| + "context" |
| + "log" |
| + "net/http" |
| + "regexp" |
| + "strconv" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/applog" |
| +) |
| + |
| +const ( |
| + // logsDefaultLimit / logsMaxLimit bound one answer (spec §6). The |
| + // response's `truncated` says when the window held more. |
| + logsDefaultLimit = 1000 |
| + logsMaxLimit = 5000 |
| + // logsCollectCap bounds what one read will hold in memory while |
| + // assembling the window — the tail survives, and `truncated` reports it. |
| + logsCollectCap = 50000 |
| + // logsDefaultWindow applies when the caller sends no `since`: an |
| + // unbounded read would walk every hour of retention for no stated |
| + // reason; a caller that wants more says so. |
| + logsDefaultWindow = time.Hour |
| +) |
| + |
| +// logsResponse is the wire shape both the CLI and the console tab read. |
| +type logsResponse struct { |
| + Records []applog.Record `json:"records"` |
| + Truncated bool `json:"truncated"` |
| + Cursor string `json:"cursor,omitempty"` |
| +} |
| + |
| +// handleAPILogs answers `carlos logs`: GET with since/until/stream/host/ |
| +// level/grep/field/limit/cursor query params. Without a cursor the answer |
| +// is the TAIL of the window (the last `limit` records — what a log reader |
| +// came for); with one it is the records strictly after that position, which |
| +// is what makes `-f` a plain 2s poll of this same endpoint. |
| +func (s *Server) handleAPILogs(w http.ResponseWriter, r *http.Request) { |
| + _, acct, app, ok := s.instanceGate(w, r) |
| + if !ok { |
| + return |
| + } |
| + f, limit, badParam := logsFilterFromQuery(r, time.Now()) |
| + if badParam != "" { |
| + jsonError(w, http.StatusBadRequest, badParam) |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| + defer cancel() |
| + recs, trimmed, err := applog.ReadWindow(ctx, s.cfg.Releases, acct.Sqid, app, f, logsCollectCap) |
| + if err != nil { |
| + log.Printf("console: api logs %s/%s: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusBadGateway, "could not read the logs") |
| + return |
| + } |
| + |
| + resp := logsResponse{Truncated: trimmed} |
| + if cur, ok := applog.DecodeCursor(r.URL.Query().Get("cursor")); ok { |
| + // Follow: everything strictly after the cursor, oldest first. |
| + var after []applog.Record |
| + for _, rec := range recs { |
| + if cur.Before(rec) { |
| + after = append(after, rec) |
| + } |
| + } |
| + if len(after) > limit { |
| + after = after[:limit] |
| + resp.Truncated = true |
| + } |
| + resp.Records = after |
| + } else { |
| + // Tail: the last `limit` records of the window. |
| + if len(recs) > limit { |
| + recs = recs[len(recs)-limit:] |
| + resp.Truncated = true |
| + } |
| + resp.Records = recs |
| + } |
| + if n := len(resp.Records); n > 0 { |
| + resp.Cursor = applog.EncodeCursor(resp.Records[n-1]) |
| + } else if c := r.URL.Query().Get("cursor"); c != "" { |
| + resp.Cursor = c // nothing new — the caller keeps polling from here |
| + } |
| + if resp.Records == nil { |
| + resp.Records = []applog.Record{} // "no logs" is [], not null |
| + } |
| + writeJSON(w, http.StatusOK, resp) |
| +} |
| + |
| +// logsFilterFromQuery builds the applog.Filter (plus record limit) from the |
| +// request. The empty badParam means the params parsed; anything else is the |
| +// 400 text, naming the parameter in the member's own terms. |
| +func logsFilterFromQuery(r *http.Request, now time.Time) (f applog.Filter, limit int, badParam string) { |
| + q := r.URL.Query() |
| + var err error |
| + if f.Since, err = applog.ParseTimeArg(q.Get("since"), now); err != nil { |
| + return f, 0, "since: " + err.Error() |
| + } |
| + if f.Until, err = applog.ParseTimeArg(q.Get("until"), now); err != nil { |
| + return f, 0, "until: " + err.Error() |
| + } |
| + if f.Since.IsZero() && q.Get("cursor") == "" { |
| + f.Since = now.Add(-logsDefaultWindow) |
| + } |
| + if v := q.Get("stream"); v != "" { |
| + if !applog.ValidStream(v) { |
| + return f, 0, "stream: want app, platform, edge or release" |
| + } |
| + f.Stream = v |
| + } |
| + f.Host = q.Get("host") |
| + if v := q.Get("level"); v != "" { |
| + if !applog.ValidLevel(v) { |
| + return f, 0, "level: want debug, info, warn or error" |
| + } |
| + f.Level = v |
| + } |
| + if v := q.Get("grep"); v != "" { |
| + re, err := regexp.Compile(v) |
| + if err != nil { |
| + return f, 0, "grep: " + err.Error() |
| + } |
| + f.Grep = re |
| + } |
| + for _, kv := range q["field"] { |
| + k, v, err := applog.ParseFieldArg(kv) |
| + if err != nil { |
| + return f, 0, err.Error() |
| + } |
| + if f.Fields == nil { |
| + f.Fields = map[string]string{} |
| + } |
| + f.Fields[k] = v |
| + } |
| + limit = logsDefaultLimit |
| + if v := q.Get("limit"); v != "" { |
| + n, err := strconv.Atoi(v) |
| + if err != nil || n < 1 { |
| + return f, 0, "limit: want a positive integer" |
| + } |
| + limit = min(n, logsMaxLimit) |
| + } |
| + return f, limit, "" |
| +} |
diff --git a/internal/console/apilogs_test.go b/internal/console/apilogs_test.go| new file mode 100644 |
| index 000000000..85b1bcbf7 |
| --- /dev/null |
| +++ b/internal/console/apilogs_test.go |
| @@ -0,0 +1,151 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "fmt" |
| + "net/http" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/applog" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func logsURL(fx *consoleFixture, acct, app, query string) string { |
| + u := fx.ts.URL + "/api/cli/apps/" + acct + "/" + app + "/logs" |
| + if query != "" { |
| + u += "?" + query |
| + } |
| + return u |
| +} |
| + |
| +// seedLogChunks ships a small timeline for titogo: an app line and a wake |
| +// failure, recent enough for the default 1h window. |
| +func seedLogChunks(t *testing.T, fx *consoleFixture, now time.Time) []applog.Record { |
| + t.Helper() |
| + recs := []applog.Record{ |
| + {TS: now.Add(-10 * time.Minute), Box: "box1", Host: "titogo.example.com", |
| + Stream: applog.StreamApp, Msg: "listening on socket", Seq: 100, |
| + Fields: map[string]string{"request_id": "r1"}}, |
| + {TS: now.Add(-5 * time.Minute), Box: "box1", Host: "titogo.example.com", |
| + Stream: applog.StreamPlatform, Level: applog.LevelError, |
| + Msg: "wake failed: nothing is promoted for this instance's channel", Seq: 101}, |
| + } |
| + body, err := applog.EncodeChunk(recs) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + key := applog.ChunkKey(release.OpsAccount, "titogo", "box1", applog.HourOf(recs[0].TS), recs[0].Seq, len(body)) |
| + if _, err := fx.rel.Put(context.Background(), key, body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + return recs |
| +} |
| + |
| +// TestAPILogsTailFiltersAndFollow drives the endpoint end to end: default |
| +// tail, stream/level/grep/field filters, and the cursor follow loop. |
| +func TestAPILogsTailFiltersAndFollow(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-logs-1") |
| + now := time.Now().UTC() |
| + seedLogChunks(t, fx, now) |
| + |
| + get := func(query string) logsResponse { |
| + t.Helper() |
| + status, _, body := rawReq(t, http.MethodGet, logsURL(fx, fx.sqid, "titogo", query), token, "") |
| + if status != http.StatusOK { |
| + t.Fatalf("GET ?%s = %d: %s", query, status, body) |
| + } |
| + var resp logsResponse |
| + if err := json.Unmarshal([]byte(body), &resp); err != nil { |
| + t.Fatalf("bad JSON: %v: %s", err, body) |
| + } |
| + return resp |
| + } |
| + |
| + // Default window: both records, oldest first, cursor present. |
| + resp := get("") |
| + if len(resp.Records) != 2 || resp.Records[0].Msg != "listening on socket" { |
| + t.Fatalf("tail: %+v", resp) |
| + } |
| + if resp.Cursor == "" || resp.Truncated { |
| + t.Fatalf("cursor/truncated: %+v", resp) |
| + } |
| + |
| + // Filters compose: stream, level, grep, field. |
| + if r := get("stream=platform"); len(r.Records) != 1 || r.Records[0].Level != "error" { |
| + t.Fatalf("stream filter: %+v", r.Records) |
| + } |
| + if r := get("level=error"); len(r.Records) != 1 { |
| + t.Fatalf("level filter: %+v", r.Records) |
| + } |
| + if r := get("grep=promoted"); len(r.Records) != 1 || r.Records[0].Stream != "platform" { |
| + t.Fatalf("grep filter: %+v", r.Records) |
| + } |
| + if r := get("field=request_id%3Dr1"); len(r.Records) != 1 || r.Records[0].Stream != "app" { |
| + t.Fatalf("field filter: %+v", r.Records) |
| + } |
| + if r := get("host=titogo.example.com"); len(r.Records) != 2 { |
| + t.Fatalf("host filter: %+v", r.Records) |
| + } |
| + if r := get("host=other.example.com"); len(r.Records) != 0 { |
| + t.Fatalf("host mismatch filter: %+v", r.Records) |
| + } |
| + |
| + // limit=1 tails to the NEWEST record and says truncated. |
| + r := get("limit=1") |
| + if len(r.Records) != 1 || r.Records[0].Stream != "platform" || !r.Truncated { |
| + t.Fatalf("limit tail: %+v", r) |
| + } |
| + |
| + // Follow: the cursor from the full read sees nothing new, keeps itself. |
| + full := get("") |
| + fr := get("cursor=" + full.Cursor) |
| + if len(fr.Records) != 0 || fr.Cursor != full.Cursor { |
| + t.Fatalf("follow at tip: %+v", fr) |
| + } |
| + // A new record lands; the same cursor now yields exactly it. |
| + extra := []applog.Record{{TS: now.Add(-time.Minute), Box: "box1", |
| + Host: "titogo.example.com", Stream: applog.StreamApp, Msg: "fresh line", Seq: 102}} |
| + body, err := applog.EncodeChunk(extra) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + key := applog.ChunkKey(release.OpsAccount, "titogo", "box1", applog.HourOf(extra[0].TS), 102, len(body)) |
| + if _, err := fx.rel.Put(context.Background(), key, body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + fr2 := get("cursor=" + full.Cursor) |
| + if len(fr2.Records) != 1 || fr2.Records[0].Msg != "fresh line" { |
| + t.Fatalf("follow after new record: %+v", fr2) |
| + } |
| +} |
| + |
| +// TestAPILogsRefusals: a non-member's read is the family's byte-identical |
| +// 404; bad parameters name themselves in a 400. |
| +func TestAPILogsRefusals(t *testing.T) { |
| + fx, _ := instanceFixture(t, "code-logs-2") |
| + eve := cliToken(t, fx, "eve@keymail.dev", "code-logs-eve") |
| + want := canonicalNotFound(t) |
| + status, h, body := rawReq(t, http.MethodGet, logsURL(fx, fx.sqid, "titogo", ""), eve, "") |
| + assertNotFound(t, "logs as non-member", want, status, h, body) |
| + |
| + _, token := func() (*consoleFixture, string) { return fx, cliToken(t, fx, "alice@keymail.dev", "code-logs-3") }() |
| + for _, q := range []string{"since=garbage", "stream=nope", "level=shouting", "grep=%5B", "field=nokey", "limit=zero"} { |
| + status, _, body := rawReq(t, http.MethodGet, logsURL(fx, fx.sqid, "titogo", q), token, "") |
| + if status != http.StatusBadRequest { |
| + t.Errorf("?%s = %d, want 400 (%s)", q, status, body) |
| + } |
| + } |
| + |
| + // An app with no logs at all: an empty list, not an error and not null. |
| + status, _, body = rawReq(t, http.MethodGet, logsURL(fx, fx.sqid, "titogo", ""), token, "") |
| + if status != http.StatusOK || !json.Valid([]byte(body)) { |
| + t.Fatalf("empty logs: %d %s", status, body) |
| + } |
| + var resp logsResponse |
| + if err := json.Unmarshal([]byte(body), &resp); err != nil || resp.Records == nil { |
| + t.Fatalf("records must be [], got %s", body) |
| + } |
| + _ = fmt.Sprintf("%v", resp) |
| +} |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index 29b0facbb..168d53380 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -266,6 +266,9 @@ func (s *Server) registerCLIRoutes() { |
| // so a host named "instances-manifest" could still never reach it. |
| s.mux.HandleFunc("PUT /api/cli/apps/{account}/{app}/instances-manifest", s.handleAPIInstanceEnable) |
| s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/instances", s.handleAPIInstanceList) |
| + // The logs read (`carlos logs`, apilogs.go) — same gate, same 404 |
| + // discipline, and an app token may read its own app's logs. |
| + s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/logs", s.handleAPILogs) |
| s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/instances", s.handleAPIInstanceCreate) |
| s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/instances/{host}", s.handleAPIInstanceGet) |
| s.mux.HandleFunc("DELETE /api/cli/apps/{account}/{app}/instances/{host}", s.handleAPIInstanceDelete) |