Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions cmd/asobi/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ func main() {
cmdDestroy()
case "env":
cmdEnv()
case "logs":
cmdLogs()
case "health":
cmdHealth()
case "config":
Expand Down Expand Up @@ -117,6 +119,8 @@ Usage:
asobi delete <name> [--game <slug>] Destroy an environment. Durable
ones retire (database kept 30 days). Owner/admin only
asobi envs [--game <slug>] List your environments
asobi logs <name> [--filter <text>] [--since 1h] [--limit N] [--game <slug>]
Engine logs for an environment (last hour by default)
asobi health [env] [--game <slug>] Check engine health (of an environment)
asobi config set <k> <v> Set config (url, api_key)
asobi config show Show current config
Expand Down Expand Up @@ -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 <name> [--filter <text>] [--since 1h] [--limit N] [--game <slug>]")
}
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 {
Expand Down
113 changes: 113 additions & 0 deletions internal/auth/logs.go
Original file line number Diff line number Diff line change
@@ -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
}
106 changes: 106 additions & 0 deletions internal/auth/logs_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}