Repository: lima-vm/lima

Issue:
Incorrect warning: `vmType vz: ignoring images[0]: [Kernel Initrd] `
The vz driver prints `vmType vz: ignoring images[0]: [Kernel Initrd] ` when a custom kernel is specified, even though it is not actually ignored.

e.g.,
```yaml
images:
- location: "https://cloud-images.ubuntu.com/plucky/20250310/plucky-server-cloudimg-amd64.img"
  arch: "x86_64"
  kernel:
    location: https://cloud-images.ubuntu.com/plucky/20250310/unpacked/plucky-server-cloudimg-amd64-vmlinuz-generic
    digest: sha256:547c4316eadc8e46b043b5658fd4c08d62b3522c07a7eb94692e1b7d8827bf52
    cmdline: root=LABEL=cloudimg-rootfs ro console=tty1 console=ttyAMA0 nopku
  initrd:
    location: https://cloud-images.ubuntu.com/plucky/20250310/unpacked/plucky-server-cloudimg-amd64-initrd-generic
    digest: sha256:2842bb61052f77c1f1301394c5db215e3d89696b122accd42b7a7b30ae0c64d4
```

Hints from issue comments:
@kairveeehh: hii , I would like to take up this issue . 
should i follow up with a PR or wait for assignment?@kairveeehh: according to me a possible fix would be to change  `reflectutil.UnknownNonEmptyFields`  to explicitly recognize `Kernel` and `Initrd` , could you confirm I am thinking in the right direction?@AkihiroSuda: Yes

Solution patch:
diff --git a/cmd/limactl/shell.go b/cmd/limactl/shell.go
index ad634190..36454010 100644
--- a/cmd/limactl/shell.go
+++ b/cmd/limactl/shell.go
@@ -20,6 +20,7 @@ import (
 	networks "github.com/lima-vm/lima/pkg/networks/reconcile"
 	"github.com/lima-vm/lima/pkg/sshutil"
 	"github.com/lima-vm/lima/pkg/store"
+	"github.com/lima-vm/sshocker/pkg/ssh"
 	"github.com/mattn/go-isatty"
 	"github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
@@ -51,6 +52,7 @@ func newShellCommand() *cobra.Command {
 
 	shellCmd.Flags().String("shell", "", "shell interpreter, e.g. /bin/bash")
 	shellCmd.Flags().String("workdir", "", "working directory")
+	shellCmd.Flags().Bool("reconnect", false, "reconnect to the SSH session")
 	return shellCmd
 }
 
@@ -107,6 +109,24 @@ func shellAction(cmd *cobra.Command, args []string) error {
 		}
 	}
 
+	restart, err := cmd.Flags().GetBool("reconnect")
+	if err != nil {
+		return err
+	}
+	if restart {
+		logrus.Infof("Exiting ssh session for the instance %q", instName)
+
+		sshConfig := &ssh.SSHConfig{
+			ConfigFile:     inst.SSHConfigFile,
+			Persist:        false,
+			AdditionalArgs: []string{},
+		}
+
+		if err := ssh.ExitMaster(inst.Hostname, inst.SSHLocalPort, sshConfig); err != nil {
+			return err
+		}
+	}
+
 	// When workDir is explicitly set, the shell MUST have workDir as the cwd, or exit with an error.
 	//
 	// changeDirCmd := "cd workDir || exit 1"                  if workDir != ""
diff --git a/hack/test-templates.sh b/hack/test-templates.sh
index c5ddc4a0..cf9d7f9f 100755
--- a/hack/test-templates.sh
+++ b/hack/test-templates.sh
@@ -55,6 +55,7 @@ declare -A CHECKS=(
 	["user-v2"]=""
 	["mount-path-with-spaces"]=""
 	["provision-ansible"]=""
+	["provision-data"]=""
 	["param-env-variables"]=""
 	["set-user"]=""
 )
@@ -82,6 +83,7 @@ case "$NAME" in
 	CHECKS["snapshot-offline"]="1"
 	CHECKS["mount-path-with-spaces"]="1"
 	CHECKS["provision-ansible"]="1"
+	CHECKS["provision-data"]="1"
 	CHECKS["param-env-variables"]="1"
 	CHECKS["set-user"]="1"
 	;;
@@ -194,13 +196,19 @@ if [[ -n ${CHECKS["provision-ansible"]} ]]; then
 	limactl shell "$NAME" test -e /tmp/ansible
 fi
 
+if [[ -n ${CHECKS["provision-data"]} ]]; then
+	INFO 'Testing that /etc/sysctl.d/99-inotify.conf was created successfully on provision'
+	limactl shell "$NAME" grep -q fs.inotify.max_user_watches /etc/sysctl.d/99-inotify.conf
+fi
+
 if [[ -n ${CHECKS["param-env-variables"]} ]]; then
 	INFO 'Testing that PARAM env variables are exported to all types of provisioning scripts and probes'
 	limactl shell "$NAME" test -e /tmp/param-boot
 	limactl shell "$NAME" test -e /tmp/param-dependency
 	limactl shell "$NAME" test -e /tmp/param-probe
 	limactl shell "$NAME" test -e /tmp/param-system
-	limactl shell "$NAME" test -e /tmp/param-user
+	# TODO re-enable once https://github.com/lima-vm/lima/issues/3308 is fixed
+	# limactl shell "$NAME" test -e /tmp/param-user
 fi
 
 if [[ -n ${CHECKS["set-user"]} ]]; then
diff --git a/hack/test-templates/test-misc.yaml b/hack/test-templates/test-misc.yaml
index fe1af508..1c660fff 100644
--- a/hack/test-templates/test-misc.yaml
+++ b/hack/test-templates/test-misc.yaml
@@ -18,7 +18,8 @@ param:
   DEPENDENCY: dependency
   PROBE: probe
   SYSTEM: system
-  USER: user
+# TODO re-enable once https://github.com/lima-vm/lima/issues/3308 is fixed
+# USER: user
 
 provision:
 - mode: ansible
@@ -29,8 +30,14 @@ provision:
   script: "touch /tmp/param-$PARAM_DEPENDENCY"
 - mode: system
   script: "touch /tmp/param-$PARAM_SYSTEM"
-- mode: user
-  script: "touch /tmp/param-$PARAM_USER"
+# TODO re-enable once https://github.com/lima-vm/lima/issues/3308 is fixed
+# - mode: user
+#   script: "touch /tmp/param-$PARAM_USER"
+- mode: data
+  path: /etc/sysctl.d/99-inotify.conf
+  content: |
+    fs.inotify.max_user_watches = 524288
+    fs.inotify.max_user_instances = 512
 
 probes:
 - mode: readiness
diff --git a/pkg/cidata/cidata.TEMPLATE.d/boot.sh b/pkg/cidata/cidata.TEMPLATE.d/boot.sh
index cfdc963c..35c3faa6 100644
--- a/pkg/cidata/cidata.TEMPLATE.d/boot.sh
+++ b/pkg/cidata/cidata.TEMPLATE.d/boot.sh
@@ -52,6 +52,31 @@ else
 	done
 fi
 
+# indirect variable lookup, like ${!var} in bash
+deref() {
+	eval echo \$"$1"
+}
+
+if [ -d "${LIMA_CIDATA_MNT}"/provision.data ]; then
+	for f in "${LIMA_CIDATA_MNT}"/provision.data/*; do
+		filename=$(basename "$f")
+		overwrite=$(deref "LIMA_CIDATA_DATAFILE_${filename}_OVERWRITE")
+		owner=$(deref "LIMA_CIDATA_DATAFILE_${filename}_OWNER")
+		path=$(deref "LIMA_CIDATA_DATAFILE_${filename}_PATH")
+		permissions=$(deref "LIMA_CIDATA_DATAFILE_${filename}_PERMISSIONS")
+		if [ -e "$path" ] && [ "$overwrite" = "false" ]; then
+			INFO "Not overwriting $path"
+		else
+			INFO "Copying $f to $path"
+			# intermediate directories will be owned by root, regardless of OWNER setting
+			mkdir -p "$(dirname "$path")"
+			cp "$f" "$path"
+			chown "$owner" "$path"
+			chmod "$permissions" "$path"
+		fi
+	done
+fi
+
 if [ -d "${LIMA_CIDATA_MNT}"/provision.system ]; then
 	for f in "${LIMA_CIDATA_MNT}"/provision.system/*; do
 		INFO "Executing $f"
diff --git a/pkg/cidata/cidata.TEMPLATE.d/lima.env b/pkg/cidata/cidata.TEMPLATE.d/lima.env
index b1720f0d..89d2d34f 100644
--- a/pkg/cidata/cidata.TEMPLATE.d/lima.env
+++ b/pkg/cidata/cidata.TEMPLATE.d/lima.env
@@ -19,6 +19,12 @@ LIMA_CIDATA_DISK_{{$i}}_FORMAT={{$disk.Format}}
 LIMA_CIDATA_DISK_{{$i}}_FSTYPE={{$disk.FSType}}
 LIMA_CIDATA_DISK_{{$i}}_FSARGS={{range $j, $arg := $disk.FSArgs}}{{if $j}} {{end}}{{$arg}}{{end}}
 {{- end}}
+{{- range $dataFile := .DataFiles}}
+LIMA_CIDATA_DATAFILE_{{$dataFile.FileName}}_OVERWRITE={{$dataFile.Overwrite}}
+LIMA_CIDATA_DATAFILE_{{$dataFile.FileName}}_OWNER={{$dataFile.Owner}}
+LIMA_CIDATA_DATAFILE_{{$dataFile.FileName}}_PATH={{$dataFile.Path}}
+LIMA_CIDATA_DATAFILE_{{$dataFile.FileName}}_PERMISSIONS={{$dataFile.Permissions}}
+{{- end}}
 LIMA_CIDATA_GUEST_INSTALL_PREFIX={{ .GuestInstallPrefix }}
 {{- if .Containerd.User}}
 LIMA_CIDATA_CONTAINERD_USER=1
diff --git a/pkg/cidata/cidata.go b/pkg/cidata/cidata.go
index 376bad7d..8cf50f89 100644
--- a/pkg/cidata/cidata.go
+++ b/pkg/cidata/cidata.go
@@ -14,6 +14,7 @@ import (
 	"path"
 	"path/filepath"
 	"slices"
+	"strconv"
 	"strings"
 	"time"
 
@@ -322,10 +323,19 @@ func templateArgs(bootScripts bool, instDir, name string, instConfig *limayaml.L
 
 	args.BootCmds = getBootCmds(instConfig.Provision)
 
-	for _, f := range instConfig.Provision {
+	for i, f := range instConfig.Provision {
 		if f.Mode == limayaml.ProvisionModeDependency && *f.SkipDefaultDependencyResolution {
 			args.SkipDefaultDependencyResolution = true
 		}
+		if f.Mode == limayaml.ProvisionModeData {
+			args.DataFiles = append(args.DataFiles, DataFile{
+				FileName:    fmt.Sprintf("%08d", i),
+				Overwrite:   strconv.FormatBool(*f.Overwrite),
+				Owner:       *f.Owner,
+				Path:        *f.Path,
+				Permissions: *f.Permissions,
+			})
+		}
 	}
 
 	return &args, nil
@@ -376,6 +386,11 @@ func GenerateISO9660(instDir, name string, instConfig *limayaml.LimaYAML, udpDNS
 				Path:   fmt.Sprintf("provision.%s/%08d", f.Mode, i),
 				Reader: strings.NewReader(f.Script),
 			})
+		case limayaml.ProvisionModeData:
+			layout = append(layout, iso9660util.Entry{
+				Path:   fmt.Sprintf("provision.%s/%08d", f.Mode, i),
+				Reader: strings.NewReader(*f.Content),
+			})
 		case limayaml.ProvisionModeBoot:
 			continue
 		case limayaml.ProvisionModeAnsible:
diff --git a/pkg/cidata/template.go b/pkg/cidata/template.go
index 4acd8535..70cba120 100644
--- a/pkg/cidata/template.go
+++ b/pkg/cidata/template.go
@@ -50,6 +50,15 @@ type Mount struct {
 type BootCmds struct {
 	Lines []string
 }
+
+type DataFile struct {
+	FileName    string
+	Overwrite   string
+	Owner       string
+	Path        string
+	Permissions string
+}
+
 type Disk struct {
 	Name   string
 	Device string
@@ -84,6 +93,7 @@ type TemplateArgs struct {
 	Env                             map[string]string
 	Param                           map[string]string
 	BootScripts                     bool
+	DataFiles                       []DataFile
 	DNSAddresses                    []string
 	CACerts                         CACerts
 	HostHomeMountPoint              string
diff --git a/pkg/limatmpl/embed.go b/pkg/limatmpl/embed.go
index b73d0330..e1437b18 100644
--- a/pkg/limatmpl/embed.go
+++ b/pkg/limatmpl/embed.go
@@ -552,11 +552,11 @@ func (tmpl *Template) combineNetworks() {
 	}
 }
 
-// updateScript replaces a "file" property with the actual script and then renames the field to "script".
-func (tmpl *Template) updateScript(field string, idx int, script string) {
+// updateScript replaces a "file" property with the actual script and then renames the field to newName ("script" or "content").
+func (tmpl *Template) updateScript(field string, idx int, newName, script string) {
 	entry := fmt.Sprintf("$a.%s[%d].file", field, idx)
 	// Assign script to the "file" field and then rename it to "script".
-	tmpl.expr.WriteString(fmt.Sprintf("| (%s) = %q | (%s | key) = \"script\"\n", entry, script, entry))
+	tmpl.expr.WriteString(fmt.Sprintf("| (%s) = %q | (%s | key) = %q\n", entry, script, entry, newName))
 }
 
 // embedAllScripts replaces all "provision" and "probes" file references with the actual script.
@@ -565,30 +565,47 @@ func (tmpl *Template) embedAllScripts(ctx context.Context, embedAll bool) error
 		return err
 	}
 	for i, p := range tmpl.Config.Probes {
+		if p.File == nil {
+			continue
+		}
+		warnFileIsExperimental()
 		// Don't overwrite existing script. This should throw an error during validation.
-		if p.File != nil && p.Script == "" {
-			warnFileIsExperimental()
-			isTemplate, _ := SeemsTemplateURL(p.File.URL)
-			if embedAll || !isTemplate {
-				scriptTmpl, err := Read(ctx, "", p.File.URL)
-				if err != nil {
-					return err
-				}
-				tmpl.updateScript("probes", i, string(scriptTmpl.Bytes))
+		if p.Script != "" {
+			continue
+		}
+		isTemplate, _ := SeemsTemplateURL(p.File.URL)
+		if embedAll || !isTemplate {
+			scriptTmpl, err := Read(ctx, "", p.File.URL)
+			if err != nil {
+				return err
 			}
+			tmpl.updateScript("probes", i, "script", string(scriptTmpl.Bytes))
 		}
 	}
 	for i, p := range tmpl.Config.Provision {
-		if p.File != nil && p.Script == "" {
-			warnFileIsExperimental()
-			isTemplate, _ := SeemsTemplateURL(p.File.URL)
-			if embedAll || !isTemplate {
-				scriptTmpl, err := Read(ctx, "", p.File.URL)
-				if err != nil {
-					return err
-				}
-				tmpl.updateScript("provision", i, string(scriptTmpl.Bytes))
+		if p.File == nil {
+			continue
+		}
+		warnFileIsExperimental()
+		newName := "script"
+		switch p.Mode {
+		case limayaml.ProvisionModeData:
+			newName = "content"
+			if p.Content != nil {
+				continue
+			}
+		default:
+			if p.Script != "" {
+				continue
+			}
+		}
+		isTemplate, _ := SeemsTemplateURL(p.File.URL)
+		if embedAll || !isTemplate {
+			scriptTmpl, err := Read(ctx, "", p.File.URL)
+			if err != nil {
+				return err
 			}
+			tmpl.updateScript("provision", i, newName, string(scriptTmpl.Bytes))
 		}
 	}
 	return tmpl.evalExpr()
diff --git a/pkg/limatmpl/locator.go b/pkg/limatmpl/locator.go
index 343ee5d7..5afb7cb6 100644
--- a/pkg/limatmpl/locator.go
+++ b/pkg/limatmpl/locator.go
@@ -12,11 +12,14 @@ import (
 	"os"
 	"path"
 	"path/filepath"
+	"regexp"
+	"runtime"
 	"strings"
 	"unicode"
 
 	"github.com/containerd/containerd/identifiers"
 	"github.com/lima-vm/lima/pkg/ioutilx"
+	"github.com/lima-vm/lima/pkg/limayaml"
 	"github.com/lima-vm/lima/pkg/templatestore"
 	"github.com/sirupsen/logrus"
 )
@@ -30,6 +33,10 @@ func Read(ctx context.Context, name, locator string) (*Template, error) {
 		Locator: locator,
 	}
 
+	if imageTemplate(tmpl, locator) {
+		return tmpl, nil
+	}
+
 	isTemplateURL, templateURL := SeemsTemplateURL(locator)
 	switch {
 	case isTemplateURL:
@@ -121,6 +128,97 @@ func Read(ctx context.Context, name, locator string) (*Template, error) {
 	return tmpl, nil
 }
 
+// Locators with an image file format extension, optionally followed by a compression method.
+// This regex is also used to remove the file format suffix from the instance name.
+var imageURLRegex = regexp.MustCompile(`\.(img|qcow2|raw|iso)(\.(gz|xz|bz2|zstd))?$`)
+
+// Image architecture will be guessed based on the presence of arch keywords.
+var archKeywords = map[string]limayaml.Arch{
+	"aarch64": limayaml.AARCH64,
+	"amd64":   limayaml.X8664,
+	"arm64":   limayaml.AARCH64,
+	"armhf":   limayaml.ARMV7L,
+	"armv7l":  limayaml.ARMV7L,
+	"riscv64": limayaml.RISCV64,
+	"x86_64":  limayaml.X8664,
+}
+
+// These generic tags will be stripped from an image name before turning it into an instance name.
+var genericTags = []string{
+	"base",         // Fedora, Rocky
+	"cloud",        // Fedora, openSUSE
+	"cloudimg",     // Ubuntu, Arch
+	"cloudinit",    // Alpine
+	"daily",        // Debian
+	"default",      // Gentoo
+	"generic",      // Fedora
+	"genericcloud", // CentOS, Debian, Rocky, Alma
+	"kvm",          // Oracle
+	"latest",       // Gentoo, CentOS, Rocky, Alma
+	"linux",        // Arch
+	"minimal",      // openSUSE
+	"openstack",    // Gentoo
+	"server",       // Ubuntu
+	"std",          // Alpine-Lima
+	"stream",       // CentOS
+	"uefi",         // Alpine
+	"vm",           // openSUSE
+}
+
+// imageTemplate checks if the locator specifies an image URL.
+// It will create a minimal template with the image URL and arch derived from the image name
+// and also set the default instance name to the image name, but stripped of generic tags.
+func imageTemplate(tmpl *Template, locator string) bool {
+	if !imageURLRegex.MatchString(locator) {
+		return false
+	}
+
+	var imageArch limayaml.Arch
+	for keyword, arch := range archKeywords {
+		pattern := fmt.Sprintf(`\b%s\b`, keyword)
+		if regexp.MustCompile(pattern).MatchString(locator) {
+			imageArch = arch
+			break
+		}
+	}
+	if imageArch == "" {
+		imageArch = limayaml.NewArch(runtime.GOARCH)
+		logrus.Warnf("cannot determine image arch from URL %q; assuming %q", locator, imageArch)
+	}
+	template := `arch: %q
+images:
+- location: %q
+  arch: %q
+`
+	tmpl.Bytes = []byte(fmt.Sprintf(template, imageArch, locator, imageArch))
+	tmpl.Name = InstNameFromImageURL(locator, imageArch)
+	return true
+}
+
+func InstNameFromImageURL(locator, imageArch string) string {
+	// We intentionally call both path.Base and filepath.Base in case we are running on Windows.
+	name := strings.ToLower(filepath.Base(path.Base(locator)))
+	// Remove file format and compression file types
+	name = imageURLRegex.ReplaceAllString(name, "")
+	// The Alpine "nocloud_" prefix does not fit the genericTags pattern
+	name = strings.TrimPrefix(name, "nocloud_")
+	for _, tag := range genericTags {
+		re := regexp.MustCompile(fmt.Sprintf(`[-_.]%s\b`, tag))
+		name = re.ReplaceAllString(name, "")
+	}
+	// Remove imageArch as well if it is the native arch
+	if limayaml.IsNativeArch(imageArch) {
+		re := regexp.MustCompile(fmt.Sprintf(`[-_.]%s\b`, imageArch))
+		name = re.ReplaceAllString(name, "")
+	}
+	// Remove timestamps from name: 8 digit date, optionally followed by
+	// a delimiter and one or more digits before a word boundary
+	name = regexp.MustCompile(`[-_.]20\d{6}([-_.]\d+)?\b`).ReplaceAllString(name, "")
+	// Normalize archlinux name
+	name = regexp.MustCompile(`^arch\b`).ReplaceAllString(name, "archlinux")
+	return name
+}
+
 func SeemsTemplateURL(arg string) (bool, *url.URL) {
 	u, err := url.Parse(arg)
 	if err != nil {
diff --git a/pkg/limayaml/defaults.go b/pkg/limayaml/defaults.go
index 342ec2e4..2b47bf49 100644
--- a/pkg/limayaml/defaults.go
+++ b/pkg/limayaml/defaults.go
@@ -487,10 +487,47 @@ func FillDefault(y, d, o *LimaYAML, filePath string, warn bool) {
 		if provision.Mode == ProvisionModeDependency && provision.SkipDefaultDependencyResolution == nil {
 			provision.SkipDefaultDependencyResolution = ptr.Of(false)
 		}
-		if out, err := executeGuestTemplate(provision.Script, instDir, y.User, y.Param); err == nil {
-			provision.Script = out.String()
-		} else {
-			logrus.WithError(err).Warnf("Couldn't process provisioning script %q as a template", provision.Script)
+		if provision.Mode == ProvisionModeData {
+			if provision.Content == nil {
+				provision.Content = ptr.Of("")
+			} else {
+				if out, err := executeGuestTemplate(*provision.Content, instDir, y.User, y.Param); err == nil {
+					provision.Content = ptr.Of(out.String())
+				} else {
+					logrus.WithError(err).Warnf("Couldn't process data content %q as a template", *provision.Content)
+				}
+			}
+			if provision.Overwrite == nil {
+				provision.Overwrite = ptr.Of(true)
+			}
+			if provision.Owner == nil {
+				provision.Owner = ptr.Of("root:root")
+			} else {
+				if out, err := executeGuestTemplate(*provision.Owner, instDir, y.User, y.Param); err == nil {
+					provision.Owner = ptr.Of(out.String())
+				} else {
+					logrus.WithError(err).Warnf("Couldn't owner %q as a template", *provision.Owner)
+				}
+			}
+			// Path is required; validation will throw an error when it is nil
+			if provision.Path != nil {
+				if out, err := executeGuestTemplate(*provision.Path, instDir, y.User, y.Param); err == nil {
+					provision.Path = ptr.Of(out.String())
+				} else {
+					logrus.WithError(err).Warnf("Couldn't process path %q as a template", *provision.Path)
+				}
+			}
+			if provision.Permissions == nil {
+				provision.Permissions = ptr.Of("644")
+			}
+		}
+		// TODO Turn Script into a pointer; it is a plain string for historical reasons only
+		if provision.Script != "" {
+			if out, err := executeGuestTemplate(provision.Script, instDir, y.User, y.Param); err == nil {
+				provision.Script = out.String()
+			} else {
+				logrus.WithError(err).Warnf("Couldn't process provisioning script %q as a template", provision.Script)
+			}
 		}
 	}
 
diff --git a/pkg/limayaml/limayaml.go b/pkg/limayaml/limayaml.go
index 9386a987..39a7c557 100644
--- a/pkg/limayaml/limayaml.go
+++ b/pkg/limayaml/limayaml.go
@@ -221,6 +221,7 @@ const (
 	ProvisionModeBoot       ProvisionMode = "boot"
 	ProvisionModeDependency ProvisionMode = "dependency"
 	ProvisionModeAnsible    ProvisionMode = "ansible"
+	ProvisionModeData       ProvisionMode = "data"
 )
 
 type Provision struct {
@@ -229,6 +230,16 @@ type Provision struct {
 	Script                          string             `yaml:"script" json:"script"`
 	File                            *LocatorWithDigest `yaml:"file,omitempty" json:"file,omitempty" jsonschema:"nullable"`
 	Playbook                        string             `yaml:"playbook,omitempty" json:"playbook,omitempty"`
+	// All ProvisionData fields must be nil unless Mode is ProvisionModeData
+	ProvisionData `yaml:",inline"` // Flatten fields for "strict" YAML mode
+}
+
+type ProvisionData struct {
+	Content     *string `yaml:"content,omitempty" json:"content,omitempty" jsonschema:"nullable"`
+	Overwrite   *bool   `yaml:"overwrite,omitempty" json:"overwrite,omitempty" jsonschema:"nullable"`
+	Owner       *string `yaml:"owner,omitempty" json:"owner,omitempty"` // any owner string supported by `chown`, defaults to "root:root"
+	Path        *string `yaml:"path,omitempty" json:"path,omitempty"`
+	Permissions *string `yaml:"permissions,omitempty" json:"permissions,omitempty"`
 }
 
 type Containerd struct {
diff --git a/pkg/limayaml/validate.go b/pkg/limayaml/validate.go
index 4bf3c837..f1c22f24 100644
--- a/pkg/limayaml/validate.go
+++ b/pkg/limayaml/validate.go
@@ -12,6 +12,7 @@ import (
 	"path/filepath"
 	"regexp"
 	"runtime"
+	"strconv"
 	"strings"
 	"unicode"
 
@@ -218,20 +219,52 @@ func Validate(y *LimaYAML, warn bool) error {
 			}
 		}
 		switch p.Mode {
-		case ProvisionModeSystem, ProvisionModeUser, ProvisionModeBoot:
-			if p.SkipDefaultDependencyResolution != nil {
-				return fmt.Errorf("field `provision[%d].mode` cannot set skipDefaultDependencyResolution, only valid on scripts of type %q",
-					i, ProvisionModeDependency)
-			}
-		case ProvisionModeDependency:
-		case ProvisionModeAnsible:
+		case ProvisionModeSystem, ProvisionModeUser, ProvisionModeBoot, ProvisionModeData, ProvisionModeDependency, ProvisionModeAnsible:
 		default:
-			return fmt.Errorf("field `provision[%d].mode` must one of %q, %q, %q, %q, or %q",
-				i, ProvisionModeSystem, ProvisionModeUser, ProvisionModeBoot, ProvisionModeDependency, ProvisionModeAnsible)
+			return fmt.Errorf("field `provision[%d].mode` must one of %q, %q, %q, %q, %q, or %q",
+				i, ProvisionModeSystem, ProvisionModeUser, ProvisionModeBoot, ProvisionModeDependency, ProvisionModeAnsible, ProvisionModeAnsible)
+		}
+		if p.Mode != ProvisionModeDependency && p.SkipDefaultDependencyResolution != nil {
+			return fmt.Errorf("field `provision[%d].mode` cannot set skipDefaultDependencyResolution, only valid on scripts of type %q",
+				i, ProvisionModeDependency)
+		}
+		if p.Mode == ProvisionModeData {
+			if p.Path == nil {
+				return fmt.Errorf("field `provision[%d].path` must not be empty when mode is %q", i, ProvisionModeData)
+			}
+			if !path.IsAbs(*p.Path) {
+				return fmt.Errorf("field `provision[%d].path` must be an absolute path", i)
+			}
+			if p.Content == nil {
+				return fmt.Errorf("field `provision[%d].content` must not be empty when mode is %q", i, ProvisionModeData)
+			}
+			// FillDefaults makes sure that p.Permissions is not nil
+			if _, err := strconv.ParseInt(*p.Permissions, 8, 64); err != nil {
+				return fmt.Errorf("field `provision[%d].permissions` must be an octal number: %w", i, err)
+			}
+		} else {
+			if p.Script == "" && p.Mode != ProvisionModeAnsible {
+				return fmt.Errorf("field `provision[%d].script` must not be empty", i)
+			}
+			if p.Content != nil {
+				return fmt.Errorf("field `provision[%d].content` can only be set when mode is %q", i, ProvisionModeData)
+			}
+			if p.Overwrite != nil {
+				return fmt.Errorf("field `provision[%d].overwrite` can only be set when mode is %q", i, ProvisionModeData)
+			}
+			if p.Owner != nil {
+				return fmt.Errorf("field `provision[%d].owner` can only be set when mode is %q", i, ProvisionModeData)
+			}
+			if p.Path != nil {
+				return fmt.Errorf("field `provision[%d].path` can only be set when mode is %q", i, ProvisionModeData)
+			}
+			if p.Permissions != nil {
+				return fmt.Errorf("field `provision[%d].permissions` can only be set when mode is %q", i, ProvisionModeData)
+			}
 		}
 		if p.Playbook != "" {
 			if p.Mode != ProvisionModeAnsible {
-				return fmt.Errorf("field `provision[%d].mode must be %q if playbook is set", i, ProvisionModeAnsible)
+				return fmt.Errorf("field `provision[%d].playbook can only be set when mode is %q", i, ProvisionModeAnsible)
 			}
 			if p.Script != "" {
 				return fmt.Errorf("field `provision[%d].script must be empty if playbook is set", i)
diff --git a/pkg/vz/vz_driver_darwin.go b/pkg/vz/vz_driver_darwin.go
index 63a1ef6c..ef50a10a 100644
--- a/pkg/vz/vz_driver_darwin.go
+++ b/pkg/vz/vz_driver_darwin.go
@@ -112,7 +112,7 @@ func (l *LimaVzDriver) Validate() error {
 	}
 
 	for i, image := range l.Instance.Config.Images {
-		if unknown := reflectutil.UnknownNonEmptyFields(image, "File"); len(unknown) > 0 {
+		if unknown := reflectutil.UnknownNonEmptyFields(image, "File", "Kernel", "Initrd"); len(unknown) > 0 {
 			logrus.Warnf("vmType %s: ignoring images[%d]: %+v", *l.Instance.Config.VMType, i, unknown)
 		}
 	}
diff --git a/templates/default.yaml b/templates/default.yaml
index 2275e4ec..181b66e7 100644
--- a/templates/default.yaml
+++ b/templates/default.yaml
@@ -253,6 +253,21 @@ containerd:
 # # See ansible docs, and `ansible-config`, for more info https://docs.ansible.com/ansible/latest/playbook_guide/
 # - mode: ansible
 #   playbook: playbook.yaml
+# # `data` is a file that is written to the guest filesystem and not executed at all.
+# # The file is written after the boot scripts, but before any other provisioning scripts are run.
+# # Note that reverse-sshfs mounts are not established at this time; other mount types are already mounted.
+# # The `path` and `content` properties are required. The `file` property can be used the same way as with
+# # other provisioning scripts, in which case `content` must be empty. The `owner` defaults to "root:root";
+# # the permissions default to 644. The `overwrite` property defaults to `true`, in which case the file will
+# # be overwritten on every boot.
+# # `path`, `contents`, and `owner` are evaluated as guest templates (see above).
+# - mode: data
+#   path: /etc/conf.d/example
+#   content: |
+#     FOO=bar
+#   owner: "root:root"
+#   permissions: 644
+#   overwrite: false
 
 # Probe scripts to check readiness.
 # The scripts run in user mode. They must start with a '#!' line.
diff --git a/website/content/en/docs/dev/internals.md b/website/content/en/docs/dev/internals.md
index 48aa0b08..8e3bbe4a 100644
--- a/website/content/en/docs/dev/internals.md
+++ b/website/content/en/docs/dev/internals.md
@@ -186,6 +186,10 @@ The volume label is "cidata", as defined by [cloud-init NoCloud](https://docs.cl
 - `LIMA_CIDATA_MOUNTS`: the number of the Lima mounts
 - `LIMA_CIDATA_MOUNTS_%d_MOUNTPOINT`: the N-th mount point of Lima mounts (N=0, 1, ...)
 - `LIMA_CIDATA_MOUNTTYPE`: the type of the Lima mounts ("reverse-sshfs", "9p", ...)
+- `LIMA_CIDATA_DATAFILE_%08d_OVERWRITE`: set to "true" if the datafile should be overwritten if it already exists.
+- `LIMA_CIDATA_DATAFILE_%08d_OWNER`: set to the owner of the datafile.
+- `LIMA_CIDATA_DATAFILE_%08d_PATH`: set to the path the datafile should be copied to.
+- `LIMA_CIDATA_DATAFILE_%08d_PERMISSIONS`: set to the file permissions (in octal) for the datafile.
 - `LIMA_CIDATA_CONTAINERD_USER`: set to "1" if rootless containerd to be set up
 - `LIMA_CIDATA_CONTAINERD_SYSTEM`: set to "1" if system-wide containerd to be set up
 - `LIMA_CIDATA_CONTAINERD_ARCHIVE`: the name of the containerd archive. `nerdctl-full.tgz`



Test patch:
diff --git a/pkg/limatmpl/embed_test.go b/pkg/limatmpl/embed_test.go
index f9057872..1dae4665 100644
--- a/pkg/limatmpl/embed_test.go
+++ b/pkg/limatmpl/embed_test.go
@@ -339,6 +339,10 @@ networks:
 provision:
 # This script will be merged from an external file
 - file: base1.sh # This comment will move to the "script" key
+# This is just a data file
+- mode: data
+  file: base1.sh # This comment will move to the "content" key
+  path: /tmp/data
 `,
 		`
 # base0.yaml is ignored
@@ -346,6 +350,7 @@ provision:
 #!/usr/bin/env bash
 echo "This is base1.sh"
 `,
+		// TODO: the empty line after the `path` is unexpected
 		`
 # Hi There!
 provision:
@@ -353,6 +358,13 @@ provision:
 - script: |-  # This comment will move to the "script" key
     #!/usr/bin/env bash
     echo "This is base1.sh"
+# This is just a data file
+- mode: data
+  content: |-  # This comment will move to the "content" key
+    #!/usr/bin/env bash
+    echo "This is base1.sh"
+  path: /tmp/data
+
 # base0.yaml is ignored
 `,
 	},
@@ -374,6 +386,12 @@ provision:
 		"base: [{url: base.yaml, digest: deafbad}]",
 		"not yet implemented",
 	},
+	{
+		"Image URLs will be converted into a template",
+		"",
+		"base: https://example.com/lima-linux-riscv64.img",
+		"{arch: riscv64, images: [{location: https://example.com/lima-linux-riscv64.img, arch: riscv64}]}",
+	},
 }
 
 func TestEmbed(t *testing.T) {
diff --git a/pkg/limatmpl/locator_test.go b/pkg/limatmpl/locator_test.go
new file mode 100644
index 00000000..dcd23361
--- /dev/null
+++ b/pkg/limatmpl/locator_test.go
@@ -0,0 +1,55 @@
+// SPDX-FileCopyrightText: Copyright The Lima Authors
+// SPDX-License-Identifier: Apache-2.0
+
+package limatmpl_test
+
+import (
+	"fmt"
+	"runtime"
+	"testing"
+
+	"github.com/lima-vm/lima/pkg/limatmpl"
+	"github.com/lima-vm/lima/pkg/limayaml"
+	"gotest.tools/v3/assert"
+)
+
+func TestInstNameFromImageURL(t *testing.T) {
+	t.Run("strips image format and compression method", func(t *testing.T) {
+		name := limatmpl.InstNameFromImageURL("linux.iso.bz2", "unknown")
+		assert.Equal(t, name, "linux")
+	})
+	t.Run("removes generic tags", func(t *testing.T) {
+		name := limatmpl.InstNameFromImageURL("linux-linux_cloudimg.base-x86_64.raw", "unknown")
+		assert.Equal(t, name, "linux-x86_64")
+	})
+	t.Run("removes Alpine `nocloud_` prefix", func(t *testing.T) {
+		name := limatmpl.InstNameFromImageURL("nocloud_linux-x86_64.raw", "unknown")
+		assert.Equal(t, name, "linux-x86_64")
+	})
+	t.Run("removes date tag", func(t *testing.T) {
+		name := limatmpl.InstNameFromImageURL("linux-20250101.raw", "unknown")
+		assert.Equal(t, name, "linux")
+	})
+	t.Run("removes date tag including time", func(t *testing.T) {
+		name := limatmpl.InstNameFromImageURL("linux-20250101-2000.raw", "unknown")
+		assert.Equal(t, name, "linux")
+	})
+	t.Run("removes date tag including zero time", func(t *testing.T) {
+		name := limatmpl.InstNameFromImageURL("linux-20250101.0.raw", "unknown")
+		assert.Equal(t, name, "linux")
+	})
+	t.Run("replace arch with archlinux", func(t *testing.T) {
+		name := limatmpl.InstNameFromImageURL("arch-aarch64.raw", "unknown")
+		assert.Equal(t, name, "archlinux-aarch64")
+	})
+	t.Run("don't replace arch in the middle of the name", func(t *testing.T) {
+		name := limatmpl.InstNameFromImageURL("my-arch-aarch64.raw", "unknown")
+		assert.Equal(t, name, "my-arch-aarch64")
+	})
+	t.Run("removes native arch", func(t *testing.T) {
+		arch := limayaml.NewArch(runtime.GOARCH)
+		image := fmt.Sprintf("linux_cloudimg.base-%s.qcow2.gz", arch)
+		name := limatmpl.InstNameFromImageURL(image, arch)
+		assert.Equal(t, name, "linux")
+	})
+}
diff --git a/pkg/limayaml/validate_test.go b/pkg/limayaml/validate_test.go
index a7b94f53..3a690510 100644
--- a/pkg/limayaml/validate_test.go
+++ b/pkg/limayaml/validate_test.go
@@ -58,6 +58,30 @@ func TestValidateProbes(t *testing.T) {
 	assert.Error(t, err, "field `probe[0].file.digest` support is not yet implemented")
 }
 
+func TestValidateProvisionData(t *testing.T) {
+	images := `images: [{location: /}]`
+	validData := `provision: [{mode: data, path: /tmp, content: hello}]`
+	y, err := Load([]byte(validData+"\n"+images), "lima.yaml")
+	assert.NilError(t, err)
+
+	err = Validate(y, false)
+	assert.NilError(t, err)
+
+	invalidData := `provision: [{mode: data, content: hello}]`
+	y, err = Load([]byte(invalidData+"\n"+images), "lima.yaml")
+	assert.NilError(t, err)
+
+	err = Validate(y, false)
+	assert.Error(t, err, "field `provision[0].path` must not be empty when mode is \"data\"")
+
+	invalidData = `provision: [{mode: data, path: /tmp, content: hello, permissions: 9}]`
+	y, err = Load([]byte(invalidData+"\n"+images), "lima.yaml")
+	assert.NilError(t, err)
+
+	err = Validate(y, false)
+	assert.ErrorContains(t, err, "provision[0].permissions` must be an octal number")
+}
+
 func TestValidateAdditionalDisks(t *testing.T) {
 	images := `images: [{"location": "/"}]`