mirror of
https://github.com/tailscale/tailscale.git
synced 2026-05-04 19:56:35 +02:00
tstest/natlab/vmtest: add macOS VM screenshot streaming to web UI
When --vmtest-web is set, Host.app is launched with --screenshot-port 0
to start a localhost HTTP server that captures the VZVirtualMachineView
display. The Go test harness parses the SCREENSHOT_PORT=<port> line from
stdout, then polls every 2 seconds for JPEG thumbnails and pushes them
over WebSocket to the web dashboard.
Clicking a screenshot thumbnail opens a full-resolution image proxied
through the web UI's /screenshot/{node} endpoint.
Screenshot events are excluded from the EventBus history (they're large
and only the latest matters, stored in NodeStatus.Screenshot).
Updates #13038
Change-Id: I9bc67ddd1cc72948b33c555d4be3d8db06a41f6d
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
parent
78627c132f
commit
4cec06b8f2
@ -35,5 +35,11 @@
|
||||
<span id="ts-{{.NodeName}}" hx-swap-oob="innerHTML">{{.Detail}}</span>
|
||||
{{end}}
|
||||
|
||||
{{if eq .Type "screenshot"}}
|
||||
<div id="screenshot-{{.NodeName}}" hx-swap-oob="innerHTML"><a href="/screenshot/{{.NodeName}}" target="_blank"><img src="data:image/jpeg;base64,{{.Message}}"></a></div>
|
||||
{{end}}
|
||||
|
||||
{{if ne .Type "screenshot"}}
|
||||
<div id="events" hx-swap-oob="beforeend"><div class="event event-{{.Type}}"><span class="event-time">{{.Time.Format "15:04:05.000"}}</span> {{if .NodeName}}<span class="event-node">[{{.NodeName}}]</span> {{end}}<span class="event-msg">{{.Message}}</span>{{if .Detail}} <span class="event-detail">{{.Detail}}</span>{{end}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@ -47,6 +47,7 @@
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="screenshot" id="screenshot-{{$node.Name}}">{{if $node.Screenshot}}<a href="/screenshot/{{$node.Name}}" target="_blank"><img src="{{$node.Screenshot}}"></a>{{end}}</div>
|
||||
<div class="console" id="console-{{$node.Name}}">{{range $node.Console}}{{ansi .}}
|
||||
{{end}}</div>
|
||||
</div>
|
||||
|
||||
@ -120,6 +120,20 @@ h2 {
|
||||
color: #4af;
|
||||
}
|
||||
|
||||
/* VM display screenshot */
|
||||
.screenshot:empty { display: none; }
|
||||
.screenshot {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.screenshot img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #222;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Console output */
|
||||
.console {
|
||||
background: #0a0a0a;
|
||||
|
||||
@ -4,9 +4,13 @@
|
||||
package vmtest
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@ -168,13 +172,16 @@ func (e *Env) startTailMacVM(n *Node) error {
|
||||
}
|
||||
e.t.Cleanup(func() { os.RemoveAll(cloneDir) })
|
||||
|
||||
// Launch Host.app in headless mode with disconnected NIC,
|
||||
// then hot-swap to the vnet dgram socket after boot.
|
||||
hostBin := filepath.Join(e.tailmacDir, "Host.app", "Contents", "MacOS", "Host")
|
||||
args := []string{
|
||||
"run", "--id", testID, "--headless",
|
||||
}
|
||||
|
||||
wantScreenshots := *vmtestWeb != ""
|
||||
if wantScreenshots {
|
||||
args = append(args, "--screenshot-port", "0")
|
||||
}
|
||||
|
||||
logPath := filepath.Join(e.tempDir, n.name+"-tailmac.log")
|
||||
logFile, err := os.Create(logPath)
|
||||
if err != nil {
|
||||
@ -182,11 +189,22 @@ func (e *Env) startTailMacVM(n *Node) error {
|
||||
}
|
||||
|
||||
cmd := exec.Command(hostBin, args...)
|
||||
// NSUnbufferedIO forces Swift/Foundation to unbuffer stdout so we can
|
||||
// see output in the log file as it happens.
|
||||
cmd.Env = append(os.Environ(), "NSUnbufferedIO=YES")
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
|
||||
// If screenshots are enabled, we need to parse stdout for the
|
||||
// SCREENSHOT_PORT=<port> line, while also logging everything to file.
|
||||
var stdoutPipe io.ReadCloser
|
||||
if wantScreenshots {
|
||||
stdoutPipe, err = cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
logFile.Close()
|
||||
return fmt.Errorf("stdout pipe: %w", err)
|
||||
}
|
||||
cmd.Stderr = logFile
|
||||
} else {
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
}
|
||||
devNull, err := os.Open(os.DevNull)
|
||||
if err != nil {
|
||||
logFile.Close()
|
||||
@ -201,6 +219,36 @@ func (e *Env) startTailMacVM(n *Node) error {
|
||||
}
|
||||
e.t.Logf("[%s] launched tailmac (pid %d), log: %s", n.name, cmd.Process.Pid, logPath)
|
||||
|
||||
// Parse screenshot port from stdout and start polling goroutine.
|
||||
if wantScreenshots {
|
||||
screenshotPortCh := make(chan int, 1)
|
||||
go func() {
|
||||
scanner := bufio.NewScanner(stdoutPipe)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
fmt.Fprintln(logFile, line) // tee to log file
|
||||
if port := 0; strings.HasPrefix(line, "SCREENSHOT_PORT=") {
|
||||
fmt.Sscanf(line, "SCREENSHOT_PORT=%d", &port)
|
||||
if port > 0 {
|
||||
screenshotPortCh <- port
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
select {
|
||||
case port := <-screenshotPortCh:
|
||||
e.t.Logf("[%s] screenshot server on port %d", n.name, port)
|
||||
e.setNodeScreenshotPort(n.name, port)
|
||||
e.tailScreenshots(n.name, port)
|
||||
case <-ctx.Done():
|
||||
e.t.Logf("[%s] screenshot port not received", n.name)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
clientSock := fmt.Sprintf("/tmp/qemu-dgram-%s.sock", testID)
|
||||
|
||||
e.t.Cleanup(func() {
|
||||
@ -234,3 +282,32 @@ func (e *Env) startTailMacVM(n *Node) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// tailScreenshots polls the Host.app screenshot HTTP server every 2 seconds
|
||||
// and publishes each screenshot as a base64 data URI to the web UI.
|
||||
func (e *Env) tailScreenshots(name string, port int) {
|
||||
url := fmt.Sprintf("http://127.0.0.1:%d/screenshot", port)
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 || len(data) == 0 {
|
||||
continue
|
||||
}
|
||||
b64 := base64.StdEncoding.EncodeToString(data)
|
||||
dataURI := "data:image/jpeg;base64," + b64
|
||||
e.setNodeScreenshot(name, dataURI)
|
||||
e.eventBus.Publish(VMEvent{
|
||||
NodeName: name,
|
||||
Type: EventScreenshot,
|
||||
Message: b64,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -139,6 +139,7 @@ const (
|
||||
EventDHCPOffer EventType = "dhcp_offer" // server sent DHCP Offer
|
||||
EventDHCPRequest EventType = "dhcp_request" // VM sent DHCP Request
|
||||
EventDHCPAck EventType = "dhcp_ack" // server sent DHCP Ack
|
||||
EventScreenshot EventType = "screenshot" // VM display screenshot (JPEG, base64)
|
||||
EventTailscale EventType = "tailscale" // Tailscale status change
|
||||
EventTestStatus EventType = "test_status" // test Running/Passed/Failed
|
||||
)
|
||||
@ -212,12 +213,14 @@ type NICStatus struct {
|
||||
// NodeStatus tracks the current DHCP and Tailscale state of a VM node
|
||||
// for rendering on the web UI's initial page load.
|
||||
type NodeStatus struct {
|
||||
Name string
|
||||
OS string
|
||||
NICs []NICStatus // one per NIC; index matches NIC index
|
||||
JoinsTailnet bool // whether this node runs Tailscale
|
||||
Tailscale string // "--", "Up (100.64.0.1)", etc.
|
||||
Console []string // recent console output lines (ring buffer)
|
||||
Name string
|
||||
OS string
|
||||
NICs []NICStatus // one per NIC; index matches NIC index
|
||||
JoinsTailnet bool // whether this node runs Tailscale
|
||||
Tailscale string // "--", "Up (100.64.0.1)", etc.
|
||||
Console []string // recent console output lines (ring buffer)
|
||||
Screenshot string // latest screenshot as data URI, or ""
|
||||
ScreenshotPort int // Host.app screenshot server port, or 0
|
||||
}
|
||||
|
||||
const maxConsoleLines = 200
|
||||
@ -249,7 +252,11 @@ func (b *EventBus) Publish(ev VMEvent) {
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.history = append(b.history, ev)
|
||||
// Don't store screenshots in history — they're large and only the
|
||||
// latest one matters (stored in NodeStatus.Screenshot instead).
|
||||
if ev.Type != EventScreenshot {
|
||||
b.history = append(b.history, ev)
|
||||
}
|
||||
if len(b.history) > eventBusHistorySize {
|
||||
// Trim old events.
|
||||
copy(b.history, b.history[len(b.history)-eventBusHistorySize:])
|
||||
|
||||
@ -1198,6 +1198,34 @@ func (e *Env) HTTPGet(from *Node, targetURL string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// setNodeScreenshot stores the latest screenshot data URI for a node.
|
||||
func (e *Env) setNodeScreenshot(name, dataURI string) {
|
||||
e.nodeStatusMu.Lock()
|
||||
if ns := e.nodeStatus[name]; ns != nil {
|
||||
ns.Screenshot = dataURI
|
||||
}
|
||||
e.nodeStatusMu.Unlock()
|
||||
}
|
||||
|
||||
// setNodeScreenshotPort stores the Host.app screenshot server port for a node.
|
||||
func (e *Env) setNodeScreenshotPort(name string, port int) {
|
||||
e.nodeStatusMu.Lock()
|
||||
if ns := e.nodeStatus[name]; ns != nil {
|
||||
ns.ScreenshotPort = port
|
||||
}
|
||||
e.nodeStatusMu.Unlock()
|
||||
}
|
||||
|
||||
// nodeScreenshotPort returns the Host.app screenshot server port for a node, or 0.
|
||||
func (e *Env) nodeScreenshotPort(name string) int {
|
||||
e.nodeStatusMu.Lock()
|
||||
defer e.nodeStatusMu.Unlock()
|
||||
if ns := e.nodeStatus[name]; ns != nil {
|
||||
return ns.ScreenshotPort
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Agent returns the node's TTA agent client, or nil if NoAgent is set.
|
||||
func (n *Node) Agent() *vnet.NodeAgentClient {
|
||||
return n.agent
|
||||
|
||||
@ -108,6 +108,7 @@ func (e *Env) maybeStartWebServer() {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /", e.serveIndex)
|
||||
mux.HandleFunc("GET /ws", e.serveWebSocket)
|
||||
mux.HandleFunc("GET /screenshot/{node}", e.serveScreenshot)
|
||||
mux.HandleFunc("GET /style.css", serveStaticAsset("style.css"))
|
||||
|
||||
srv := &http.Server{Handler: mux}
|
||||
@ -155,6 +156,25 @@ func (e *Env) serveIndex(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// serveScreenshot proxies a full-resolution screenshot from the Host.app
|
||||
// screenshot server. Returns raw JPEG with no HTML wrapper.
|
||||
func (e *Env) serveScreenshot(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("node")
|
||||
port := e.nodeScreenshotPort(name)
|
||||
if port == 0 {
|
||||
http.Error(w, "no screenshot server for node", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/screenshot?full=1", port))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
io.Copy(w, resp.Body)
|
||||
}
|
||||
|
||||
func (e *Env) serveWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := websocket.Accept(w, r, nil)
|
||||
if err != nil {
|
||||
|
||||
@ -5,12 +5,12 @@ endif
|
||||
|
||||
.PHONY: tailmac
|
||||
tailmac:
|
||||
xcodebuild -scheme tailmac -destination 'platform=macOS,arch=arm64' -derivedDataPath build -configuration Release build | $(XCPRETTIFIER)
|
||||
set -o pipefail && xcodebuild -scheme tailmac -destination 'platform=macOS,arch=arm64' -derivedDataPath build -configuration Release build | $(XCPRETTIFIER)
|
||||
cp -r ./build/Build/Products/Release/tailmac ./bin/tailmac
|
||||
|
||||
.PHONY: host
|
||||
host:
|
||||
xcodebuild -scheme host -destination 'platform=macOS,arch=arm64' -derivedDataPath build -configuration Release build | $(XCPRETTIFIER)
|
||||
set -o pipefail && xcodebuild -scheme host -destination 'platform=macOS,arch=arm64' -derivedDataPath build -configuration Release build | $(XCPRETTIFIER)
|
||||
cp -r ./build/Build/Products/Release/Host.app ./bin/Host.app
|
||||
|
||||
.PHONY: clean
|
||||
|
||||
@ -202,12 +202,6 @@ class ScreenshotHTTPServer: NSObject {
|
||||
let n = read(fd, &buf, buf.count)
|
||||
let requestLine = n > 0 ? String(bytes: buf[..<n], encoding: .utf8) ?? "" : ""
|
||||
|
||||
// Route: POST /keypress?key=<keycode> — send a key event to the VM.
|
||||
if requestLine.contains("/keypress") {
|
||||
handleKeypress(fd, requestLine)
|
||||
return
|
||||
}
|
||||
|
||||
// Route: GET /screenshot — capture the VM display.
|
||||
let wantFull = requestLine.contains("full=1")
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user