Repository: lima-vm/lima
Pull request #3302 (2025-03-04 04:34:59)

Implement "data" provision mode
These are data files copied to the guest filesystem. They are not scripts and are not being executed.

It is pretty similar to the `write_files` feature in `cloud-init`, but is completely implemented in our `boot.sh`, so works automatically with `lima-init` and other `cloud-init` alternatives.

```yaml
provision:
- mode: data
  path: /etc/conf.d/example
  content: |
    VAR=VALUE
  overwrite: false
- mode: data
  path: "{{.Home}}/kubeadm.yaml"
  file: kubeadm.yaml
  owner: "{{.User}}"
  permissions: 0700
```

The `file` property works the same way it works for provision scripts and probes (during template embedding the file content is placed into content and the file property is removed, so the data is embedded in the instance directory).


jandubois: @nirs This is an evolved variant of the data mode scripts we talked about on Slack a few weeks ago. Instead of providing variables of the data file locations I thought it makes more sense to allow the user to specify the target `path` where the file should be installed, so you don't need a `cp` command in a script.

Example:

```console
❯ cat inotify.yaml
base: template://ubuntu-24.04
provision:
- mode: data
  path: /etc/sysctl.d/99-inotify.conf
  file: sysctl.conf

❯ cat sysctl.conf
fs.inotify.max_user_watches = 524288
fs.inotify.max_user_instances = 512

❯ l tmpl copy --embed inotify.yaml -
WARN[0000] `provision[*].file` and `probes[*].file` are experimental
base: template://ubuntu-24.04
provision:
- mode: data
  path: /etc/sysctl.d/99-inotify.conf
  content: |
    fs.inotify.max_user_watches = 524288
    fs.inotify.max_user_instances = 512
```

Patch:
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/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/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/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/limatmpl/embed_test.go b/pkg/limatmpl/embed_test.go
index f9057872..fff9cd14 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
 `,
 	},
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": "/"}]`