Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 27 additions & 13 deletions builder/tart/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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...)
}
Expand Down Expand Up @@ -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))
}

Expand Down
4 changes: 4 additions & 0 deletions builder/tart/builder.hcl2spec.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

53 changes: 53 additions & 0 deletions builder/tart/builder_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
162 changes: 126 additions & 36 deletions builder/tart/step_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Serialize VNC writes while recording boot commands

When both vnc_recording_dir and boot_command are set, this starts the recorder goroutine on the same vncSession before typeBootCommandOverVNC sends keys. The recorder periodically calls FramebufferUpdateRequest while bootcommand.NewVNCDriver writes key events to the same go-vnc ClientConn; go-vnc's KeyEvent emits one VNC message through several writes, so a recording request can interleave mid-key event and corrupt the VNC protocol stream. Please guard all ClientConn writes with a shared mutex or use a separate VNC connection for recording.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the bot is right here. If we're recording off of the VNC stream in real-time, we probably need to factor this out and share the resulting FB with the detection and recording.

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
}
}
Expand All @@ -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
}

Expand Down Expand Up @@ -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)
Expand All @@ -226,7 +230,7 @@ func typeBootCommandOverVNC(

select {
case <-vncCtx.Done():
return false
return nil, false
case <-time.After(time.Second):
// continue
}
Expand All @@ -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{
Expand All @@ -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!")

Expand All @@ -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)
Expand Down
Loading
Loading