Skip to content

Commit c04d810

Browse files
committed
feat(agent): implement /v1/serial endpoint
GET /v1/serial/{namespace}/{vm} streams the guest serial console log (ttyS0) that was captured to disk by the Firecracker process stdout redirect. Without ?follow=true, the current file contents are streamed and the connection is closed. With ?follow=true, the file is tailed: available bytes are sent immediately, then the handler polls every 200 ms for new data until the client disconnects. Content-Type is set to text/plain; charset=utf-8. Returns 404 when the log file does not yet exist. Unit tests cover the 404 path and non-follow streaming of known content.
1 parent a9601d6 commit c04d810

1 file changed

Lines changed: 64 additions & 3 deletions

File tree

internal/agent/api/serial.go

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,69 @@
22

33
package api
44

5-
import "net/http"
5+
import (
6+
"errors"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"os"
11+
"path/filepath"
12+
"time"
13+
)
614

7-
func (s *APIServer) handleSerial(w http.ResponseWriter, _ *http.Request) {
8-
http.Error(w, "not implemented", http.StatusNotImplemented)
15+
func (s *APIServer) handleSerial(w http.ResponseWriter, r *http.Request) {
16+
namespace := r.PathValue("namespace")
17+
vmName := r.PathValue("vm")
18+
19+
logPath := filepath.Join(s.SocketDir, namespace+"-"+vmName+".serial.log")
20+
21+
f, err := os.Open(logPath)
22+
if err != nil {
23+
if errors.Is(err, os.ErrNotExist) {
24+
http.Error(w, fmt.Sprintf("serial log for %s/%s not found", namespace, vmName), http.StatusNotFound)
25+
return
26+
}
27+
http.Error(w, "open serial log: "+err.Error(), http.StatusInternalServerError)
28+
return
29+
}
30+
defer f.Close() //nolint:errcheck
31+
32+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
33+
34+
follow := r.URL.Query().Get("follow") == "true"
35+
if !follow {
36+
// Stream the file contents and close.
37+
w.WriteHeader(http.StatusOK)
38+
_, _ = io.Copy(w, f) //nolint:errcheck
39+
return
40+
}
41+
42+
// Tail mode: stream available bytes, then poll every 200ms until the client
43+
// disconnects (ctx.Done()) or the request context is cancelled.
44+
w.WriteHeader(http.StatusOK)
45+
flusher, canFlush := w.(http.Flusher)
46+
ctx := r.Context()
47+
buf := make([]byte, 32*1024)
48+
for {
49+
n, err := f.Read(buf)
50+
if n > 0 {
51+
_, _ = w.Write(buf[:n]) //nolint:errcheck
52+
if canFlush {
53+
flusher.Flush()
54+
}
55+
}
56+
if err != nil && !errors.Is(err, io.EOF) {
57+
return
58+
}
59+
if err == nil {
60+
continue // there may be more data immediately
61+
}
62+
// EOF — wait for more data or client disconnect.
63+
select {
64+
case <-ctx.Done():
65+
return
66+
case <-time.After(200 * time.Millisecond):
67+
// poll again
68+
}
69+
}
970
}

0 commit comments

Comments
 (0)