Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
7 changes: 7 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ type VM struct {
SSHPort int `toml:"sshport"`
Recipes []string `toml:"recipes"`

// Display is the user's screen preference: "" or "auto" (default),
// "window", or "vnc". core.validateDisplay is the single place that
// checks the value; empty means an old vm.toml predates this field, so
// it must read the same as "auto". qemu.DisplayKind is the rule that
// turns this into DisplayWindow or DisplayVNC.
Display string `toml:"display"`

// Forwards are user-declared TCP ports forwarded from host to guest, in
// addition to the SSHPort forward that always exists. internal/qemu.Args
// renders them into qemu's -netdev hostfwd= clauses. Changes apply at
Expand Down
25 changes: 25 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ func TestSaveLoadRoundtrip(t *testing.T) {
SSHPort: 2201,
Recipes: []string{"xfce"},
Forwards: []PortForward{{HostPort: 8080, GuestPort: 80}},
Display: "window",
Dir: filepath.Join(Root(), "alpine-live"),
}
if err := want.Save(); err != nil {
Expand All @@ -43,6 +44,30 @@ func TestSaveLoadRoundtrip(t *testing.T) {
}
}

// TestSaveLoadRoundtripEmptyDisplayMeansAuto covers a vm.toml with no display
// preference, the shape every pre-existing VM has. Load must leave it "",
// not default it to a literal "auto": core.validateDisplay and
// qemu.DisplayKind both already treat "" the same as "auto".
func TestSaveLoadRoundtripEmptyDisplayMeansAuto(t *testing.T) {
t.Setenv("STOAT_HOME", t.TempDir())
if err := EnsureRoot(); err != nil {
t.Fatal(err)
}

want := &VM{Name: "plain", Mode: "live", SSHPort: 2203, Dir: filepath.Join(Root(), "plain")}
if err := want.Save(); err != nil {
t.Fatal(err)
}

got, err := Load("plain")
if err != nil {
t.Fatal(err)
}
if got.Display != "" {
t.Errorf("Display = %q, want empty", got.Display)
}
}

func TestSaveLoadRoundtripCloudVM(t *testing.T) {
t.Setenv("STOAT_HOME", t.TempDir())
if err := EnsureRoot(); err != nil {
Expand Down
10 changes: 10 additions & 0 deletions internal/core/core.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ type Spec struct {
Share string
Recipes []string

// Display is the screen preference to record in vm.toml: "" or "auto"
// (default), "window", or "vnc". validateDisplay is the single check
// for the value; plan applies it.
Display string

// ConsolePassword: empty means config.DefaultConsolePassword, "random"
// generates one, anything else is used verbatim. Written for the
// cloudinit backend only; see config.VM.ConsolePassword for why no
Expand Down Expand Up @@ -206,6 +211,10 @@ func plan(s Spec) (*config.VM, error) {
return nil, err
}

if err := validateDisplay(s.Display); err != nil {
return nil, err
}

port, err := config.FreePort()
if err != nil {
return nil, err
Expand All @@ -232,6 +241,7 @@ func plan(s Spec) (*config.VM, error) {
SSHPort: port,
Recipes: s.Recipes,
AllowExec: allowExec,
Display: s.Display,
}

if img.backend == "cloudinit" {
Expand Down
24 changes: 24 additions & 0 deletions internal/core/core_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,30 @@ func TestPlanRefusesRelativeDiskSize(t *testing.T) {
}
}

func TestPlanRejectsInvalidDisplay(t *testing.T) {
dir := root(t)
haveImage(t, dir, "alpine-virt-3.24.1-x86_64.iso")

_, err := plan(Spec{Name: "work", Image: "alpine-virt-3.24.1-x86_64.iso", Display: "fullscreen"})
if !errors.Is(err, ErrInvalidSpec) {
t.Fatalf("err = %v, want ErrInvalidSpec", err)
}
}

func TestPlanAcceptsEveryValidDisplay(t *testing.T) {
dir := root(t)
for _, pref := range []string{"", "auto", "window", "vnc"} {
haveImage(t, dir, "alpine-virt-3.24.1-x86_64.iso")
v, err := plan(Spec{Name: "work-" + pref, Image: "alpine-virt-3.24.1-x86_64.iso", Display: pref})
if err != nil {
t.Fatalf("Display=%q: %v", pref, err)
}
if v.Display != pref {
t.Errorf("Display = %q, want %q", v.Display, pref)
}
}
}

func TestPlanRefusesDuplicateName(t *testing.T) {
dir := root(t)
haveImage(t, dir, "alpine-virt-3.24.1-x86_64.iso")
Expand Down
20 changes: 18 additions & 2 deletions internal/core/display.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package core

import "github.com/novusedge/stoat/internal/qemu"
import (
"fmt"

"github.com/novusedge/stoat/internal/qemu"
)

// Re-exported so a caller can branch on Display.Kind without importing
// internal/qemu, which is otherwise entirely below this package.
Expand Down Expand Up @@ -56,7 +60,19 @@ func DisplayKind(v VM, graphical bool) string {
if v.State == StateBroken {
return ""
}
return qemu.DisplayKind(v.Mode, v.Installed, graphical)
return qemu.DisplayKind(v.Display, v.Mode, v.Installed, graphical)
}

// validateDisplay checks a config.VM.Display candidate. "" and "auto" both
// mean the installer-console default; anything but those two and "window"/
// "vnc" is rejected so a typo in vm.toml or an MCP call fails loudly instead
// of silently falling back to auto.
func validateDisplay(pref string) error {
switch pref {
case "", "auto", qemu.DisplayWindow, qemu.DisplayVNC:
return nil
}
return fmt.Errorf("%w: display must be one of \"\", auto, window, vnc, not %q", ErrInvalidSpec, pref)
}

// DisplayFor is DisplayKind plus the lookup of a VNC viewer installed on this
Expand Down
14 changes: 14 additions & 0 deletions internal/core/display_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,17 @@ func TestDisplayKindIsPure(t *testing.T) {
t.Errorf("DisplayKind = %q, want %q on a host with no session", got, DisplayVNC)
}
}

// A per-VM preference overrides the mode/installed default, but not the
// host's veto.
func TestDisplayKindHonoursPreference(t *testing.T) {
if got := DisplayKind(VM{Mode: "cloud", Display: "window"}, true); got != DisplayWindow {
t.Errorf("Display=window: got %q, want %q", got, DisplayWindow)
}
if got := DisplayKind(VM{Mode: "disk", Installed: false, Display: "vnc"}, true); got != DisplayVNC {
t.Errorf("Display=vnc: got %q, want %q", got, DisplayVNC)
}
if got := DisplayKind(VM{Mode: "cloud", Display: "window"}, false); got != DisplayVNC {
t.Errorf("Display=window on a headless host: got %q, want %q", got, DisplayVNC)
}
}
11 changes: 11 additions & 0 deletions internal/core/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ type Patch struct {
// looks written; this field is the escape hatch when that guess is wrong,
// so it is mutable, unlike Name/OS/Backend/Mode.
Installed *bool

// Display is the screen preference: "" or "auto", "window", or "vnc".
// Safe: it only changes which -display argument qemu.Args builds next
// start, nothing about the running process.
Display *string
}

// checkImmutable reports ErrImmutableField, naming the field, when a Patch sets
Expand Down Expand Up @@ -131,6 +136,12 @@ func Update(name string, p Patch) (VM, error) {
if p.Installed != nil {
v.Installed = *p.Installed
}
if p.Display != nil {
if err := validateDisplay(*p.Display); err != nil {
return VM{}, err
}
v.Display = *p.Display
}

if p.SSHPort != nil && *p.SSHPort != v.SSHPort {
if err := validateSSHPort(v, *p.SSHPort); err != nil {
Expand Down
33 changes: 33 additions & 0 deletions internal/core/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,39 @@ func TestUpdateRejectsInvalidRAMAndCPUs(t *testing.T) {
}
}

func TestUpdateDisplay(t *testing.T) {
root(t)
v := &config.VM{Name: "work", Mode: "disk", RAM: 1024, CPUs: 1, SSHPort: 2200}
if err := v.Save(); err != nil {
t.Fatal(err)
}
got, err := Update("work", Patch{Display: ptr("window")})
if err != nil {
t.Fatal(err)
}
if got.Display != "window" {
t.Errorf("Display = %q, want window", got.Display)
}
reloaded, err := config.Load("work")
if err != nil {
t.Fatal(err)
}
if reloaded.Display != "window" {
t.Errorf("not persisted: Display = %q", reloaded.Display)
}
}

func TestUpdateRejectsInvalidDisplay(t *testing.T) {
root(t)
v := &config.VM{Name: "work", Mode: "disk", RAM: 1024, CPUs: 1, SSHPort: 2200}
if err := v.Save(); err != nil {
t.Fatal(err)
}
if _, err := Update("work", Patch{Display: ptr("fullscreen")}); !errors.Is(err, ErrInvalidSpec) {
t.Errorf("err = %v, want ErrInvalidSpec", err)
}
}

func TestUpdateRecipesReplacesWholesale(t *testing.T) {
root(t)
v := &config.VM{Name: "work", Mode: "live", RAM: 1024, CPUs: 1, SSHPort: 2200, Recipes: []string{"old.yaml"}}
Expand Down
6 changes: 6 additions & 0 deletions internal/core/vm.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ type VM struct {
SSHPort int
SSHUser string

// Display is the user's screen preference: "" or "auto" (default),
// "window", or "vnc". DisplayKind and DisplayFor turn this, plus the
// host's graphical session, into where the screen actually lands.
Display string

// ISO and Base are plain vm.toml facts. They are here because a caller
// asking what a VM IS needs them, and without them the TUI would have to
// keep a second config.Load beside every core.Get.
Expand Down Expand Up @@ -210,6 +215,7 @@ func fromConfig(v *config.VM) VM {
Applied: applied(v.Applied),
SSHPort: v.SSHPort,
SSHUser: v.SSHUser,
Display: v.Display,
ISO: v.ISO,
Base: v.Base,
ConsolePassword: v.ConsolePassword,
Expand Down
44 changes: 26 additions & 18 deletions internal/qemu/args.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,35 +19,43 @@ const (
DisplayVNC = "vnc"
)

// DisplayKind is the rule, stated over the three facts it actually depends on
// rather than over a *config.VM: core.VM is a different type that carries the
// first two, and it must ask this question rather than restate it.
// DisplayKind is the rule, stated over the four facts it actually depends on
// rather than over a *config.VM: core.VM is a different type that carries
// pref/mode/installed, and it must ask this question rather than restate it.
//
// Only an uninstalled disk-mode VM wants a real window, because its OS
// installer draws to VGA rather than the serial console and a human has to
// drive it. live, cloud and installed disk VMs reach ssh with no console
// interaction, so their screen goes to the VNC socket instead.
// pref is config.VM.Display: "window" or "vnc" pins the surface outright,
// "" or "auto" runs the installer-console default below. Only an uninstalled
// disk-mode VM wants a window under that default, because its OS installer
// draws to VGA rather than the serial console and a human has to drive it.
// live, cloud and installed disk VMs reach ssh with no console interaction,
// so their screen goes to the VNC socket unless pref overrides it.
//
// graphical is the host's veto, and it is a veto rather than a preference:
// graphical is the host's veto, checked before pref and unconditional: even
// a "window" preference falls back to VNC on a host with no display server.
// -display gtk needs a display server on the HOST, and qemu does not degrade
// when there is none, it exits 1 (measured: "gtk initialization failed", and
// with gl=on it fails one option earlier still, on "OpenGL is not supported by
// display backend 'gtk'"). Before this argument existed, a fresh disk VM on a
// display backend 'gtk'"). Before this veto existed, a fresh disk VM on a
// headless host could not be started at all, and the install could not be
// completed by any route. It falls back to VNC instead, which serves the exact
// same VGA framebuffer over a socket the user can reach from a machine that
// does have a screen. Whoever answers this must answer it for the host, not
// for the VM: see GraphicalSession.
//
// This is deliberately blind to whether the guest has a desktop on it. An
// installed disk VM running XFCE would like a window and does not get one; see
// docs/troubleshooting.md. Widening that needs an explicit per-VM preference,
// not a looser predicate.
func DisplayKind(mode string, installed, graphical bool) string {
if mode == "disk" && !installed && graphical {
func DisplayKind(pref, mode string, installed, graphical bool) string {
if !graphical {
return DisplayVNC
}
switch pref {
case DisplayWindow:
return DisplayWindow
case DisplayVNC:
return DisplayVNC
default:
if mode == "disk" && !installed {
return DisplayWindow
}
return DisplayVNC
}
return DisplayVNC
}

// NeedsWindow reports whether this VM gets a real qemu window on a host whose
Expand All @@ -56,7 +64,7 @@ func DisplayKind(mode string, installed, graphical bool) string {
// Exported so the TUI can describe the right escape hatch (a GTK window vs.
// the VNC socket) without duplicating this rule.
func NeedsWindow(v *config.VM, graphical bool) bool {
return DisplayKind(v.Mode, v.Installed, graphical) == DisplayWindow
return DisplayKind(v.Display, v.Mode, v.Installed, graphical) == DisplayWindow
}

// WantsWindow reports whether this VM would use a window if the host could
Expand Down
43 changes: 30 additions & 13 deletions internal/qemu/display_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,26 +25,43 @@ func fakeBins(t *testing.T, names ...string) {

func TestDisplayKind(t *testing.T) {
for _, c := range []struct {
pref string
mode string
installed bool
graphical bool
want string
}{
// The install console: the one case a human MUST be able to look at,
// because setup-alpine cannot be driven any other way.
{"disk", false, true, DisplayWindow},
// pref "" and "auto" both run the installer-console default.
{"", "disk", false, true, DisplayWindow},
{"auto", "disk", false, true, DisplayWindow},
// The same VM on a host with no display server. qemu would exit 1 on
// the window rather than fall back, so stoat falls back for it.
{"disk", false, false, DisplayVNC},
{"disk", true, true, DisplayVNC},
{"live", false, true, DisplayVNC},
{"live", true, true, DisplayVNC},
{"cloud", false, true, DisplayVNC},
{"cloud", true, true, DisplayVNC},
{"cloud", false, false, DisplayVNC},
{"", "disk", false, false, DisplayVNC},
{"auto", "disk", false, false, DisplayVNC},
{"", "disk", true, true, DisplayVNC},
{"", "live", false, true, DisplayVNC},
{"", "live", true, true, DisplayVNC},
{"", "cloud", false, true, DisplayVNC},
{"", "cloud", true, true, DisplayVNC},
{"", "cloud", false, false, DisplayVNC},

// "window" pins the surface, on every mode/installed combination,
// as long as the host has a session.
{"window", "live", false, true, DisplayWindow},
{"window", "disk", true, true, DisplayWindow},
{"window", "cloud", false, true, DisplayWindow},
// "vnc" pins the other way, including the installer-console case
// the auto default would have given a window.
{"vnc", "disk", false, true, DisplayVNC},
{"vnc", "live", true, true, DisplayVNC},

// graphical is a veto: it wins over "window" too, so a headless
// host never gets handed a qemu argv that makes qemu exit 1.
{"window", "disk", false, false, DisplayVNC},
{"window", "live", true, false, DisplayVNC},
} {
if got := DisplayKind(c.mode, c.installed, c.graphical); got != c.want {
t.Errorf("DisplayKind(%q, %v, graphical=%v) = %q, want %q", c.mode, c.installed, c.graphical, got, c.want)
if got := DisplayKind(c.pref, c.mode, c.installed, c.graphical); got != c.want {
t.Errorf("DisplayKind(%q, %q, %v, graphical=%v) = %q, want %q", c.pref, c.mode, c.installed, c.graphical, got, c.want)
}
}
}
Expand All @@ -58,7 +75,7 @@ func TestDisplayKindReadsNoEnvironment(t *testing.T) {
t.Setenv("WAYLAND_DISPLAY", "")
t.Setenv("XDG_RUNTIME_DIR", "")
t.Setenv(GraphicalEnv, "0")
if got := DisplayKind("disk", false, true); got != DisplayWindow {
if got := DisplayKind("", "disk", false, true); got != DisplayWindow {
t.Errorf("DisplayKind = %q, want %q: the argument decides, not the environment", got, DisplayWindow)
}
}
Expand Down
Loading
Loading