From 50921a7ccbeab612fbf8f312852fa6f429b56fb9 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Mon, 17 Aug 2026 13:24:38 +0200 Subject: [PATCH] feat: asobi logs, engine logs for an environment without leaving the terminal The backend has done the tenant-scoped Loki query since the dashboard gained its logs page. The CLI had no way to reach it, so debugging a deploy meant opening a browser. No --follow. The backend query is a bounded range (Loki's query_range), not a stream, so a tail would be a poll pretending to be one; promising it and not delivering is worse than not offering it. --since takes 30m, 2h or a bare number of seconds. The bare number matters because the API itself takes seconds, so somebody who reads the docs and passes 3600 should get an hour rather than a parse error. A 422 and a 503 get their own messages: a bad filter is the caller's to fix and an unavailable Loki is not, and reporting both generically makes the first one unfixable. --- cmd/asobi/main.go | 45 +++++++++++++++ internal/auth/logs.go | 113 +++++++++++++++++++++++++++++++++++++ internal/auth/logs_test.go | 106 ++++++++++++++++++++++++++++++++++ 3 files changed, 264 insertions(+) create mode 100644 internal/auth/logs.go create mode 100644 internal/auth/logs_test.go diff --git a/cmd/asobi/main.go b/cmd/asobi/main.go index 7585187..67eb9c1 100644 --- a/cmd/asobi/main.go +++ b/cmd/asobi/main.go @@ -71,6 +71,8 @@ func main() { cmdDestroy() case "env": cmdEnv() + case "logs": + cmdLogs() case "health": cmdHealth() case "config": @@ -117,6 +119,8 @@ Usage: asobi delete [--game ] Destroy an environment. Durable ones retire (database kept 30 days). Owner/admin only asobi envs [--game ] List your environments + asobi logs [--filter ] [--since 1h] [--limit N] [--game ] + Engine logs for an environment (last hour by default) asobi health [env] [--game ] Check engine health (of an environment) asobi config set Set config (url, api_key) asobi config show Show current config @@ -653,6 +657,47 @@ func cmdRetention() { fmt.Printf("Environment %s deletes unclaimed guests after %s days of inactivity - it restarts to pick this up\n", args[0], afterFlag) } +// Engine logs for one environment. The backend query is a bounded range +// (Loki's query_range), not a stream, so there is no --follow: promising a +// tail this cannot deliver would be worse than not offering one. +func cmdLogs() { + gameFlag, args := extractFlag(os.Args[2:], "--game") + filterFlag, args := extractFlag(args, "--filter") + sinceFlag, args := extractFlag(args, "--since") + limitFlag, args := extractFlag(args, "--limit") + if len(args) < 1 { + fatal("usage: asobi logs [--filter ] [--since 1h] [--limit N] [--game ]") + } + since, err := auth.ParseSince(sinceFlag) + if err != nil { + fatal("%v", err) + } + limit := 0 + if limitFlag != "" { + limit, err = strconv.Atoi(limitFlag) + if err != nil || limit <= 0 { + fatal("--limit must be a positive number") + } + } + creds := mustLoadCreds() + game := resolveGame(gameFlag, creds) + lines, err := auth.EnvLogs(creds, game, args[0], auth.LogOptions{ + Filter: filterFlag, + Since: since, + Limit: limit, + }) + if err != nil { + fatal("logs: %v", err) + } + if len(lines) == 0 { + fmt.Println("No log lines in that window.") + return + } + for _, l := range lines { + fmt.Printf("%s %s\n", l.Ts, l.Line) + } +} + func cmdDelete() { gameFlag, args := extractFlag(os.Args[2:], "--game") if len(args) < 1 { diff --git a/internal/auth/logs.go b/internal/auth/logs.go new file mode 100644 index 0000000..0abfa21 --- /dev/null +++ b/internal/auth/logs.go @@ -0,0 +1,113 @@ +package auth + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// LogLine is one engine log line as Loki returned it. +type LogLine struct { + Ts string `json:"ts"` + Line string `json:"line"` +} + +// LogOptions narrows a log query. Zero values mean "use the server's default"; +// the control plane clamps the range to 24h and the count to 500, so there is +// no point enforcing either here as well. +type LogOptions struct { + Filter string + Since time.Duration + Limit int +} + +// ParseSince turns "30m", "2h", "90s" or a bare number of seconds into a +// duration. Accepting a bare number matters because the API takes seconds, so +// somebody reading the docs and passing 3600 should get what they expect +// rather than an error. +func ParseSince(s string) (time.Duration, error) { + if s == "" { + return 0, nil + } + if n, err := strconv.Atoi(s); err == nil { + if n <= 0 { + return 0, fmt.Errorf("--since must be positive") + } + return time.Duration(n) * time.Second, nil + } + d, err := time.ParseDuration(s) + if err != nil { + return 0, fmt.Errorf("--since %q: use 30m, 2h, or a number of seconds", s) + } + if d <= 0 { + return 0, fmt.Errorf("--since must be positive") + } + return d, nil +} + +// EnvLogs fetches engine logs for one environment. The tenant and environment +// are resolved server-side from the caller's token and the environment name - +// nothing here selects whose logs come back. +func EnvLogs(creds *Credentials, game, name string, opts LogOptions) ([]LogLine, error) { + q := url.Values{} + if game != "" { + q.Set("game", game) + } + if opts.Filter != "" { + q.Set("filter", opts.Filter) + } + if opts.Since > 0 { + q.Set("since", strconv.Itoa(int(opts.Since.Seconds()))) + } + if opts.Limit > 0 { + q.Set("limit", strconv.Itoa(opts.Limit)) + } + endpoint := creds.SaasURL + "/internal/cli/envs/" + url.PathEscape(name) + "/logs" + if encoded := q.Encode(); encoded != "" { + endpoint += "?" + encoded + } + + req, err := http.NewRequest("GET", endpoint, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+creds.AccessToken) + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode == 401 { + refreshed, err := RefreshAccessToken(creds) + if err != nil { + return nil, err + } + creds.AccessToken = refreshed + _ = SaveCredentials(creds) + return EnvLogs(creds, game, name, opts) + } + // Both of these are worth naming: one is a filter the caller can fix, the + // other is a backend that is down and has nothing to do with them. + if resp.StatusCode == 422 { + return nil, fmt.Errorf("invalid --filter") + } + if resp.StatusCode == 503 { + return nil, fmt.Errorf("logs are temporarily unavailable") + } + if resp.StatusCode >= 400 { + data, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("logs failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(data))) + } + var body struct { + Lines []LogLine `json:"lines"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return nil, fmt.Errorf("decode logs: %w", err) + } + return body.Lines, nil +} diff --git a/internal/auth/logs_test.go b/internal/auth/logs_test.go new file mode 100644 index 0000000..4ba4ae1 --- /dev/null +++ b/internal/auth/logs_test.go @@ -0,0 +1,106 @@ +package auth + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// The API takes seconds, so a reader who passes the number from the docs must +// get what they expect rather than a parse error. +func TestParseSinceAcceptsDurationsAndBareSeconds(t *testing.T) { + cases := map[string]time.Duration{ + "": 0, + "90s": 90 * time.Second, + "30m": 30 * time.Minute, + "2h": 2 * time.Hour, + "3600": time.Hour, + } + for in, want := range cases { + got, err := ParseSince(in) + if err != nil { + t.Fatalf("ParseSince(%q): %v", in, err) + } + if got != want { + t.Fatalf("ParseSince(%q) = %v, want %v", in, got, want) + } + } +} + +func TestParseSinceRejectsNonsense(t *testing.T) { + for _, in := range []string{"0", "-5", "-1h", "yesterday", "1 hour"} { + if _, err := ParseSince(in); err == nil { + t.Fatalf("ParseSince(%q) should have failed", in) + } + } +} + +func TestEnvLogsSendsTheNarrowingOptions(t *testing.T) { + var got string + mux := http.NewServeMux() + mux.HandleFunc("/internal/cli/envs/prod/logs", func(w http.ResponseWriter, r *http.Request) { + got = r.URL.RawQuery + json.NewEncoder(w).Encode(map[string]any{ + "lines": []map[string]string{{"ts": "2026-08-17T10:00:00Z", "line": "boot"}}, + }) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + lines, err := EnvLogs(&Credentials{AccessToken: "at-1", SaasURL: srv.URL}, "arena", "prod", + LogOptions{Filter: "error", Since: 30 * time.Minute, Limit: 50}) + if err != nil { + t.Fatalf("EnvLogs: %v", err) + } + if len(lines) != 1 || lines[0].Line != "boot" { + t.Fatalf("lines = %+v", lines) + } + for _, want := range []string{"game=arena", "filter=error", "since=1800", "limit=50"} { + if !strings.Contains(got, want) { + t.Fatalf("query %q missing %q", got, want) + } + } +} + +// Zero values must be omitted rather than sent as 0, or they would shadow the +// server's own defaults with something it would clamp back up. +func TestEnvLogsOmitsUnsetOptions(t *testing.T) { + var got string + mux := http.NewServeMux() + mux.HandleFunc("/internal/cli/envs/prod/logs", func(w http.ResponseWriter, r *http.Request) { + got = r.URL.RawQuery + w.Write([]byte(`{"lines":[]}`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + if _, err := EnvLogs(&Credentials{AccessToken: "at-1", SaasURL: srv.URL}, "", "prod", LogOptions{}); err != nil { + t.Fatalf("EnvLogs: %v", err) + } + for _, unwanted := range []string{"since=", "limit=", "filter=", "game="} { + if strings.Contains(got, unwanted) { + t.Fatalf("query %q should not carry %q", got, unwanted) + } + } +} + +// A bad filter is the caller's to fix; an unavailable backend is not. Reporting +// both as a generic failure makes the first one unfixable. +func TestEnvLogsDistinguishesBadFilterFromOutage(t *testing.T) { + for code, want := range map[int]string{422: "filter", 503: "temporarily unavailable"} { + mux := http.NewServeMux() + status := code + mux.HandleFunc("/internal/cli/envs/prod/logs", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(status) + }) + srv := httptest.NewServer(mux) + _, err := EnvLogs(&Credentials{AccessToken: "at-1", SaasURL: srv.URL}, "", "prod", LogOptions{}) + srv.Close() + if err == nil || !strings.Contains(err.Error(), want) { + t.Fatalf("status %d gave %v, want it to mention %q", code, err, want) + } + } +}