package main import ( "context" "encoding/json" "flag" "fmt" "net/url" "os" "os/signal" "strings" "time" ) func runLogs(args []string) error { fs := flag.NewFlagSet("logs", flag.ExitOnError) g := addGlobalFlags(fs) follow := fs.Bool("f", false, "follow the log stream (Ctrl-C to stop)") tail := fs.Int("tail", 200, "number of trailing lines to show (max 5000)") container := fs.String("container", "", "container row id/prefix or role (when an app has several)") fs.Usage = func() { fmt.Fprint(os.Stderr, "Usage: tinyforge logs [-f] [--tail N] [--container CID]\n\nPrint or follow a container's logs.\n") fs.PrintDefaults() } if err := fs.Parse(args); err != nil { return err } if fs.NArg() != 1 { fs.Usage() return fmt.Errorf("expected exactly one app (name or id)") } sess, err := newSession(g) if err != nil { return err } resolveCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() app, err := resolveApp(resolveCtx, sess.client, fs.Arg(0)) if err != nil { return err } var containers []Container if err := sess.client.doJSON(resolveCtx, "GET", "/api/workloads/"+app.ID+"/containers", nil, &containers); err != nil { return err } target, err := chooseContainer(containers, *container) if err != nil { return err } q := url.Values{} q.Set("tail", fmt.Sprintf("%d", *tail)) base := "/api/workloads/" + app.ID + "/containers/" + target.ID + "/logs" if !*follow { var lines []string if err := sess.client.doJSON(resolveCtx, "GET", base+"?"+q.Encode(), nil, &lines); err != nil { return err } for _, line := range lines { fmt.Println(line) } return nil } // Follow: stream until EOF or Ctrl-C. q.Set("follow", "true") ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() err = sess.client.streamSSE(ctx, base+"?"+q.Encode(), func(payload []byte) error { var frame struct { Line string `json:"line"` } if json.Unmarshal(payload, &frame) != nil { return nil // ignore frames we can't parse } fmt.Println(frame.Line) return nil }) if ctx.Err() != nil { // user interrupted — clean exit return nil } return err } // chooseContainer selects which container to read. With an explicit selector, // it matches the row id (exact or prefix) or the role. Otherwise it uses the // sole container, or the sole running one, and errors with a list when the // choice is ambiguous. func chooseContainer(cs []Container, selector string) (Container, error) { if len(cs) == 0 { return Container{}, fmt.Errorf("app has no containers yet — deploy it first") } if selector != "" { var matches []Container for _, c := range cs { if c.ID == selector || strings.EqualFold(c.Role, selector) || (len(selector) >= 6 && strings.HasPrefix(c.ID, selector)) { matches = append(matches, c) } } switch len(matches) { case 1: return matches[0], nil case 0: return Container{}, fmt.Errorf("no container matching %q\n%s", selector, containerList(cs)) default: return Container{}, fmt.Errorf("%q matches multiple containers\n%s", selector, containerList(cs)) } } if len(cs) == 1 { return cs[0], nil } var running []Container for _, c := range cs { if c.State == "running" { running = append(running, c) } } if len(running) == 1 { return running[0], nil } return Container{}, fmt.Errorf("app has %d containers; pick one with --container:\n%s", len(cs), containerList(cs)) } func containerList(cs []Container) string { var b strings.Builder for _, c := range cs { role := c.Role if role == "" { role = "(default)" } fmt.Fprintf(&b, " %s %-12s %s\n", idShort(c.ID), role, c.State) } return strings.TrimRight(b.String(), "\n") }