package api // Following a container's log over a held connection, the way the Services tab follows a // journal. The polled `?since=` endpoint next door still exists — it is what a reconnect // hands back to pick up from — but the live view holds one `docker logs -f` per viewer // instead of asking again every two seconds. import ( "bufio" "context" "encoding/json" "io" "net/http" "os/exec" "sort" "strconv" "strings" "syscall" "time" "github.com/coder/websocket" "hamlab/internal/priv" ) // logLine is one line of a container's output: the time Docker stamped it with, the text, // and which of the two streams it came out of — stderr is where a container says what went // wrong, and the page colours it accordingly. type logLine struct { T string `json:"t"` M string `json:"m"` Err bool `json:"err,omitempty"` at string // when to sort it by: its own stamp, or the last one its stream carried } // splitStamp pulls Docker's RFC 3339 prefix off a line. A line without one (a container // that writes bare bytes, or a continuation) keeps the whole text and no time — but is // still sorted where its stream put it, behind the last line that did have one. func splitStamp(l string, carry *string) logLine { t, m, ok := strings.Cut(l, " ") if _, err := time.Parse(time.RFC3339Nano, t); !ok || err != nil { return logLine{M: l, at: *carry} } *carry = t return logLine{T: t, M: m, at: t} } // dockerTail streams a container's output as JSON text frames until the client goes away. // `?since=` resumes where a dropped connection left off; otherwise it starts with // the last `?n=` lines. func (s *Server) dockerTail(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if !dockerRef.MatchString(id) { jsonErr(w, 400, "bad container") return } tool := dockerTool() if tool == "" { jsonErr(w, 500, "neither docker nor podman is installed here") return } args := []string{tool, "logs", "--timestamps", "--follow"} if since := r.URL.Query().Get("since"); since != "" { if _, err := time.Parse(time.RFC3339Nano, since); err != nil { jsonErr(w, 400, "since is an RFC 3339 time") return } args = append(args, "--since", since) } else { args = append(args, "--tail", strconv.Itoa(logCount(r))) } args = append(args, id) if priv.Available() && !dockerReachable() { args = priv.Wrap(args...) } conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{CompressionMode: websocket.CompressionDisabled}) if err != nil { return } defer conn.Close(websocket.StatusNormalClosure, "") ctx, cancel := context.WithCancel(r.Context()) defer cancel() cmd := exec.CommandContext(ctx, args[0], args[1:]...) // Own process group, so the kill reaches the docker client itself and not just a sudo // wrapper — the same reason the journal tail does it. cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} cmd.Cancel = func() error { pg := "-" + strconv.Itoa(cmd.Process.Pid) if args[0] != tool { // running under sudo: only sudo can signal root's children k := priv.Wrap("kill", "-9", "--", pg) return exec.Command(k[0], k[1:]...).Run() } return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) } stdout, err := cmd.StdoutPipe() if err != nil { return } stderr, err := cmd.StderrPipe() if err != nil { return } if err := cmd.Start(); err != nil { _ = conn.Write(ctx, websocket.MessageText, mustJSON(logLine{M: err.Error(), Err: true})) return } // Both pipes funnel into one writer: a websocket takes one writer at a time. lines := make(chan logLine, 256) read := func(p io.Reader, isErr bool) { sc := bufio.NewScanner(p) sc.Buffer(make([]byte, 0, 64<<10), 4<<20) carry := "" for sc.Scan() { l := splitStamp(sc.Text(), &carry) l.Err = isErr select { case lines <- l: case <-ctx.Done(): return } } } done := make(chan struct{}, 2) go func() { read(stdout, false); done <- struct{}{} }() go func() { read(stderr, true); done <- struct{}{} }() go func() { // the command ends when the container does; closing says so, having said why <-done <-done _ = cmd.Wait() close(lines) }() go func() { // reader: only to notice the close defer cancel() for { if _, _, err := conn.Read(ctx); err != nil { return } } }() ping := time.NewTicker(25 * time.Second) defer ping.Stop() // stdout and stderr are two pipes, and what Docker replays down them arrives in no // particular order. So the opening burst is held for a moment and sorted by the time // Docker stamped each line with; after that the log is live and arrives in order anyway. const burstFor = 300 * time.Millisecond warm := time.NewTimer(burstFor) defer warm.Stop() warmC := warm.C // nil once the burst has been flushed: a nil channel never fires var burst []logLine write := func(l logLine) bool { return conn.Write(ctx, websocket.MessageText, mustJSON(l)) == nil } flush := func() bool { sort.SliceStable(burst, func(i, j int) bool { return burst[i].at < burst[j].at }) for _, l := range burst { if !write(l) { return false } } burst = nil return true } for { select { case <-ctx.Done(): return case l, ok := <-lines: if !ok { // the container stopped, or never existed: say the last of it and go flush() return } if warmC != nil { burst = append(burst, l) if len(burst) < 10000 { // a container that never stops talking still gets read continue } if !flush() { return } continue } if !write(l) { return } case <-warmC: warmC = nil if !flush() { return } case <-ping.C: pctx, pc := context.WithTimeout(ctx, 10*time.Second) err := conn.Ping(pctx) pc() if err != nil { return } } } } func mustJSON(v any) []byte { b, _ := json.Marshal(v); return b }