From b77e8cedb36f0585fefd74ae9cdfe2713a82a93e Mon Sep 17 00:00:00 2001 From: Fedor Korotkov Date: Sat, 13 Jun 2026 13:04:15 -0400 Subject: [PATCH 1/2] Add VNC snapshot recording --- builder/tart/builder.go | 40 ++++-- builder/tart/builder.hcl2spec.go | 4 + builder/tart/builder_test.go | 53 ++++++++ builder/tart/step_run.go | 162 +++++++++++++++++----- builder/tart/vnc.go | 134 +++---------------- builder/tart/vnc_framebuffer.go | 61 +++++++++ builder/tart/vnc_framebuffer_test.go | 75 +++++++++++ builder/tart/vnc_recorder.go | 193 +++++++++++++++++++++++++++ builder/tart/vnc_recorder_test.go | 65 +++++++++ builder/tart/vnc_session.go | 134 +++++++++++++++++++ docs/builders/tart.mdx | 2 + 11 files changed, 757 insertions(+), 166 deletions(-) create mode 100644 builder/tart/builder_test.go create mode 100644 builder/tart/vnc_framebuffer.go create mode 100644 builder/tart/vnc_framebuffer_test.go create mode 100644 builder/tart/vnc_recorder.go create mode 100644 builder/tart/vnc_recorder_test.go create mode 100644 builder/tart/vnc_session.go diff --git a/builder/tart/builder.go b/builder/tart/builder.go index 23c3b43f..64f98b46 100644 --- a/builder/tart/builder.go +++ b/builder/tart/builder.go @@ -35,18 +35,20 @@ type Config struct { AlwaysPull bool `mapstructure:"always_pull"` PullConcurrency uint16 `mapstructure:"pull_concurrency"` - CpuCount uint8 `mapstructure:"cpu_count"` - CreateGraceTime time.Duration `mapstructure:"create_grace_time"` - DiskSizeGb uint16 `mapstructure:"disk_size_gb"` - DiskFormat string `mapstructure:"disk_format"` - RecoveryPartition string `mapstructure:"recovery_partition"` - Display string `mapstructure:"display"` - Headless bool `mapstructure:"headless"` - MemoryGb uint16 `mapstructure:"memory_gb"` - Recovery bool `mapstructure:"recovery"` - Rosetta string `mapstructure:"rosetta"` - RunExtraArgs []string `mapstructure:"run_extra_args"` - IpExtraArgs []string `mapstructure:"ip_extra_args"` + CpuCount uint8 `mapstructure:"cpu_count"` + CreateGraceTime time.Duration `mapstructure:"create_grace_time"` + DiskSizeGb uint16 `mapstructure:"disk_size_gb"` + DiskFormat string `mapstructure:"disk_format"` + RecoveryPartition string `mapstructure:"recovery_partition"` + Display string `mapstructure:"display"` + Headless bool `mapstructure:"headless"` + MemoryGb uint16 `mapstructure:"memory_gb"` + Recovery bool `mapstructure:"recovery"` + Rosetta string `mapstructure:"rosetta"` + RunExtraArgs []string `mapstructure:"run_extra_args"` + IpExtraArgs []string `mapstructure:"ip_extra_args"` + VNCRecordingDir string `mapstructure:"vnc_recording_dir"` + VNCRecordingInterval time.Duration `mapstructure:"vnc_recording_interval"` ctx interpolate.Context } @@ -105,6 +107,18 @@ func (b *Builder) Prepare(raws ...interface{}) (generatedVars []string, warnings "for ASIF disks") } + if b.config.VNCRecordingDir != "" { + if b.config.DisableVNC { + return nil, nil, fmt.Errorf("vnc_recording_dir requires VNC; remove disable_vnc or set it to false") + } + if b.config.VNCRecordingInterval == 0 { + b.config.VNCRecordingInterval = time.Second + } + if b.config.VNCRecordingInterval < 0 { + return nil, nil, fmt.Errorf("vnc_recording_interval must be greater than 0") + } + } + if errs := b.config.CommunicatorConfig.Prepare(&b.config.ctx); len(errs) != 0 { return nil, nil, packer.MultiErrorAppend(nil, errs...) } @@ -152,7 +166,7 @@ func (b *Builder) Run(ctx context.Context, ui packer.Ui, hook packer.Hook) (pack ) communicatorConfigured := b.config.CommunicatorConfig.Type != "none" - if len(b.config.BootCommand) > 0 || communicatorConfigured { + if len(b.config.BootCommand) > 0 || communicatorConfigured || b.config.VNCRecordingDir != "" { steps = append(steps, new(stepRun)) } diff --git a/builder/tart/builder.hcl2spec.go b/builder/tart/builder.hcl2spec.go index 0457856f..d67b3562 100644 --- a/builder/tart/builder.hcl2spec.go +++ b/builder/tart/builder.hcl2spec.go @@ -98,6 +98,8 @@ type FlatConfig struct { Rosetta *string `mapstructure:"rosetta" cty:"rosetta" hcl:"rosetta"` RunExtraArgs []string `mapstructure:"run_extra_args" cty:"run_extra_args" hcl:"run_extra_args"` IpExtraArgs []string `mapstructure:"ip_extra_args" cty:"ip_extra_args" hcl:"ip_extra_args"` + VNCRecordingDir *string `mapstructure:"vnc_recording_dir" cty:"vnc_recording_dir" hcl:"vnc_recording_dir"` + VNCRecordingInterval *string `mapstructure:"vnc_recording_interval" cty:"vnc_recording_interval" hcl:"vnc_recording_interval"` } // FlatMapstructure returns a new FlatConfig. @@ -200,6 +202,8 @@ func (*FlatConfig) HCL2Spec() map[string]hcldec.Spec { "rosetta": &hcldec.AttrSpec{Name: "rosetta", Type: cty.String, Required: false}, "run_extra_args": &hcldec.AttrSpec{Name: "run_extra_args", Type: cty.List(cty.String), Required: false}, "ip_extra_args": &hcldec.AttrSpec{Name: "ip_extra_args", Type: cty.List(cty.String), Required: false}, + "vnc_recording_dir": &hcldec.AttrSpec{Name: "vnc_recording_dir", Type: cty.String, Required: false}, + "vnc_recording_interval": &hcldec.AttrSpec{Name: "vnc_recording_interval", Type: cty.String, Required: false}, } return s } diff --git a/builder/tart/builder_test.go b/builder/tart/builder_test.go new file mode 100644 index 00000000..c55f078c --- /dev/null +++ b/builder/tart/builder_test.go @@ -0,0 +1,53 @@ +package tart + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestPrepareVNCRecordingDefaultsInterval(t *testing.T) { + var builder Builder + + _, _, err := builder.Prepare(map[string]interface{}{ + "communicator": "none", + "vnc_recording_dir": "frames", + }) + require.NoError(t, err) + require.Equal(t, time.Second, builder.config.VNCRecordingInterval) +} + +func TestPrepareVNCRecordingUsesConfiguredInterval(t *testing.T) { + var builder Builder + + _, _, err := builder.Prepare(map[string]interface{}{ + "communicator": "none", + "vnc_recording_dir": "frames", + "vnc_recording_interval": "250ms", + }) + require.NoError(t, err) + require.Equal(t, 250*time.Millisecond, builder.config.VNCRecordingInterval) +} + +func TestPrepareVNCRecordingRejectsDisableVNC(t *testing.T) { + var builder Builder + + _, _, err := builder.Prepare(map[string]interface{}{ + "communicator": "none", + "disable_vnc": true, + "vnc_recording_dir": "frames", + }) + require.ErrorContains(t, err, "vnc_recording_dir requires VNC") +} + +func TestPrepareVNCRecordingRejectsNegativeInterval(t *testing.T) { + var builder Builder + + _, _, err := builder.Prepare(map[string]interface{}{ + "communicator": "none", + "vnc_recording_dir": "frames", + "vnc_recording_interval": "-1s", + }) + require.ErrorContains(t, err, "vnc_recording_interval must be greater than 0") +} diff --git a/builder/tart/step_run.go b/builder/tart/step_run.go index 782fffff..01fc2a76 100644 --- a/builder/tart/step_run.go +++ b/builder/tart/step_run.go @@ -114,8 +114,26 @@ func (s *stepRun) Run(ctx context.Context, state multistep.StateBag) multistep.S } }() + needsVNCSession := !config.DisableVNC && (len(config.BootCommand) > 0 || config.VNCRecordingDir != "") + var vncSession *vncSession + if needsVNCSession { + vncConnection, ok := connectToTartVNC(ctx, state, ui, stdout) + if !ok { + return multistep.ActionHalt + } + state.Put("vnc-connection", vncConnection) + vncSession = vncConnection.session + + if config.VNCRecordingDir != "" { + if !startVNCRecording(ctx, state, config, ui, vncSession) { + return multistep.ActionHalt + } + } + } + if len(config.BootCommand) > 0 && !config.DisableVNC { - if !typeBootCommandOverVNC(ctx, state, config, ui, stdout) { + vncDriver := newCustomDriver(vncSession, config, ctx) + if !typeBootCommandOverVNC(ctx, state, config, ui, vncDriver) { return multistep.ActionHalt } } @@ -132,12 +150,23 @@ func (u uiWriter) Write(p []byte) (n int, err error) { return len(p), nil } +type vncConnection struct { + session *vncSession + netConn net.Conn +} + +func (c *vncConnection) Close() { + c.session.vncClient.Close() + _ = c.netConn.Close() +} + // Cleanup stops the VM. func (s *stepRun) Cleanup(state multistep.StateBag) { config := state.Get("config").(*Config) ui := state.Get("ui").(packersdk.Ui) cmd := state.Get("tart-cmd").(*exec.Cmd) if cmd == nil || cmd.ProcessState != nil { + cleanupVNCResources(state, ui) return // Nothing to shut down } @@ -170,41 +199,16 @@ func (s *stepRun) Cleanup(state multistep.StateBag) { // so that we properly read and close stdout/stderr. ui.Say("Waiting for the tart process to exit...") _, _ = cmd.Process.Wait() + + cleanupVNCResources(state, ui) } -func typeBootCommandOverVNC( +func connectToTartVNC( ctx context.Context, state multistep.StateBag, - config *Config, ui packersdk.Ui, tartRunStdout *bytes.Buffer, -) bool { - ui.Say("Typing boot commands over VNC...") - - if config.HTTPDir != "" || len(config.HTTPContent) != 0 { - ui.Say("Detecting host IP...") - - hostIP, err := detectHostIP(ctx, config) - if err != nil { - err := fmt.Errorf("Failed to detect the host IP address: %v", err) - state.Put("error", err) - ui.Error(err.Error()) - - return false - } - - ui.Say(fmt.Sprintf("Host IP is assumed to be %s", hostIP)) - state.Put("http_ip", hostIP) - - // Should be already filled by the Packer's commonsteps.StepHTTPServer - httpPort := state.Get("http_port").(int) - - config.ctx.Data = &bootCommandTemplateData{ - HTTPIP: hostIP, - HTTPPort: httpPort, - } - } - +) (*vncConnection, bool) { ui.Say("Waiting for VNC server credentials from Tart...") vncCtx, cancel := context.WithTimeout(ctx, 30*time.Second) @@ -226,7 +230,7 @@ func typeBootCommandOverVNC( select { case <-vncCtx.Done(): - return false + return nil, false case <-time.After(time.Second): // continue } @@ -243,9 +247,8 @@ func typeBootCommandOverVNC( state.Put("error", err) ui.Error(err.Error()) - return false + return nil, false } - defer netConn.Close() serverMessageChannel := make(chan vnc.ServerMessage) vncClient, err := vnc.Client(netConn, &vnc.ClientConfig{ @@ -259,9 +262,9 @@ func typeBootCommandOverVNC( state.Put("error", err) ui.Error(err.Error()) - return false + _ = netConn.Close() + return nil, false } - defer vncClient.Close() ui.Say("Connected to VNC server!") @@ -271,12 +274,99 @@ func typeBootCommandOverVNC( }) if err != nil { err := fmt.Errorf("Failed to set VNC encoding: %s", err) + state.Put("error", err) + ui.Error(err.Error()) + vncClient.Close() + _ = netConn.Close() + return nil, false + } + + return &vncConnection{ + session: newVNCSession(vncClient, serverMessageChannel), + netConn: netConn, + }, true +} + +func startVNCRecording( + ctx context.Context, + state multistep.StateBag, + config *Config, + ui packersdk.Ui, + session *vncSession, +) bool { + recorder := newVNCRecorder(session, config.VNCRecordingDir, config.VNCRecordingInterval) + if err := recorder.Prepare(); err != nil { state.Put("error", err) ui.Error(err.Error()) return false } - vncDriver := newCustomDriver(vncClient, serverMessageChannel, config, ctx) + ui.Sayf("Recording VNC snapshots to %s every %s...", + config.VNCRecordingDir, config.VNCRecordingInterval) + + handle := startVNCRecorder(ctx, recorder) + state.Put("vnc-recorder", handle) + + go func() { + <-handle.done + if err := handle.Err(); err != nil { + ui.Error(fmt.Sprintf("VNC recording failed: %s", err)) + state.Put("error", err) + cancelBuild, ok := state.Get("cancel-build").(context.CancelFunc) + if ok && cancelBuild != nil { + cancelBuild() + } + } + }() + + return true +} + +func cleanupVNCResources(state multistep.StateBag, ui packersdk.Ui) { + if rawRecorder, ok := state.GetOk("vnc-recorder"); ok { + if err := rawRecorder.(*vncRecorderHandle).Stop(); err != nil { + ui.Error(fmt.Sprintf("VNC recording failed: %s", err)) + state.Put("error", err) + } + } + + if rawConnection, ok := state.GetOk("vnc-connection"); ok { + rawConnection.(*vncConnection).Close() + } +} + +func typeBootCommandOverVNC( + ctx context.Context, + state multistep.StateBag, + config *Config, + ui packersdk.Ui, + vncDriver *customDriver, +) bool { + ui.Say("Typing boot commands over VNC...") + + if config.HTTPDir != "" || len(config.HTTPContent) != 0 { + ui.Say("Detecting host IP...") + + hostIP, err := detectHostIP(ctx, config) + if err != nil { + err := fmt.Errorf("Failed to detect the host IP address: %v", err) + state.Put("error", err) + ui.Error(err.Error()) + + return false + } + + ui.Say(fmt.Sprintf("Host IP is assumed to be %s", hostIP)) + state.Put("http_ip", hostIP) + + // Should be already filled by the Packer's commonsteps.StepHTTPServer + httpPort := state.Get("http_port").(int) + + config.ctx.Data = &bootCommandTemplateData{ + HTTPIP: hostIP, + HTTPPort: httpPort, + } + } if config.VNCConfig.BootWait > 0 { message := fmt.Sprintf("Waiting %v after the VM has booted...", config.VNCConfig.BootWait) diff --git a/builder/tart/vnc.go b/builder/tart/vnc.go index b85f6405..facdf57f 100644 --- a/builder/tart/vnc.go +++ b/builder/tart/vnc.go @@ -12,6 +12,7 @@ import "C" import ( "context" "fmt" + "image" "io" "os" "strings" @@ -20,32 +21,20 @@ import ( "github.com/hashicorp/packer-plugin-sdk/bootcommand" "github.com/mitchellh/go-vnc" - "image" - "image/color" - "image/png" - "unsafe" ) -var debugVNC = os.Getenv("PACKER_TART_DEBUG_VNC_FRAMEBUFFER_UPDATES") != "" - type customDriver struct { - vncClient *vnc.ClientConn - serverMessageChannel chan vnc.ServerMessage - config *Config - vncDriver bootcommand.BCDriver - keyInterval time.Duration - ctx context.Context - frameBuffer *image.RGBA - waitString strings.Builder - clickString strings.Builder + vncClient *vnc.ClientConn + session *vncSession + vncDriver bootcommand.BCDriver + keyInterval time.Duration + ctx context.Context + waitString strings.Builder + clickString strings.Builder } -func newCustomDriver(vncClient *vnc.ClientConn, - serverMessageChannel chan vnc.ServerMessage, - config *Config, - ctx context.Context) *customDriver { - +func newCustomDriver(session *vncSession, config *Config, ctx context.Context) *customDriver { // Resolve key interval manually so we can accurately report it back keyInterval := bootcommand.PackerKeyDefault if delay, err := time.ParseDuration(os.Getenv(bootcommand.PackerKeyEnv)); err == nil { @@ -55,16 +44,12 @@ func newCustomDriver(vncClient *vnc.ClientConn, keyInterval = config.BootKeyInterval } - w, h := int(vncClient.FrameBufferWidth), int(vncClient.FrameBufferHeight) - d := &customDriver{ - vncClient: vncClient, - serverMessageChannel: serverMessageChannel, - config: config, - vncDriver: bootcommand.NewVNCDriver(vncClient, keyInterval), - keyInterval: keyInterval, - ctx: ctx, - frameBuffer: image.NewRGBA(image.Rect(0, 0, w, h)), + vncClient: session.vncClient, + session: session, + vncDriver: bootcommand.NewVNCDriver(session.vncClient, keyInterval), + keyInterval: keyInterval, + ctx: ctx, } return d @@ -97,7 +82,7 @@ func (d *customDriver) SendKey(key rune, action bootcommand.KeyAction) error { for { fmt.Fprintf(os.Stderr, "🔎 Looking for '%s'...\n", waitString) - if FindTextCoordinates(d.frameBuffer, waitString) != nil { + if FindTextCoordinates(d.session.Snapshot(), waitString) != nil { break } @@ -116,7 +101,7 @@ func (d *customDriver) SendKey(key rune, action bootcommand.KeyAction) error { for { fmt.Fprintf(os.Stderr, "🔎 Looking for '%s'...\n", clickString) - rectangle = FindTextCoordinates(d.frameBuffer, clickString) + rectangle = FindTextCoordinates(d.session.Snapshot(), clickString) if rectangle != nil { break } @@ -160,92 +145,7 @@ func (d *customDriver) Flush() error { } func (d *customDriver) WaitForFramebufferUpdate() error { - incremental := true - - for { - w, h := d.vncClient.FrameBufferWidth, d.vncClient.FrameBufferHeight - fmt.Fprintf(os.Stderr, "📡 Requesting %s frame buffer update for %dx%d\n", - map[bool]string{true: "incremental", false: "full"}[incremental], w, h) - - if err := d.vncClient.FramebufferUpdateRequest(incremental, 0, 0, w, h); err != nil { - return err - } - - select { - case msg := <-d.serverMessageChannel: - if framebufferUpdateMessage, ok := msg.(*vnc.FramebufferUpdateMessage); ok { - if len(framebufferUpdateMessage.Rectangles) == 0 { - return fmt.Errorf("⚠️ Frame update did not have any rectangles") - } - fmt.Fprintf(os.Stderr, "🖼️ New framebuffer update with %d rectangles\n", - len(framebufferUpdateMessage.Rectangles)) - - for _, rect := range framebufferUpdateMessage.Rectangles { - switch encoding := rect.Enc.(type) { - case *DesktopSizePseudoEncoding: - w, h := int(d.vncClient.FrameBufferWidth), int(d.vncClient.FrameBufferHeight) - d.frameBuffer = image.NewRGBA(image.Rect(0, 0, w, h)) - fmt.Fprintf(os.Stderr, "🖥️ New desktop size is %dx%d, resized framebuffer\n", w, h) - continue - case *vnc.RawEncoding: - for i, c := range encoding.Colors { - x, y := i%int(rect.Width), i/int(rect.Width) - r, g, b := uint8(c.R), uint8(c.G), uint8(c.B) - d.frameBuffer.Set(int(rect.X)+x, int(rect.Y)+y, color.RGBA{r, g, b, 255}) - } - default: - return fmt.Errorf("⚠️ Frame had unknown encoding %s", encoding) - } - } - - if debugVNC { - file, err := os.Create("framebuffer.png") - if err != nil { - fmt.Fprintf(os.Stderr, "⚠️ Failed to create framebuffer file: %v\n", err) - } else { - if err := png.Encode(file, d.frameBuffer); err != nil { - fmt.Fprintf(os.Stderr, "⚠️ Failed to encode framebuffer: %v\n", err) - } - _ = file.Close() - } - } - - return nil - } else { - // Ignore messages we didn't ask for - fmt.Fprintln(os.Stderr, "⚠️ Ignoring unknown message type", msg.Type(), msg) - continue - } - case <-time.After(30 * time.Second): - fmt.Fprintf(os.Stderr, "⏱️ Framebuffer update timed out after 30s. ") - // The built-in VNC server in Virtualization.framework will sometimes - // fail to deliver a framebuffer update, even though the VM view shows - // new content. - if (incremental) { - // As a first step, we try a full update, which according to - // RFC 6143 7.5.3 should result in the server sending the entire - // contents of the specified area as soon as possible. - fmt.Fprintf(os.Stderr, "Switching to full update\n") - incremental = false - } else { - // However even full updates may in some cases fail to trigger - // an update from the VZ VNC server. As a second step, we move - // the mouse, which should result in an update (as long as the - // VM shows a local cursor). - fmt.Fprintf(os.Stderr, "Moving mouse to trigger update\n") - if err := d.vncClient.PointerEvent(0, w-1, 0); err != nil { - return err - } - time.Sleep(1 * time.Second) - if err := d.vncClient.PointerEvent(0, 0, 0); err != nil { - return err - } - } - continue - case <-d.ctx.Done(): - return d.ctx.Err() - } - } + return d.session.WaitForFramebufferUpdate(d.ctx) } type DesktopSizePseudoEncoding struct{} diff --git a/builder/tart/vnc_framebuffer.go b/builder/tart/vnc_framebuffer.go new file mode 100644 index 00000000..e89c4eca --- /dev/null +++ b/builder/tart/vnc_framebuffer.go @@ -0,0 +1,61 @@ +package tart + +import ( + "fmt" + "image" + "image/color" + "os" + "sync" + + "github.com/mitchellh/go-vnc" +) + +type vncFrameBuffer struct { + mu sync.RWMutex + rgba *image.RGBA +} + +func newVNCFrameBuffer(width, height int) *vncFrameBuffer { + return &vncFrameBuffer{ + rgba: image.NewRGBA(image.Rect(0, 0, width, height)), + } +} + +func (f *vncFrameBuffer) applyUpdate(rectangles []vnc.Rectangle, width, height uint16) error { + f.mu.Lock() + defer f.mu.Unlock() + + for _, rect := range rectangles { + switch encoding := rect.Enc.(type) { + case *DesktopSizePseudoEncoding: + f.rgba = image.NewRGBA(image.Rect(0, 0, int(width), int(height))) + fmt.Fprintf(os.Stderr, "🖥️ New desktop size is %dx%d, resized framebuffer\n", width, height) + case *vnc.RawEncoding: + expectedColors := int(rect.Width) * int(rect.Height) + if len(encoding.Colors) != expectedColors { + return fmt.Errorf("raw frame rectangle %dx%d contains %d colors, expected %d", + rect.Width, rect.Height, len(encoding.Colors), expectedColors) + } + + for i, c := range encoding.Colors { + x, y := i%int(rect.Width), i/int(rect.Width) + r, g, b := uint8(c.R), uint8(c.G), uint8(c.B) + f.rgba.Set(int(rect.X)+x, int(rect.Y)+y, color.RGBA{r, g, b, 255}) + } + default: + return fmt.Errorf("frame had unknown encoding %T", encoding) + } + } + + return nil +} + +func (f *vncFrameBuffer) snapshot() *image.RGBA { + f.mu.RLock() + defer f.mu.RUnlock() + + frame := image.NewRGBA(f.rgba.Bounds()) + copy(frame.Pix, f.rgba.Pix) + + return frame +} diff --git a/builder/tart/vnc_framebuffer_test.go b/builder/tart/vnc_framebuffer_test.go new file mode 100644 index 00000000..e33f5794 --- /dev/null +++ b/builder/tart/vnc_framebuffer_test.go @@ -0,0 +1,75 @@ +package tart + +import ( + "image" + "image/color" + "testing" + + "github.com/mitchellh/go-vnc" + "github.com/stretchr/testify/require" +) + +func TestVNCFrameBufferAppliesRawUpdates(t *testing.T) { + frameBuffer := newVNCFrameBuffer(2, 2) + + err := frameBuffer.applyUpdate([]vnc.Rectangle{ + { + X: 0, + Y: 1, + Width: 2, + Height: 1, + Enc: &vnc.RawEncoding{Colors: []vnc.Color{ + {R: 255, G: 0, B: 0}, + {R: 0, G: 255, B: 0}, + }}, + }, + }, 2, 2) + require.NoError(t, err) + + snapshot := frameBuffer.snapshot() + require.Equal(t, color.RGBA{R: 255, A: 255}, snapshot.RGBAAt(0, 1)) + require.Equal(t, color.RGBA{G: 255, A: 255}, snapshot.RGBAAt(1, 1)) +} + +func TestVNCFrameBufferSnapshotIsACopy(t *testing.T) { + frameBuffer := newVNCFrameBuffer(1, 1) + err := frameBuffer.applyUpdate([]vnc.Rectangle{ + { + Width: 1, + Height: 1, + Enc: &vnc.RawEncoding{Colors: []vnc.Color{ + {R: 10, G: 20, B: 30}, + }}, + }, + }, 1, 1) + require.NoError(t, err) + + snapshot := frameBuffer.snapshot() + snapshot.SetRGBA(0, 0, color.RGBA{}) + + require.Equal(t, color.RGBA{R: 10, G: 20, B: 30, A: 255}, frameBuffer.snapshot().RGBAAt(0, 0)) +} + +func TestVNCFrameBufferResizesOnDesktopSizeUpdate(t *testing.T) { + frameBuffer := newVNCFrameBuffer(1, 1) + + err := frameBuffer.applyUpdate([]vnc.Rectangle{ + {Enc: &DesktopSizePseudoEncoding{}}, + }, 3, 4) + require.NoError(t, err) + + require.Equal(t, image.Rect(0, 0, 3, 4), frameBuffer.snapshot().Bounds()) +} + +func TestVNCFrameBufferRejectsMalformedRawUpdate(t *testing.T) { + frameBuffer := newVNCFrameBuffer(2, 2) + + err := frameBuffer.applyUpdate([]vnc.Rectangle{ + { + Width: 2, + Height: 2, + Enc: &vnc.RawEncoding{Colors: []vnc.Color{{}}}, + }, + }, 2, 2) + require.ErrorContains(t, err, "contains 1 colors, expected 4") +} diff --git a/builder/tart/vnc_recorder.go b/builder/tart/vnc_recorder.go new file mode 100644 index 00000000..6a81f328 --- /dev/null +++ b/builder/tart/vnc_recorder.go @@ -0,0 +1,193 @@ +package tart + +import ( + "context" + "crypto/sha256" + "encoding/binary" + "fmt" + "image" + "image/png" + "os" + "path/filepath" + "sync" + "time" +) + +type vncRecorder struct { + session *vncSession + dir string + interval time.Duration + now func() time.Time + hasLast bool + lastHash [sha256.Size]byte +} + +func newVNCRecorder(session *vncSession, dir string, interval time.Duration) *vncRecorder { + return &vncRecorder{ + session: session, + dir: dir, + interval: interval, + now: time.Now, + } +} + +func (r *vncRecorder) Prepare() error { + if err := os.MkdirAll(r.dir, 0755); err != nil { + return fmt.Errorf("failed to create VNC recording directory %q: %w", r.dir, err) + } + + if err := clearDirectory(r.dir); err != nil { + return fmt.Errorf("failed to clear VNC recording directory %q: %w", r.dir, err) + } + + return nil +} + +func clearDirectory(dir string) error { + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + + for _, entry := range entries { + if err := os.RemoveAll(filepath.Join(dir, entry.Name())); err != nil { + return err + } + } + + return nil +} + +func (r *vncRecorder) Run(ctx context.Context) error { + if err := r.capture(ctx, false); err != nil { + if ctx.Err() != nil { + return nil + } + return err + } + + ticker := time.NewTicker(r.interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + if err := r.capture(ctx, true); err != nil { + if ctx.Err() != nil { + return nil + } + return err + } + } + } +} + +func (r *vncRecorder) capture(ctx context.Context, incremental bool) error { + var err error + if incremental { + err = r.session.WaitForFramebufferUpdate(ctx) + } else { + err = r.session.WaitForFullFramebufferUpdate(ctx) + } + if err != nil { + return err + } + + return r.writeIfChanged(r.session.Snapshot()) +} + +func (r *vncRecorder) writeIfChanged(frame *image.RGBA) error { + hash := hashFrame(frame) + if r.hasLast && hash == r.lastHash { + return nil + } + + path := filepath.Join(r.dir, r.now().Format("20060102-150405.000000000")+".png") + file, err := os.Create(path) + if err != nil { + return fmt.Errorf("failed to create VNC snapshot %q: %w", path, err) + } + + if err := png.Encode(file, frame); err != nil { + _ = file.Close() + return fmt.Errorf("failed to encode VNC snapshot %q: %w", path, err) + } + + if err := file.Close(); err != nil { + return fmt.Errorf("failed to close VNC snapshot %q: %w", path, err) + } + + r.lastHash = hash + r.hasLast = true + + return nil +} + +func hashFrame(frame *image.RGBA) [sha256.Size]byte { + hasher := sha256.New() + bounds := frame.Bounds() + + var buf [8]byte + writeInt := func(v int) { + binary.LittleEndian.PutUint64(buf[:], uint64(v)) + _, _ = hasher.Write(buf[:]) + } + + writeInt(bounds.Min.X) + writeInt(bounds.Min.Y) + writeInt(bounds.Max.X) + writeInt(bounds.Max.Y) + writeInt(frame.Stride) + _, _ = hasher.Write(frame.Pix) + + var sum [sha256.Size]byte + copy(sum[:], hasher.Sum(nil)) + + return sum +} + +type vncRecorderHandle struct { + cancel context.CancelFunc + done chan struct{} + + mu sync.Mutex + err error +} + +func startVNCRecorder(ctx context.Context, recorder *vncRecorder) *vncRecorderHandle { + recorderCtx, cancel := context.WithCancel(ctx) + handle := &vncRecorderHandle{ + cancel: cancel, + done: make(chan struct{}), + } + + go func() { + handle.setErr(recorder.Run(recorderCtx)) + close(handle.done) + }() + + return handle +} + +func (h *vncRecorderHandle) Stop() error { + h.cancel() + <-h.done + + return h.Err() +} + +func (h *vncRecorderHandle) Err() error { + h.mu.Lock() + defer h.mu.Unlock() + + return h.err +} + +func (h *vncRecorderHandle) setErr(err error) { + h.mu.Lock() + defer h.mu.Unlock() + + h.err = err +} diff --git a/builder/tart/vnc_recorder_test.go b/builder/tart/vnc_recorder_test.go new file mode 100644 index 00000000..f8e3bc6c --- /dev/null +++ b/builder/tart/vnc_recorder_test.go @@ -0,0 +1,65 @@ +package tart + +import ( + "image" + "image/color" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestVNCRecorderWritesOnlyChangedFrames(t *testing.T) { + dir := t.TempDir() + frame := image.NewRGBA(image.Rect(0, 0, 1, 1)) + frame.SetRGBA(0, 0, color.RGBA{R: 1, A: 255}) + + times := []time.Time{ + time.Date(2026, 6, 12, 10, 0, 0, 1, time.UTC), + time.Date(2026, 6, 12, 10, 0, 0, 2, time.UTC), + } + timeIndex := 0 + recorder := &vncRecorder{ + dir: dir, + now: func() time.Time { + t := times[timeIndex] + timeIndex++ + return t + }, + } + + require.NoError(t, recorder.writeIfChanged(frame)) + require.NoError(t, recorder.writeIfChanged(frame)) + + frame.SetRGBA(0, 0, color.RGBA{G: 1, A: 255}) + require.NoError(t, recorder.writeIfChanged(frame)) + + entries, err := os.ReadDir(dir) + require.NoError(t, err) + require.Len(t, entries, 2) + require.Equal(t, "20260612-100000.000000001.png", entries[0].Name()) + require.Equal(t, "20260612-100000.000000002.png", entries[1].Name()) +} + +func TestVNCRecorderPrepareClearsExistingDirectory(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "old.png"), []byte("old"), 0644)) + require.NoError(t, os.Mkdir(filepath.Join(dir, "nested"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "nested", "old.png"), []byte("old"), 0644)) + + recorder := &vncRecorder{dir: dir} + require.NoError(t, recorder.Prepare()) + + entries, err := os.ReadDir(dir) + require.NoError(t, err) + require.Empty(t, entries) +} + +func TestHashFrameIncludesDimensions(t *testing.T) { + oneByTwo := image.NewRGBA(image.Rect(0, 0, 1, 2)) + twoByOne := image.NewRGBA(image.Rect(0, 0, 2, 1)) + + require.NotEqual(t, hashFrame(oneByTwo), hashFrame(twoByOne)) +} diff --git a/builder/tart/vnc_session.go b/builder/tart/vnc_session.go new file mode 100644 index 00000000..9c4437f3 --- /dev/null +++ b/builder/tart/vnc_session.go @@ -0,0 +1,134 @@ +package tart + +import ( + "context" + "fmt" + "image" + "image/png" + "os" + "sync" + "time" + + "github.com/mitchellh/go-vnc" +) + +var debugVNC = os.Getenv("PACKER_TART_DEBUG_VNC_FRAMEBUFFER_UPDATES") != "" + +type vncSession struct { + vncClient *vnc.ClientConn + serverMessageChannel chan vnc.ServerMessage + frameBuffer *vncFrameBuffer + updateMu sync.Mutex +} + +func newVNCSession(vncClient *vnc.ClientConn, serverMessageChannel chan vnc.ServerMessage) *vncSession { + return &vncSession{ + vncClient: vncClient, + serverMessageChannel: serverMessageChannel, + frameBuffer: newVNCFrameBuffer( + int(vncClient.FrameBufferWidth), + int(vncClient.FrameBufferHeight), + ), + } +} + +func (s *vncSession) Snapshot() *image.RGBA { + return s.frameBuffer.snapshot() +} + +func (s *vncSession) WaitForFramebufferUpdate(ctx context.Context) error { + return s.waitForFramebufferUpdate(ctx, true) +} + +func (s *vncSession) WaitForFullFramebufferUpdate(ctx context.Context) error { + return s.waitForFramebufferUpdate(ctx, false) +} + +func (s *vncSession) waitForFramebufferUpdate(ctx context.Context, initialIncremental bool) error { + s.updateMu.Lock() + defer s.updateMu.Unlock() + + incremental := initialIncremental + + for { + w, h := s.vncClient.FrameBufferWidth, s.vncClient.FrameBufferHeight + fmt.Fprintf(os.Stderr, "📡 Requesting %s frame buffer update for %dx%d\n", + map[bool]string{true: "incremental", false: "full"}[incremental], w, h) + + if err := s.vncClient.FramebufferUpdateRequest(incremental, 0, 0, w, h); err != nil { + return err + } + + select { + case msg, ok := <-s.serverMessageChannel: + if !ok { + return fmt.Errorf("VNC server message channel closed") + } + + framebufferUpdateMessage, ok := msg.(*vnc.FramebufferUpdateMessage) + if !ok { + // Ignore messages we didn't ask for. + fmt.Fprintln(os.Stderr, "⚠️ Ignoring unknown message type", msg.Type(), msg) + continue + } + + if len(framebufferUpdateMessage.Rectangles) == 0 { + return fmt.Errorf("⚠️ Frame update did not have any rectangles") + } + fmt.Fprintf(os.Stderr, "🖼️ New framebuffer update with %d rectangles\n", + len(framebufferUpdateMessage.Rectangles)) + + if err := s.frameBuffer.applyUpdate(framebufferUpdateMessage.Rectangles, + s.vncClient.FrameBufferWidth, s.vncClient.FrameBufferHeight); err != nil { + return err + } + + if debugVNC { + writeDebugVNCFrame(s.Snapshot()) + } + + return nil + case <-time.After(30 * time.Second): + fmt.Fprintf(os.Stderr, "⏱️ Framebuffer update timed out after 30s. ") + // The built-in VNC server in Virtualization.framework will sometimes + // fail to deliver a framebuffer update, even though the VM view shows + // new content. + if incremental { + // As a first step, we try a full update, which according to + // RFC 6143 7.5.3 should result in the server sending the entire + // contents of the specified area as soon as possible. + fmt.Fprintf(os.Stderr, "Switching to full update\n") + incremental = false + } else { + // However even full updates may in some cases fail to trigger + // an update from the VZ VNC server. As a second step, we move + // the mouse, which should result in an update (as long as the + // VM shows a local cursor). + fmt.Fprintf(os.Stderr, "Moving mouse to trigger update\n") + if err := s.vncClient.PointerEvent(0, w-1, 0); err != nil { + return err + } + time.Sleep(1 * time.Second) + if err := s.vncClient.PointerEvent(0, 0, 0); err != nil { + return err + } + } + continue + case <-ctx.Done(): + return ctx.Err() + } + } +} + +func writeDebugVNCFrame(frame *image.RGBA) { + file, err := os.Create("framebuffer.png") + if err != nil { + fmt.Fprintf(os.Stderr, "⚠️ Failed to create framebuffer file: %v\n", err) + return + } + defer file.Close() + + if err := png.Encode(file, frame); err != nil { + fmt.Fprintf(os.Stderr, "⚠️ Failed to encode framebuffer: %v\n", err) + } +} diff --git a/docs/builders/tart.mdx b/docs/builders/tart.mdx index 2fb23c6b..212c1351 100644 --- a/docs/builders/tart.mdx +++ b/docs/builders/tart.mdx @@ -71,6 +71,8 @@ For more advanced examples, please refer to the [`example/` directory](https://g - `rosetta` (string) - Whether to enable Rosetta support of a Linux guest VM. Useful for running non-arm64 binaries in the guest VM. A common used value is `rosetta`, for further details and explanation run `tart run --help`. - `run_extra_args` (list(string)) - Extra arguments to pass to `tart run` command. For example, you can enable bridged networking by specifying `--net-bridged=en0`. - `ip_extra_args` (list(string)) - Extra arguments to pass to `tart ip` command. For example, you can use a different resolver in case of bridged network by specifying `--resolver=arp`. +- `vnc_recording_dir` (string) - Directory where PNG snapshots of the VM screen should be written. When set, the builder connects to Tart's `--vnc-experimental` server during `tart run`, records the initial setup/provisioning session, and only writes a new snapshot when the framebuffer changes. Existing contents of this directory are removed when recording starts, and snapshots from the current run are preserved if the build fails. Cannot be used with `disable_vnc`. +- `vnc_recording_interval` (duration string | ex: "1s") - How often to check the VNC framebuffer for changes when `vnc_recording_dir` is set. Defaults to `"1s"`. - `vm_base_name` (string) - The name of the VM to be used for the initial cloning. Can be either a local VM or a remote VM that will be pulled from a registry. Mutually exclusive with `from_ipsw` and `from_iso`. ### SSH connection configuration From 82e661320be76da66706973b80054e8b2e971326 Mon Sep 17 00:00:00 2001 From: Fedor Korotkov Date: Sat, 13 Jun 2026 13:07:55 -0400 Subject: [PATCH 2/2] Update generated builder docs --- .web-docs/components/builder/tart/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.web-docs/components/builder/tart/README.md b/.web-docs/components/builder/tart/README.md index e0db6882..5514da85 100644 --- a/.web-docs/components/builder/tart/README.md +++ b/.web-docs/components/builder/tart/README.md @@ -62,6 +62,8 @@ For more advanced examples, please refer to the [`example/` directory](https://g - `rosetta` (string) - Whether to enable Rosetta support of a Linux guest VM. Useful for running non-arm64 binaries in the guest VM. A common used value is `rosetta`, for further details and explanation run `tart run --help`. - `run_extra_args` (list(string)) - Extra arguments to pass to `tart run` command. For example, you can enable bridged networking by specifying `--net-bridged=en0`. - `ip_extra_args` (list(string)) - Extra arguments to pass to `tart ip` command. For example, you can use a different resolver in case of bridged network by specifying `--resolver=arp`. +- `vnc_recording_dir` (string) - Directory where PNG snapshots of the VM screen should be written. When set, the builder connects to Tart's `--vnc-experimental` server during `tart run`, records the initial setup/provisioning session, and only writes a new snapshot when the framebuffer changes. Existing contents of this directory are removed when recording starts, and snapshots from the current run are preserved if the build fails. Cannot be used with `disable_vnc`. +- `vnc_recording_interval` (duration string | ex: "1s") - How often to check the VNC framebuffer for changes when `vnc_recording_dir` is set. Defaults to `"1s"`. - `vm_base_name` (string) - The name of the VM to be used for the initial cloning. Can be either a local VM or a remote VM that will be pulled from a registry. Mutually exclusive with `from_ipsw` and `from_iso`. ### SSH connection configuration