mirror of
https://github.com/tailscale/tailscale.git
synced 2026-05-05 04:06:35 +02:00
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>
210 lines
5.5 KiB
Go
210 lines
5.5 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
package vmtest
|
|
|
|
import (
|
|
"embed"
|
|
"flag"
|
|
"fmt"
|
|
"hash/crc32"
|
|
"html/template"
|
|
"io"
|
|
"io/fs"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/coder/websocket"
|
|
"github.com/robert-nix/ansihtml"
|
|
)
|
|
|
|
var vmtestWeb = flag.String("vmtest-web", "", "listen address for vmtest web UI (e.g. :0, localhost:0, :8080)")
|
|
|
|
//go:embed assets/*.html
|
|
var templatesSrc embed.FS
|
|
|
|
//go:embed assets/*.css
|
|
var staticAssets embed.FS
|
|
|
|
var tmpl = sync.OnceValue(func() *template.Template {
|
|
d, err := fs.Sub(templatesSrc, "assets")
|
|
if err != nil {
|
|
panic(fmt.Errorf("getting vmtest web templates subdir: %w", err))
|
|
}
|
|
return template.Must(template.New("").Funcs(template.FuncMap{
|
|
"formatDuration": formatDuration,
|
|
"ansi": ansiToHTML,
|
|
}).ParseFS(d, "*"))
|
|
})
|
|
|
|
// ansiToHTML converts a string with ANSI escape sequences to HTML with
|
|
// inline styles. Returns template.HTML so html/template doesn't double-escape it.
|
|
func ansiToHTML(s string) template.HTML {
|
|
return template.HTML(ansihtml.ConvertToHTML([]byte(s)))
|
|
}
|
|
|
|
// formatDuration returns a human-readable duration like "1.2s" or "45.3s".
|
|
func formatDuration(d time.Duration) string {
|
|
if d < time.Second {
|
|
return fmt.Sprintf("%dms", d.Milliseconds())
|
|
}
|
|
return fmt.Sprintf("%.1fs", d.Seconds())
|
|
}
|
|
|
|
// deterministicPort returns a deterministic port in the range [20000, 40000)
|
|
// based on the test name, so re-running the same test gets the same URL.
|
|
func deterministicPort(testName string) int {
|
|
return int(crc32.ChecksumIEEE([]byte(testName)))%20000 + 20000
|
|
}
|
|
|
|
// listenWeb listens on the given address. If the port is 0, it first tries a
|
|
// deterministic port based on the test name so re-runs get the same URL.
|
|
// Falls back to :0 (OS-assigned) on any listen error.
|
|
func (e *Env) listenWeb(addr string) (net.Listener, error) {
|
|
host, port, _ := net.SplitHostPort(addr)
|
|
if port == "0" {
|
|
detPort := deterministicPort(e.t.Name())
|
|
detAddr := net.JoinHostPort(host, fmt.Sprintf("%d", detPort))
|
|
if ln, err := net.Listen("tcp", detAddr); err == nil {
|
|
return ln, nil
|
|
}
|
|
// Deterministic port busy; fall back to OS-assigned.
|
|
}
|
|
return net.Listen("tcp", addr)
|
|
}
|
|
|
|
// maybeStartWebServer starts the web UI if --vmtest-web is set.
|
|
// Called at the very top of Env.Start(), before compilation or image downloads.
|
|
func (e *Env) maybeStartWebServer() {
|
|
addr := *vmtestWeb
|
|
if addr == "" {
|
|
return
|
|
}
|
|
|
|
ln, err := e.listenWeb(addr)
|
|
if err != nil {
|
|
e.t.Fatalf("vmtest-web listen: %v", err)
|
|
}
|
|
e.t.Cleanup(func() { ln.Close() })
|
|
|
|
actualAddr := ln.Addr().(*net.TCPAddr)
|
|
|
|
host, _, _ := net.SplitHostPort(addr)
|
|
if host == "" || host == "0.0.0.0" || host == "::" {
|
|
hostname, err := os.Hostname()
|
|
if err != nil {
|
|
hostname = "localhost"
|
|
}
|
|
e.t.Logf("Status at http://%s:%d/", hostname, actualAddr.Port)
|
|
} else {
|
|
e.t.Logf("Status at http://%s/", actualAddr.String())
|
|
}
|
|
|
|
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}
|
|
go srv.Serve(ln)
|
|
e.t.Cleanup(func() { srv.Close() })
|
|
}
|
|
|
|
func serveStaticAsset(name string) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if !strings.HasSuffix(name, ".css") {
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/css")
|
|
f, err := staticAssets.Open(filepath.Join("assets", name))
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer f.Close()
|
|
io.Copy(w, f)
|
|
}
|
|
}
|
|
|
|
func (e *Env) serveIndex(w http.ResponseWriter, r *http.Request) {
|
|
type indexData struct {
|
|
TestName string
|
|
TestStatus *TestStatus
|
|
Steps []*Step
|
|
Nodes []NodeStatus
|
|
}
|
|
|
|
data := indexData{
|
|
TestName: e.t.Name(),
|
|
TestStatus: e.testStatus,
|
|
Steps: e.Steps(),
|
|
}
|
|
for _, n := range e.nodes {
|
|
data.Nodes = append(data.Nodes, e.getNodeStatus(n.name))
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := tmpl().ExecuteTemplate(w, "index.html", data); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
return
|
|
}
|
|
defer conn.CloseNow()
|
|
wsCtx := conn.CloseRead(r.Context())
|
|
|
|
sub := e.eventBus.Subscribe()
|
|
defer sub.Close()
|
|
|
|
for {
|
|
select {
|
|
case <-wsCtx.Done():
|
|
return
|
|
case <-sub.Done():
|
|
return
|
|
case ev := <-sub.Events():
|
|
msg, err := conn.Writer(r.Context(), websocket.MessageText)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if err := tmpl().ExecuteTemplate(msg, "event.html", ev); err != nil {
|
|
msg.Close()
|
|
return
|
|
}
|
|
if err := msg.Close(); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|