Repository: lima-vm/lima (go)

Issue:
genschema.go shouldn't import multiple jsonschema libraries
https://github.com/lima-vm/lima/blob/00e3398febfc0aabed19c54f7e751dcaf5af5b43/cmd/limactl/genschema.go#L13-L15

Shouldn't need both "github.com/invopop/jsonschema" and "github.com/santhosh-tekuri/jsonschema/v6

Pull request:
Move jsonschema validation into a utility dir
This performs the same task as py "check-jsonschema" or "jv".

But included with Lima, instead of being a separate CLI tool.

Add simple example, for unit test.

See https://tour.json-schema.org/

Closes #3510

It is a little wasteful to compile the schema everytime, but on the other hand it doesn't take long to run.

~~We don't _actually_ validate that the jsonschema library works here, but we could do that if we wanted to...~~

Patch:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 2c43048f..c0c5d6e0 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -37,7 +37,7 @@ jobs:
     - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2
       with:
         fetch-depth: 1
-    - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5  # v5.5.0
+    - uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b  # v5.4.0
       with:
         go-version: 1.24.x
     - name: Make darwin artifacts
@@ -64,7 +64,7 @@ jobs:
       with:
         name: artifacts-darwin
         path: _artifacts/
-    - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5  # v5.5.0
+    - uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b  # v5.4.0
       with:
         go-version: 1.24.x
     - name: Install gcc-x86-64-linux-gnu
diff --git a/Makefile b/Makefile
index c0b576d0..5f679955 100644
--- a/Makefile
+++ b/Makefile
@@ -436,15 +436,14 @@ ifeq ($(native_compiling),true)
 	$< tmpl copy --embed-all templates/default.yaml $@
 endif
 
-schema-limayaml.json: _output/bin/limactl$(exe) templates/default.yaml default-template.yaml
+schema-limayaml.json: _output/bin/limactl$(exe) default-template.yaml
 ifeq ($(native_compiling),true)
-	# validate both the original template (with the "base" etc), and the embedded template
-	$< generate-jsonschema --schemafile $@ templates/default.yaml default-template.yaml
+	$< generate-jsonschema --schemafile $@ default-template.yaml
 endif
 
 .PHONY: check-jsonschema
-check-jsonschema: schema-limayaml.json templates/default.yaml default-template.yaml
-	check-jsonschema --schemafile schema-limayaml.json templates/default.yaml default-template.yaml
+check-jsonschema: schema-limayaml.json default-template.yaml
+	check-jsonschema --schemafile schema-limayaml.json default-template.yaml
 
 ################################################################################
 .PHONY: diagrams
diff --git a/cmd/limactl/disk.go b/cmd/limactl/disk.go
index 44e09bdb..f5d17136 100644
--- a/cmd/limactl/disk.go
+++ b/cmd/limactl/disk.go
@@ -16,7 +16,7 @@ import (
 	"github.com/docker/go-units"
 	"github.com/lima-vm/go-qcow2reader"
 	"github.com/lima-vm/lima/pkg/nativeimgutil"
-	"github.com/lima-vm/lima/pkg/qemu/imgutil"
+	"github.com/lima-vm/lima/pkg/qemu"
 	"github.com/lima-vm/lima/pkg/store"
 	"github.com/lima-vm/lima/pkg/store/filenames"
 	"github.com/sirupsen/logrus"
@@ -114,7 +114,7 @@ func diskCreateAction(cmd *cobra.Command, args []string) error {
 	if format == "raw" {
 		err = nativeimgutil.CreateRawDataDisk(diskDir, int(diskSize))
 	} else {
-		err = imgutil.CreateDataDisk(diskDir, format, int(diskSize))
+		err = qemu.CreateDataDisk(diskDir, format, int(diskSize))
 	}
 	if err != nil {
 		rerr := os.RemoveAll(diskDir)
@@ -410,7 +410,7 @@ func diskResizeAction(cmd *cobra.Command, args []string) error {
 	if disk.Format == "raw" {
 		err = nativeimgutil.ResizeRawDataDisk(disk.Dir, int(diskSize))
 	} else {
-		err = imgutil.ResizeDataDisk(disk.Dir, disk.Format, int(diskSize))
+		err = qemu.ResizeDataDisk(disk.Dir, disk.Format, int(diskSize))
 	}
 	if err != nil {
 		return fmt.Errorf("failed to resize disk %q: %w", diskName, err)
diff --git a/cmd/limactl/genschema.go b/cmd/limactl/genschema.go
index 296db8ee..54dbf2ef 100644
--- a/cmd/limactl/genschema.go
+++ b/cmd/limactl/genschema.go
@@ -9,10 +9,9 @@ import (
 	"fmt"
 	"os"
 
-	"github.com/goccy/go-yaml"
 	"github.com/invopop/jsonschema"
+	"github.com/lima-vm/lima/pkg/jsonschemautil"
 	"github.com/lima-vm/lima/pkg/limayaml"
-	jsonschema2 "github.com/santhosh-tekuri/jsonschema/v6"
 	"github.com/spf13/cobra"
 	orderedmap "github.com/wk8/go-ordered-map/v2"
 
@@ -60,18 +59,6 @@ func genschemaAction(cmd *cobra.Command, args []string) error {
 		{Type: "string"},
 		{Type: "object"},
 	}
-	// allow BaseTemplates to be either string (url) or array (array)
-	schema.Definitions["BaseTemplates"].Type = "" // was: "array"
-	schema.Definitions["BaseTemplates"].OneOf = []*jsonschema.Schema{
-		{Type: "string"},
-		{Type: "array"},
-	}
-	// allow LocatorWithDigest to be either string (url) or object (struct)
-	schema.Definitions["LocatorWithDigest"].Type = "" // was: "object"
-	schema.Definitions["LocatorWithDigest"].OneOf = []*jsonschema.Schema{
-		{Type: "string"},
-		{Type: "object"},
-	}
 	properties := schema.Definitions["LimaYAML"].Properties
 	getProp(properties, "os").Enum = toAny(limayaml.OSTypes)
 	getProp(properties, "arch").Enum = toAny(limayaml.ArchTypes)
@@ -93,22 +80,8 @@ func genschemaAction(cmd *cobra.Command, args []string) error {
 	if err != nil {
 		return err
 	}
-	compiler := jsonschema2.NewCompiler()
-	schema2, err := compiler.Compile(file)
-	if err != nil {
-		return err
-	}
 	for _, f := range args {
-		b, err := os.ReadFile(f)
-		if err != nil {
-			return err
-		}
-		var y any
-		err = yaml.Unmarshal(b, &y)
-		if err != nil {
-			return err
-		}
-		err = schema2.Validate(y)
+		err = jsonschemautil.Validate(file, f)
 		if err != nil {
 			return fmt.Errorf("%q: %w", f, err)
 		}
diff --git a/cmd/limactl/main.go b/cmd/limactl/main.go
index d56047f5..a428a9fd 100644
--- a/cmd/limactl/main.go
+++ b/cmd/limactl/main.go
@@ -186,9 +186,10 @@ func newApp() *cobra.Command {
 		newTunnelCommand(),
 		newTemplateCommand(),
 		newRestartCommand(),
-		newSudoersCommand(),
-		newStartAtLoginCommand(),
 	)
+	for _, cmd := range additionalAdvancedCommands() {
+		rootCmd.AddCommand(cmd)
+	}
 
 	return rootCmd
 }
diff --git a/cmd/limactl/main_darwin.go b/cmd/limactl/main_darwin.go
new file mode 100644
index 00000000..f3849d20
--- /dev/null
+++ b/cmd/limactl/main_darwin.go
@@ -0,0 +1,13 @@
+// SPDX-FileCopyrightText: Copyright The Lima Authors
+// SPDX-License-Identifier: Apache-2.0
+
+package main
+
+import "github.com/spf13/cobra"
+
+func additionalAdvancedCommands() []*cobra.Command {
+	return []*cobra.Command{
+		newSudoersCommand(),
+		startAtLoginCommand(),
+	}
+}
diff --git a/cmd/limactl/main_linux.go b/cmd/limactl/main_linux.go
new file mode 100644
index 00000000..f09c3ea7
--- /dev/null
+++ b/cmd/limactl/main_linux.go
@@ -0,0 +1,12 @@
+// SPDX-FileCopyrightText: Copyright The Lima Authors
+// SPDX-License-Identifier: Apache-2.0
+
+package main
+
+import "github.com/spf13/cobra"
+
+func additionalAdvancedCommands() []*cobra.Command {
+	return []*cobra.Command{
+		startAtLoginCommand(),
+	}
+}
diff --git a/cmd/limactl/main_windows.go b/cmd/limactl/main_windows.go
new file mode 100644
index 00000000..bf3bba33
--- /dev/null
+++ b/cmd/limactl/main_windows.go
@@ -0,0 +1,10 @@
+// SPDX-FileCopyrightText: Copyright The Lima Authors
+// SPDX-License-Identifier: Apache-2.0
+
+package main
+
+import "github.com/spf13/cobra"
+
+func additionalAdvancedCommands() []*cobra.Command {
+	return nil
+}
diff --git a/cmd/limactl/start-at-login.go b/cmd/limactl/start-at-login.go
deleted file mode 100644
index b7ca2aaf..00000000
--- a/cmd/limactl/start-at-login.go
+++ /dev/null
@@ -1,30 +0,0 @@
-// SPDX-FileCopyrightText: Copyright The Lima Authors
-// SPDX-License-Identifier: Apache-2.0
-
-package main
-
-import (
-	"github.com/spf13/cobra"
-)
-
-func newStartAtLoginCommand() *cobra.Command {
-	startAtLoginCommand := &cobra.Command{
-		Use:               "start-at-login INSTANCE",
-		Short:             "Register/Unregister an autostart file for the instance",
-		Args:              WrapArgsError(cobra.MaximumNArgs(1)),
-		RunE:              startAtLoginAction,
-		ValidArgsFunction: startAtLoginComplete,
-		GroupID:           advancedCommand,
-	}
-
-	startAtLoginCommand.Flags().Bool(
-		"enabled", true,
-		"Automatically start the instance when the user logs in",
-	)
-
-	return startAtLoginCommand
-}
-
-func startAtLoginComplete(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
-	return bashCompleteInstanceNames(cmd)
-}
diff --git a/cmd/limactl/start-at-login_unix.go b/cmd/limactl/start-at-login_unix.go
index 6b4b189d..b6e0e0ae 100644
--- a/cmd/limactl/start-at-login_unix.go
+++ b/cmd/limactl/start-at-login_unix.go
@@ -16,6 +16,24 @@ import (
 	"github.com/spf13/cobra"
 )
 
+func startAtLoginCommand() *cobra.Command {
+	startAtLoginCommand := &cobra.Command{
+		Use:               "start-at-login INSTANCE",
+		Short:             "Register/Unregister an autostart file for the instance",
+		Args:              WrapArgsError(cobra.MaximumNArgs(1)),
+		RunE:              startAtLoginAction,
+		ValidArgsFunction: startAtLoginComplete,
+		GroupID:           advancedCommand,
+	}
+
+	startAtLoginCommand.Flags().Bool(
+		"enabled", true,
+		"Automatically start the instance when the user logs in",
+	)
+
+	return startAtLoginCommand
+}
+
 func startAtLoginAction(cmd *cobra.Command, args []string) error {
 	instName := DefaultInstanceName
 	if len(args) > 0 {
@@ -53,3 +71,7 @@ func startAtLoginAction(cmd *cobra.Command, args []string) error {
 
 	return nil
 }
+
+func startAtLoginComplete(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
+	return bashCompleteInstanceNames(cmd)
+}
diff --git a/cmd/limactl/start-at-login_windows.go b/cmd/limactl/start-at-login_windows.go
deleted file mode 100644
index 5112dff8..00000000
--- a/cmd/limactl/start-at-login_windows.go
+++ /dev/null
@@ -1,14 +0,0 @@
-// SPDX-FileCopyrightText: Copyright The Lima Authors
-// SPDX-License-Identifier: Apache-2.0
-
-package main
-
-import (
-	"errors"
-
-	"github.com/spf13/cobra"
-)
-
-func startAtLoginAction(_ *cobra.Command, _ []string) error {
-	return errors.New("start-at-login command is only supported on macOS and Linux right now")
-}
diff --git a/cmd/limactl/sudoers.go b/cmd/limactl/sudoers.go
deleted file mode 100644
index 7478312e..00000000
--- a/cmd/limactl/sudoers.go
+++ /dev/null
@@ -1,41 +0,0 @@
-// SPDX-FileCopyrightText: Copyright The Lima Authors
-// SPDX-License-Identifier: Apache-2.0
-
-package main
-
-import (
-	"fmt"
-
-	"github.com/lima-vm/lima/pkg/networks"
-	"github.com/spf13/cobra"
-)
-
-const socketVMNetURL = "https://lima-vm.io/docs/config/network/vmnet/#socket_vmnet"
-
-// newSudoersCommand is specific to macOS, but the help message is
-// compiled on Linux too, as depended by `make docsy`.
-// https://github.com/lima-vm/lima/issues/3436
-func newSudoersCommand() *cobra.Command {
-	sudoersCommand := &cobra.Command{
-		Use: "sudoers [--check [SUDOERSFILE-TO-CHECK]]",
-		Example: `
-To generate the /etc/sudoers.d/lima file:
-$ limactl sudoers | sudo tee /etc/sudoers.d/lima
-
-To validate the existing /etc/sudoers.d/lima file:
-$ limactl sudoers --check /etc/sudoers.d/lima
-`,
-		Short: "Generate the content of the /etc/sudoers.d/lima file",
-		Long: fmt.Sprintf(`Generate the content of the /etc/sudoers.d/lima file for enabling vmnet.framework support (socket_vmnet) on macOS.
-The content is written to stdout, NOT to the file.
-This command must not run as the root user.
-See %s for the usage.`, socketVMNetURL),
-		Args:    WrapArgsError(cobra.MaximumNArgs(1)),
-		RunE:    sudoersAction,
-		GroupID: advancedCommand,
-	}
-	cfgFile, _ := networks.ConfigFile()
-	sudoersCommand.Flags().Bool("check", false,
-		fmt.Sprintf("check that the sudoers file is up-to-date with %q", cfgFile))
-	return sudoersCommand
-}
diff --git a/cmd/limactl/sudoers_darwin.go b/cmd/limactl/sudoers_darwin.go
index 96fb265b..dfe3ba70 100644
--- a/cmd/limactl/sudoers_darwin.go
+++ b/cmd/limactl/sudoers_darwin.go
@@ -13,6 +13,33 @@ import (
 	"github.com/spf13/cobra"
 )
 
+const networksURL = "https://lima-vm.io/docs/config/network/#socket_vmnet"
+
+func newSudoersCommand() *cobra.Command {
+	sudoersCommand := &cobra.Command{
+		Use: "sudoers [--check [SUDOERSFILE-TO-CHECK]]",
+		Example: `
+To generate the /etc/sudoers.d/lima file:
+$ limactl sudoers | sudo tee /etc/sudoers.d/lima
+
+To validate the existing /etc/sudoers.d/lima file:
+$ limactl sudoers --check /etc/sudoers.d/lima
+`,
+		Short: "Generate the content of the /etc/sudoers.d/lima file",
+		Long: fmt.Sprintf(`Generate the content of the /etc/sudoers.d/lima file for enabling vmnet.framework support.
+The content is written to stdout, NOT to the file.
+This command must not run as the root user.
+See %s for the usage.`, networksURL),
+		Args:    WrapArgsError(cobra.MaximumNArgs(1)),
+		RunE:    sudoersAction,
+		GroupID: advancedCommand,
+	}
+	cfgFile, _ := networks.ConfigFile()
+	sudoersCommand.Flags().Bool("check", false,
+		fmt.Sprintf("check that the sudoers file is up-to-date with %q", cfgFile))
+	return sudoersCommand
+}
+
 func sudoersAction(cmd *cobra.Command, args []string) error {
 	nwCfg, err := networks.LoadConfig()
 	if err != nil {
@@ -20,7 +47,7 @@ func sudoersAction(cmd *cobra.Command, args []string) error {
 	}
 	// Make sure the current network configuration is secure
 	if err := nwCfg.Validate(); err != nil {
-		logrus.Infof("Please check %s for more information.", socketVMNetURL)
+		logrus.Infof("Please check %s for more information.", networksURL)
 		return err
 	}
 	check, err := cmd.Flags().GetBool("check")
diff --git a/cmd/limactl/sudoers_nodarwin.go b/cmd/limactl/sudoers_nodarwin.go
deleted file mode 100644
index 07f60a50..00000000
--- a/cmd/limactl/sudoers_nodarwin.go
+++ /dev/null
@@ -1,16 +0,0 @@
-//go:build !darwin
-
-// SPDX-FileCopyrightText: Copyright The Lima Authors
-// SPDX-License-Identifier: Apache-2.0
-
-package main
-
-import (
-	"errors"
-
-	"github.com/spf13/cobra"
-)
-
-func sudoersAction(_ *cobra.Command, _ []string) error {
-	return errors.New("sudoers command is only supported on macOS right now")
-}
diff --git a/pkg/jsonschemautil/jsonschemautil.go b/pkg/jsonschemautil/jsonschemautil.go
new file mode 100644
index 00000000..7f7b16a0
--- /dev/null
+++ b/pkg/jsonschemautil/jsonschemautil.go
@@ -0,0 +1,29 @@
+// SPDX-FileCopyrightText: Copyright The Lima Authors
+// SPDX-License-Identifier: Apache-2.0
+
+package jsonschemautil
+
+import (
+	"os"
+
+	"github.com/goccy/go-yaml"
+	"github.com/santhosh-tekuri/jsonschema/v6"
+)
+
+func Validate(schemafile, instancefile string) error {
+	compiler := jsonschema.NewCompiler()
+	schema, err := compiler.Compile(schemafile)
+	if err != nil {
+		return err
+	}
+	instance, err := os.ReadFile(instancefile)
+	if err != nil {
+		return err
+	}
+	var y any
+	err = yaml.Unmarshal(instance, &y)
+	if err != nil {
+		return err
+	}
+	return schema.Validate(y)
+}
diff --git a/pkg/limayaml/limayaml.go b/pkg/limayaml/limayaml.go
index d72e5b60..4082b82d 100644
--- a/pkg/limayaml/limayaml.go
+++ b/pkg/limayaml/limayaml.go
@@ -10,13 +10,13 @@ import (
 )
 
 type LimaYAML struct {
-	Base                  BaseTemplates `yaml:"base,omitempty" json:"base,omitempty"`
+	Base                  BaseTemplates `yaml:"base,omitempty" json:"base,omitempty" jsonschema:"nullable"`
 	MinimumLimaVersion    *string       `yaml:"minimumLimaVersion,omitempty" json:"minimumLimaVersion,omitempty" jsonschema:"nullable"`
 	VMType                *VMType       `yaml:"vmType,omitempty" json:"vmType,omitempty" jsonschema:"nullable"`
 	VMOpts                VMOpts        `yaml:"vmOpts,omitempty" json:"vmOpts,omitempty"`
 	OS                    *OS           `yaml:"os,omitempty" json:"os,omitempty" jsonschema:"nullable"`
 	Arch                  *Arch         `yaml:"arch,omitempty" json:"arch,omitempty" jsonschema:"nullable"`
-	Images                []Image       `yaml:"images,omitempty" json:"images,omitempty" jsonschema:"nullable"`
+	Images                []Image       `yaml:"images" json:"images"` // REQUIRED
 	CPUType               CPUType       `yaml:"cpuType,omitempty" json:"cpuType,omitempty" jsonschema:"nullable"`
 	CPUs                  *int          `yaml:"cpus,omitempty" json:"cpus,omitempty" jsonschema:"nullable"`
 	Memory                *string       `yaml:"memory,omitempty" json:"memory,omitempty" jsonschema:"nullable"` // go-units.RAMInBytes
@@ -58,7 +58,7 @@ type BaseTemplates []LocatorWithDigest
 
 type LocatorWithDigest struct {
 	URL    string  `yaml:"url" json:"url"`
-	Digest *string `yaml:"digest,omitempty" json:"digest,omitempty"` // TODO currently unused
+	Digest *string `yaml:"digest,omitempty" json:"digest,omitempty" jsonschema:"nullable"` // TODO currently unused
 }
 
 type (
@@ -229,7 +229,7 @@ const (
 type Provision struct {
 	Mode                            ProvisionMode      `yaml:"mode,omitempty" json:"mode,omitempty" jsonschema:"default=system"`
 	SkipDefaultDependencyResolution *bool              `yaml:"skipDefaultDependencyResolution,omitempty" json:"skipDefaultDependencyResolution,omitempty"`
-	Script                          string             `yaml:"script,omitempty" json:"script,omitempty"`
+	Script                          string             `yaml:"script" json:"script"`
 	File                            *LocatorWithDigest `yaml:"file,omitempty" json:"file,omitempty" jsonschema:"nullable"`
 	Playbook                        string             `yaml:"playbook,omitempty" json:"playbook,omitempty"` // DEPRECATED
 	// All ProvisionData fields must be nil unless Mode is ProvisionModeData
diff --git a/pkg/limayaml/marshal.go b/pkg/limayaml/marshal.go
index 70caee2f..0380fa9b 100644
--- a/pkg/limayaml/marshal.go
+++ b/pkg/limayaml/marshal.go
@@ -45,6 +45,11 @@ func unmarshalBaseTemplates(dst *BaseTemplates, b []byte) error {
 		*dst = BaseTemplates{LocatorWithDigest{URL: s}}
 		return nil
 	}
+	var locator LocatorWithDigest
+	if err := yaml.Unmarshal(b, &locator); err == nil {
+		*dst = BaseTemplates{locator}
+		return nil
+	}
 	return yaml.UnmarshalWithOptions(b, dst, yaml.CustomUnmarshaler[LocatorWithDigest](unmarshalLocatorWithDigest))
 }
 
diff --git a/pkg/qemu/imgutil/imgutil.go b/pkg/qemu/imgutil/imgutil.go
index 688c5787..2fffbfd1 100644
--- a/pkg/qemu/imgutil/imgutil.go
+++ b/pkg/qemu/imgutil/imgutil.go
@@ -6,15 +6,9 @@ package imgutil
 import (
 	"bytes"
 	"encoding/json"
-	"errors"
 	"fmt"
-	"io/fs"
-	"os"
 	"os/exec"
-	"path/filepath"
-	"strconv"
 
-	"github.com/lima-vm/lima/pkg/store/filenames"
 	"github.com/sirupsen/logrus"
 )
 
@@ -28,32 +22,6 @@ type InfoFormatSpecific struct {
 	Data json.RawMessage `json:"data,omitempty"` // since QEMU 1.7
 }
 
-func CreateDataDisk(dir, format string, size int) error {
-	dataDisk := filepath.Join(dir, filenames.DataDisk)
-	if _, err := os.Stat(dataDisk); err == nil || !errors.Is(err, fs.ErrNotExist) {
-		// datadisk already exists
-		return err
-	}
-
-	args := []string{"create", "-f", format, dataDisk, strconv.Itoa(size)}
-	cmd := exec.Command("qemu-img", args...)
-	if out, err := cmd.CombinedOutput(); err != nil {
-		return fmt.Errorf("failed to run %v: %q: %w", cmd.Args, string(out), err)
-	}
-	return nil
-}
-
-func ResizeDataDisk(dir, format string, size int) error {
-	dataDisk := filepath.Join(dir, filenames.DataDisk)
-
-	args := []string{"resize", "-f", format, dataDisk, strconv.Itoa(size)}
-	cmd := exec.Command("qemu-img", args...)
-	if out, err := cmd.CombinedOutput(); err != nil {
-		return fmt.Errorf("failed to run %v: %q: %w", cmd.Args, string(out), err)
-	}
-	return nil
-}
-
 func (sp *InfoFormatSpecific) Qcow2() *InfoFormatSpecificDataQcow2 {
 	if sp.Type != "qcow2" {
 		return nil
diff --git a/pkg/qemu/qemu.go b/pkg/qemu/qemu.go
index 12e148bf..4211d2e6 100644
--- a/pkg/qemu/qemu.go
+++ b/pkg/qemu/qemu.go
@@ -47,42 +47,10 @@ type Config struct {
 	VirtioGA     bool
 }
 
-// minimumQemuVersion returns hardMin and softMin.
-//
-// hardMin is the hard minimum version of QEMU.
-// The driver immediately returns the error when QEMU is older than this version.
-//
-// softMin is the oldest recommended version of QEMU.
-// softMin must be >= hardMin.
-//
-// When updating this function, make sure to update
-// `website/content/en/docs/config/vmtype.md` too.
-func minimumQemuVersion() (hardMin, softMin semver.Version) {
-	var h, s string
-	switch runtime.GOOS {
-	case "darwin":
-		switch runtime.GOARCH {
-		case "arm64":
-			// https://gitlab.com/qemu-project/qemu/-/issues/1990
-			h, s = "8.2.1", "8.2.1"
-		default:
-			// The code specific to QEMU < 7.0 on macOS (https://github.com/lima-vm/lima/pull/703)
-			// was removed in https://github.com/lima-vm/lima/pull/3491
-			h, s = "7.0.0", "8.2.1"
-		}
-	default:
-		// hardMin: Untested and maybe does not even work.
-		// softMin: Ubuntu 22.04's QEMU. The oldest version that can be easily tested on GitHub Actions.
-		h, s = "4.0.0", "6.2.0"
-	}
-	hardMin, softMin = *semver.New(h), *semver.New(s)
-	if softMin.LessThan(hardMin) {
-		// NOTREACHED
-		logrus.Fatalf("internal error: QEMU: soft minimum version %v must be >= hard minimum version %v",
-			softMin, hardMin)
-	}
-	return hardMin, softMin
-}
+// MinimumQemuVersion is the minimum supported QEMU version.
+const (
+	MinimumQemuVersion = "4.0.0"
+)
 
 // EnsureDisk also ensures the kernel and the initrd.
 func EnsureDisk(ctx context.Context, cfg Config) error {
@@ -159,6 +127,32 @@ func EnsureDisk(ctx context.Context, cfg Config) error {
 	return nil
 }
 
+func CreateDataDisk(dir, format string, size int) error {
+	dataDisk := filepath.Join(dir, filenames.DataDisk)
+	if _, err := os.Stat(dataDisk); err == nil || !errors.Is(err, fs.ErrNotExist) {
+		// datadisk already exists
+		return err
+	}
+
+	args := []string{"create", "-f", format, dataDisk, strconv.Itoa(size)}
+	cmd := exec.Command("qemu-img", args...)
+	if out, err := cmd.CombinedOutput(); err != nil {
+		return fmt.Errorf("failed to run %v: %q: %w", cmd.Args, string(out), err)
+	}
+	return nil
+}
+
+func ResizeDataDisk(dir, format string, size int) error {
+	dataDisk := filepath.Join(dir, filenames.DataDisk)
+
+	args := []string{"resize", "-f", format, dataDisk, strconv.Itoa(size)}
+	cmd := exec.Command("qemu-img", args...)
+	if out, err := cmd.CombinedOutput(); err != nil {
+		return fmt.Errorf("failed to run %v: %q: %w", cmd.Args, string(out), err)
+	}
+	return nil
+}
+
 func newQmpClient(cfg Config) (*qmp.SocketMonitor, error) {
 	qmpSock := filepath.Join(cfg.InstanceDir, filenames.QMPSock)
 	qmpClient, err := qmp.NewSocketMonitor("unix", qmpSock, 5*time.Second)
@@ -319,6 +313,9 @@ type features struct {
 	// e.g. "Available CPUs:\n...\nx86 base...\nx86 host...\n...\n"
 	// Not machine-readable, but checking strings.Contains() should be fine.
 	CPUHelp []byte
+
+	// VersionGEQ7 is true when the QEMU version seems v7.0.0 or later
+	VersionGEQ7 bool
 }
 
 func inspectFeatures(exe, machine string) (*features, error) {
@@ -362,6 +359,7 @@ func inspectFeatures(exe, machine string) (*features, error) {
 			f.MachineHelp = stderr.Bytes()
 		}
 	}
+	f.VersionGEQ7 = strings.Contains(string(f.MachineHelp), "-7.0")
 
 	// Avoid error: "No machine specified, and there is no default"
 	cmd = exec.Command(exe, "-cpu", "help", "-machine", machine)
@@ -379,15 +377,56 @@ func inspectFeatures(exe, machine string) (*features, error) {
 	return &f, nil
 }
 
+// showDarwinARM64HVFQEMU620Warning shows a warning on M1 macOS when QEMU is older than 6.2.0_1.
+//
+// See:
+// - https://gitlab.com/qemu-project/qemu/-/issues/899
+// - https://github.com/Homebrew/homebrew-core/pull/96743
+// - https://github.com/lima-vm/lima/issues/712
+func showDarwinARM64HVFQEMU620Warning(exe, accel string, features *features) {
+	if runtime.GOOS != "darwin" {
+		return
+	}
+	if runtime.GOARCH != "arm64" {
+		return
+	}
+	if accel != "hvf" {
+		return
+	}
+	if features.VersionGEQ7 {
+		return
+	}
+	if exeFull, err := exec.LookPath(exe); err == nil {
+		if exeResolved, err2 := filepath.EvalSymlinks(exeFull); err2 == nil {
+			if strings.Contains(exeResolved, "Cellar/qemu/6.2.0_") {
+				// Homebrew's QEMU 6.2.0_1 or later
+				return
+			}
+		}
+	}
+	w := "This version of QEMU might not be able to boot recent Linux guests on M1 macOS hosts."
+	if _, err := exec.LookPath("brew"); err == nil {
+		w += "Run `brew upgrade` and make sure your QEMU version is 6.2.0_1 or later."
+	} else {
+		w += `Reinstall QEMU with the following commits (included in QEMU 7.0.0):
+- https://github.com/qemu/qemu/commit/ad99f64f "hvf: arm: Use macros for sysreg shift/masking"
+- https://github.com/qemu/qemu/commit/7f6c295c "hvf: arm: Handle unknown ID registers as RES0"
+`
+		w += "See https://github.com/Homebrew/homebrew-core/pull/96743 for the further information."
+	}
+	logrus.Warn(w)
+}
+
 // adjustMemBytesDarwinARM64HVF adjusts the memory to be <= 3 GiB, only when the following conditions are met:
 //
 // - Host OS   <  macOS 12.4
 // - Host Arch == arm64
 // - Accel     == hvf
+// - QEMU      >= 7.0
 //
 // This adjustment is required for avoiding host kernel panic. The issue was fixed in macOS 12.4 Beta 1.
 // See https://github.com/lima-vm/lima/issues/795 https://gitlab.com/qemu-project/qemu/-/issues/903#note_911000975
-func adjustMemBytesDarwinARM64HVF(memBytes int64, accel string) int64 {
+func adjustMemBytesDarwinARM64HVF(memBytes int64, accel string, features *features) int64 {
 	const safeSize = 3 * 1024 * 1024 * 1024 // 3 GiB
 	if memBytes <= safeSize {
 		return memBytes
@@ -401,6 +440,9 @@ func adjustMemBytesDarwinARM64HVF(memBytes int64, accel string) int64 {
 	if accel != "hvf" {
 		return memBytes
 	}
+	if !features.VersionGEQ7 {
+		return memBytes
+	}
 	macOSProductVersion, err := osutil.ProductVersion()
 	if err != nil {
 		logrus.Warn(err)
@@ -409,8 +451,8 @@ func adjustMemBytesDarwinARM64HVF(memBytes int64, accel string) int64 {
 	if !macOSProductVersion.LessThan(*semver.New("12.4.0")) {
 		return memBytes
 	}
-	logrus.Warnf("Reducing the guest memory from %s to %s, to avoid host kernel panic on macOS <= 12.3; "+
-		"Please update macOS to 12.4 or later; "+
+	logrus.Warnf("Reducing the guest memory from %s to %s, to avoid host kernel panic on macOS <= 12.3 with QEMU >= 7.0; "+
+		"Please update macOS to 12.4 or later, or downgrade QEMU to 6.2; "+
 		"See https://github.com/lima-vm/lima/issues/795 for the further background.",
 		units.BytesSize(float64(memBytes)), units.BytesSize(float64(safeSize)))
 	memBytes = safeSize
@@ -455,16 +497,16 @@ func Cmdline(ctx context.Context, cfg Config) (exe string, args []string, err er
 		logrus.WithError(err).Warning("Failed to detect QEMU version")
 	} else {
 		logrus.Debugf("QEMU version %s detected", version.String())
-		hardMin, softMin := minimumQemuVersion()
-		if version.LessThan(hardMin) {
-			logrus.Fatalf("QEMU %v is too old, %v or later required", version, hardMin)
-		}
-		if version.LessThan(softMin) {
-			logrus.Warnf("QEMU %v is too old, %v or later is recommended", version, softMin)
+		if version.LessThan(*semver.New(MinimumQemuVersion)) {
+			logrus.Fatalf("QEMU %v is too old, %v or later required", version, MinimumQemuVersion)
 		}
 		if y.VMOpts.QEMU.MinimumVersion != nil && version.LessThan(*semver.New(*y.VMOpts.QEMU.MinimumVersion)) {
 			logrus.Fatalf("QEMU %v is too old, template requires %q or later", version, *y.VMOpts.QEMU.MinimumVersion)
 		}
+		if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" && version.Equal(*semver.New("8.2.0")) {
+			logrus.Fatal("QEMU 8.2.0 is no longer supported on ARM Mac due to <https://gitlab.com/qemu-project/qemu/-/issues/1990>. " +
+				"Please upgrade QEMU to v8.2.1 (or downgrade to v8.1.x).")
+		}
 	}
 
 	// Architecture
@@ -472,13 +514,14 @@ func Cmdline(ctx context.Context, cfg Config) (exe string, args []string, err er
 	if !strings.Contains(string(features.AccelHelp), accel) {
 		return "", nil, fmt.Errorf("accelerator %q is not supported by %s", accel, exe)
 	}
+	showDarwinARM64HVFQEMU620Warning(exe, accel, features)
 
 	// Memory
 	memBytes, err := units.RAMInBytes(*y.Memory)
 	if err != nil {
 		return "", nil, err
 	}
-	memBytes = adjustMemBytesDarwinARM64HVF(memBytes, accel)
+	memBytes = adjustMemBytesDarwinARM64HVF(memBytes, accel, features)
 	args = appendArgsIfNoConflict(args, "-m", strconv.Itoa(int(memBytes>>20)))
 
 	if *y.MountType == limayaml.VIRTIOFS {
@@ -526,6 +569,14 @@ func Cmdline(ctx context.Context, cfg Config) (exe string, args []string, err er
 		}
 	case limayaml.AARCH64:
 		machine := "virt,accel=" + accel
+		// QEMU >= 7.0 requires highmem=off NOT to be set, otherwise fails with "Addressing limited to 32 bits, but memory exceeds it by 1073741824 bytes"
+		// QEMU <  7.0 requires highmem=off to be set, otherwise fails with "VCPU supports less PA bits (36) than requested by the memory map (40)"
+		// https://github.com/lima-vm/lima/issues/680
+		// https://github.com/lima-vm/lima/pull/24
+		// But when the memory size is <= 3 GiB, we can always set highmem=off.
+		if !features.VersionGEQ7 || memBytes <= 3*1024*1024*1024 {
+			machine += ",highmem=off"
+		}
 		args = appendArgsIfNoConflict(args, "-machine", machine)
 	case limayaml.RISCV64:
 		// https://github.com/tianocore/edk2/blob/edk2-stable202408/OvmfPkg/RiscVVirt/README.md#test
@@ -825,15 +876,23 @@ func Cmdline(ctx context.Context, cfg Config) (exe string, args []string, err er
 	}
 
 	switch *y.Arch {
-	// FIXME: use virtio-gpu on all the architectures
 	case limayaml.X8664, limayaml.RISCV64:
 		args = append(args, "-device", "virtio-vga")
-	default:
-		args = append(args, "-device", "virtio-gpu")
+		args = append(args, "-device", "virtio-keyboard-pci")
+		args = append(args, "-device", "virtio-"+input+"-pci")
+		args = append(args, "-device", "qemu-xhci,id=usb-bus")
+	case limayaml.AARCH64, limayaml.ARMV7L, limayaml.PPC64LE, limayaml.S390X:
+		if features.VersionGEQ7 {
+			args = append(args, "-device", "virtio-gpu")
+			args = append(args, "-device", "virtio-keyboard-pci")
+			args = append(args, "-device", "virtio-"+input+"-pci")
+		} else { // kernel panic with virtio and old versions of QEMU
+			args = append(args, "-vga", "none", "-device", "ramfb")
+			args = append(args, "-device", "usb-kbd,bus=usb-bus")
+			args = append(args, "-device", "usb-"+input+",bus=usb-bus")
+		}
+		args = append(args, "-device", "qemu-xhci,id=usb-bus")
 	}
-	args = append(args, "-device", "virtio-keyboard-pci")
-	args = append(args, "-device", "virtio-"+input+"-pci")
-	args = append(args, "-device", "qemu-xhci,id=usb-bus")
 
 	// Parallel
 	args = append(args, "-parallel", "none")
diff --git a/website/content/en/docs/config/vmtype.md b/website/content/en/docs/config/vmtype.md
index ff67d591..3c407a5d 100644
--- a/website/content/en/docs/config/vmtype.md
+++ b/website/content/en/docs/config/vmtype.md
@@ -29,10 +29,6 @@ unless the config is incompatible with VZ. (e.g., legacyBIOS or 9p is enabled)
 ## QEMU
 "qemu" option makes use of QEMU to run guest operating system. 
 
-Recommended QEMU version:
-- v8.2.1 or later (macOS)
-- v6.2.0 or later (Linux)
-
 ## VZ
 
 | ⚡ Requirement | Lima >= 0.14, macOS >= 13.0 |
diff --git a/website/content/en/docs/installation/_index.md b/website/content/en/docs/installation/_index.md
index 647bcc36..8e76134f 100644
--- a/website/content/en/docs/installation/_index.md
+++ b/website/content/en/docs/installation/_index.md
@@ -10,7 +10,7 @@ Supported host OS:
 - Windows (untested)
 
 Prerequisite:
-- QEMU (Required, only if [QEMU]({{< ref "/docs/config/vmtype#qemu" >}}) driver is used)
+- QEMU 7.1 or later (Required, only if QEMU driver is used)
 
 {{< tabpane text=true >}}
 


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index cccff4ac..66791bb2 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -29,7 +29,7 @@ jobs:
       with:
         # To avoid "failed to load YAML file \"templates/experimental/riscv64.yaml\": can't parse builtin Lima version \"3f3a6f6\": 3f3a6f6 is not in dotted-tri format"
         fetch-depth: 0
-    - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5  # v5.5.0
+    - uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b  # v5.4.0
       with:
         go-version: 1.24.x
     - name: Install protoc
@@ -111,7 +111,7 @@ jobs:
     - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2
       with:
         fetch-depth: 1
-    - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5  # v5.5.0
+    - uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b  # v5.4.0
       with:
         go-version: ${{ matrix.go-version }}
     - name: Unit tests
@@ -157,7 +157,7 @@ jobs:
     - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2
       with:
         fetch-depth: 1
-    - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5  # v5.5.0
+    - uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b  # v5.4.0
       with:
         go-version: 1.24.x
     - name: Unit tests
@@ -184,7 +184,7 @@ jobs:
       with:
         # To avoid "failed to load YAML file \"templates/experimental/riscv64.yaml\": can't parse builtin Lima version \"3f3a6f6\": 3f3a6f6 is not in dotted-tri format"
         fetch-depth: 0
-    - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5  # v5.5.0
+    - uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b  # v5.4.0
       with:
         go-version: 1.24.x
     - name: Unit tests
@@ -259,7 +259,7 @@ jobs:
       with:
         # To avoid "failed to load YAML file \"templates/experimental/riscv64.yaml\": can't parse builtin Lima version \"3f3a6f6\": 3f3a6f6 is not in dotted-tri format"
         fetch-depth: 0
-    - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5  # v5.5.0
+    - uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b  # v5.4.0
       with:
         go-version: 1.24.x
     - name: Make
@@ -310,7 +310,7 @@ jobs:
         # fetch-depth is set to 0 to let `limactl --version` print semver-ish version
         fetch-depth: 0
         ref: ${{ github.event.pull_request.head.sha }}
-    - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5  # v5.5.0
+    - uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b  # v5.4.0
       with:
         go-version: 1.24.x
     - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684  # v4.2.3
@@ -355,7 +355,7 @@ jobs:
     - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2
       with:
         fetch-depth: 0
-    - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5  # v5.5.0
+    - uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b  # v5.4.0
       with:
         go-version: 1.24.x
     - name: Make
@@ -419,7 +419,7 @@ jobs:
         path: homebrew-core
         fetch-depth: 0
         filter: tree:0
-    - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5  # v5.5.0
+    - uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b  # v5.4.0
       with:
         go-version: 1.24.x
     - name: Cache image used by ${{ matrix.oldver }}/examples/ubuntu-lts.yaml
@@ -455,7 +455,7 @@ jobs:
     - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2
       with:
         fetch-depth: 0
-    - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5  # v5.5.0
+    - uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b  # v5.4.0
       with:
         go-version: 1.24.x
     - name: Make
@@ -490,7 +490,7 @@ jobs:
     - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2
       with:
         fetch-depth: 0
-    - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5  # v5.5.0
+    - uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b  # v5.4.0
       with:
         go-version: 1.24.x
     - name: Install gomodjail
@@ -508,49 +508,3 @@ jobs:
         template: templates/default.yaml
     - name: Smoke test
       run: gomodjail run --go-mod=./go.mod -- limactl start --tty=false
-
-  cross:
-    name: "Cross-compile (NetBSD, DragonFlyBSD)"
-    runs-on: ubuntu-24.04
-    timeout-minutes: 30
-    steps:
-    - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2
-      with:
-        # To avoid "failed to load YAML file \"templates/experimental/riscv64.yaml\": can't parse builtin Lima version \"3f3a6f6\": 3f3a6f6 is not in dotted-tri format"
-        fetch-depth: 0
-    - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5  # v5.5.0
-      with:
-        go-version: 1.24.x
-    - run: GOOS=netbsd go build ./...
-    - run: GOOS=dragonfly go build ./...
-
-  qemu-linux-old:
-    name: "Smoke tests (QEMU, old Linux host)"
-    runs-on: ubuntu-22.04  # QEMU 6.2
-    timeout-minutes: 30
-    steps:
-    - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2
-      with:
-        # To avoid "failed to load YAML file \"templates/experimental/riscv64.yaml\": can't parse builtin Lima version \"3f3a6f6\": 3f3a6f6 is not in dotted-tri format"
-        fetch-depth: 0
-    - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5  # v5.5.0
-      with:
-        go-version: 1.24.x
-    - name: Make
-      run: make
-    - name: Install
-      run: sudo make install
-    - name: Cache image used by templates/default.yaml
-      uses: ./.github/actions/setup_cache_for_template
-      with:
-        template: templates/default.yaml
-    - name: Install test dependencies
-      run: |
-        sudo apt-get update
-        sudo apt-get install -y --no-install-recommends ovmf qemu-system-x86 qemu-utils
-        qemu-system-x86_64 --version
-        sudo modprobe kvm
-        # `sudo usermod -aG kvm $(whoami)` does not take an effect on GHA
-        sudo chown $(whoami) /dev/kvm
-    - name: Smoke test
-      run: limactl start --tty=false
diff --git a/pkg/jsonschemautil/jsonschemautil_test.go b/pkg/jsonschemautil/jsonschemautil_test.go
new file mode 100644
index 00000000..6301af7b
--- /dev/null
+++ b/pkg/jsonschemautil/jsonschemautil_test.go
@@ -0,0 +1,24 @@
+// SPDX-FileCopyrightText: Copyright The Lima Authors
+// SPDX-License-Identifier: Apache-2.0
+
+package jsonschemautil
+
+import (
+	"testing"
+
+	"gotest.tools/v3/assert"
+)
+
+func TestValidateValidInstance(t *testing.T) {
+	schema := "testdata/schema.json"
+	instance := "testdata/valid.yaml"
+	err := Validate(schema, instance)
+	assert.NilError(t, err)
+}
+
+func TestValidateInvalidInstance(t *testing.T) {
+	schema := "testdata/schema.json"
+	instance := "testdata/invalid.yaml"
+	err := Validate(schema, instance)
+	assert.ErrorContains(t, err, "jsonschema validation failed")
+}
diff --git a/pkg/jsonschemautil/testdata/invalid.yaml b/pkg/jsonschemautil/testdata/invalid.yaml
new file mode 100644
index 00000000..8538dd10
--- /dev/null
+++ b/pkg/jsonschemautil/testdata/invalid.yaml
@@ -0,0 +1,2 @@
+name: John Doe
+age: "30"
diff --git a/pkg/jsonschemautil/testdata/schema.json b/pkg/jsonschemautil/testdata/schema.json
new file mode 100644
index 00000000..33eb2d18
--- /dev/null
+++ b/pkg/jsonschemautil/testdata/schema.json
@@ -0,0 +1,11 @@
+{
+  "type": "object",
+  "properties": {
+    "name": {
+      "type": "string"
+    },
+    "age": {
+      "type": "integer"
+    }
+  }
+}
diff --git a/pkg/jsonschemautil/testdata/valid.yaml b/pkg/jsonschemautil/testdata/valid.yaml
new file mode 100644
index 00000000..be10e25b
--- /dev/null
+++ b/pkg/jsonschemautil/testdata/valid.yaml
@@ -0,0 +1,2 @@
+name: John Doe
+age: 30
diff --git a/pkg/limatmpl/abs_test.go b/pkg/limatmpl/abs_test.go
index fbf7c19b..b4f192f6 100644
--- a/pkg/limatmpl/abs_test.go
+++ b/pkg/limatmpl/abs_test.go
@@ -35,9 +35,9 @@ var useAbsLocatorsTestCases = []useAbsLocatorsTestCase{
 	{
 		"Flow style array of one base template",
 		"template://foo",
-		`base: [{url: bar.yaml, digest: deadbeef}]`,
+		`base: {url: bar.yaml, digest: deadbeef}`,
 		// not sure why the quotes around the URL were added; maybe because we don't copy the style from the source
-		`base: [{url: 'template://bar.yaml', digest: deadbeef}]`,
+		`base: {url: 'template://bar.yaml', digest: deadbeef}`,
 	},
 	{
 		"Flow style array of sequence of two base URLs",
diff --git a/pkg/limatmpl/embed_test.go b/pkg/limatmpl/embed_test.go
index e1db9ba2..a8e19291 100644
--- a/pkg/limatmpl/embed_test.go
+++ b/pkg/limatmpl/embed_test.go
@@ -374,7 +374,13 @@ provision:
 		`provision: [{script: "#! my script"}]`,
 	},
 	{
-		"ERROR base digest is not yet implemented",
+		"ERROR base digest is not yet implemented (1)",
+		"",
+		"base: {url: base.yaml, digest: deafbad}",
+		"not yet implemented",
+	},
+	{
+		"ERROR base digest is not yet implemented (2)",
 		"",
 		"base: [{url: base.yaml, digest: deafbad}]",
 		"not yet implemented",
diff --git a/pkg/limayaml/limayaml_test.go b/pkg/limayaml/limayaml_test.go
index 39c929ea..a0320232 100644
--- a/pkg/limayaml/limayaml_test.go
+++ b/pkg/limayaml/limayaml_test.go
@@ -17,7 +17,7 @@ func dumpJSON(t *testing.T, d any) string {
 	return string(b)
 }
 
-const emptyYAML = "{}\n"
+const emptyYAML = "images: []\n"
 
 func TestEmptyYAML(t *testing.T) {
 	var y LimaYAML
@@ -27,7 +27,7 @@ func TestEmptyYAML(t *testing.T) {
 	assert.Equal(t, string(b), emptyYAML)
 }
 
-const defaultYAML = "{}\n"
+const defaultYAML = "images: []\n"
 
 func TestDefaultYAML(t *testing.T) {
 	bytes, err := os.ReadFile("default.yaml")
diff --git a/pkg/limayaml/marshal_test.go b/pkg/limayaml/marshal_test.go
index 0f77b11f..29f2f72c 100644
--- a/pkg/limayaml/marshal_test.go
+++ b/pkg/limayaml/marshal_test.go
@@ -28,6 +28,7 @@ func TestMarshalTilde(t *testing.T) {
 	// yaml will load ~ (or null) as null
 	// make sure that it is always quoted
 	assert.Equal(t, string(b), `---
+images: []
 mounts:
 - location: "~"
   writable: false