Repository: lima-vm/lima
Pull request #2570 (2024-08-30 16:40:35)

Add limayaml param settings to provisioning script environment
They will be prefixed with `PARAM_`, so `param.FOO` becomes `PARAM_FOO`.

This is useful because parameter substitution happens when a template is instantiated, so `[ "{{.Param.ROOTFUL}}" = true ]` becomes `[ "true" = true ]` in the `cloud-init-output.log`.

This mechanism also works better when the parameter contains quotes, which would break a simplistic `FOO="{{.Param.FOO}}"`.

Test sample:

```yaml
param:
  FOO: 'foo"bar'

provision:
- mode: system
  script: |
    echo "$PARAM_FOO"
```

Output:

```
LIMA 2024-08-30T09:13:11-07:00| Executing /mnt/lima-cidata/provision.system/00000000
foo"bar
LIMA 2024-08-30T09:13:11-07:00| Exiting with code 0
```

Example of real-life usage (from #2515) will be to replace

```diff
-     # Setting shell variable makes it easier to read cloud-init-output.log
-     readonly ROOTFUL="{{.Param.ROOTFUL}}"
-     if [ "$ROOTFUL" = true ]; then
+     if [ "$PARAM_ROOTFUL" = true ]; then
```

jandubois: I'm not proud of the hack to make the `$PARAM_*` variables available to the probes, but I couldn't figure out a cleaner way as long as we support arbitrary interpreters via the hashbang line.

Anyways, I think the PR is ready for review now.
jandubois: > This change looks like something I considered before implementing #2498

This change just build on top of #2498, which is still needed so we can use `{{.Param.Key}}` in all the places that are not provisioning scripts or probes.

> I haven't finished reading the `probes` hack yet, but I'll share what I understand so far.

I've moved the hack to a separate function and added rather verbose documentation about it. It feels kind of too long, but maybe it is useful.

Patch:
diff --git a/examples/default.yaml b/examples/default.yaml
index 8c31da87..5b269ff7 100644
--- a/examples/default.yaml
+++ b/examples/default.yaml
@@ -237,6 +237,7 @@ containerd:
 #   playbook: playbook.yaml
 
 # Probe scripts to check readiness.
+# The scripts run in user mode. They must start with a '#!' line.
 # The scripts can use the following template variables: {{.Home}}, {{.UID}}, {{.User}}, and {{.Param.Key}}
 # 🟢 Builtin default: null
 # probes:
@@ -422,7 +423,12 @@ networks:
 #   KEY: value
 
 # Defines variables used for customizing the functionality.
+# Key names must start with an uppercase or lowercase letter followed by
+# any number of letters, digits, and underscores.
+# Values must not contain non-printable characters except for spaces and tabs.
 # These variables can be referenced as {{.Param.Key}} in lima.yaml.
+# In provisioning scripts and probes they are also available as predefined
+# environment variables, prefixed with "PARAM` (so `Key` → `$PARAM_Key`).
 # param:
 #   Key: value
 
diff --git a/pkg/cidata/cidata.TEMPLATE.d/boot.sh b/pkg/cidata/cidata.TEMPLATE.d/boot.sh
index 5d9c867f..ab65c719 100644
--- a/pkg/cidata/cidata.TEMPLATE.d/boot.sh
+++ b/pkg/cidata/cidata.TEMPLATE.d/boot.sh
@@ -10,7 +10,9 @@ WARNING() {
 }
 
 # shellcheck disable=SC2163
-while read -r line; do export "$line"; done <"${LIMA_CIDATA_MNT}"/lima.env
+while read -r line; do [ -n "$line" ] && export "$line"; done <"${LIMA_CIDATA_MNT}"/lima.env
+# shellcheck disable=SC2163
+while read -r line; do [ -n "$line" ] && export "$line"; done <"${LIMA_CIDATA_MNT}"/param.env
 
 # shellcheck disable=SC2163
 while read -r line; do
@@ -61,12 +63,13 @@ if [ -d "${LIMA_CIDATA_MNT}"/provision.user ]; then
 	if [ ! -f /sbin/openrc-run ]; then
 		until [ -e "/run/user/${LIMA_CIDATA_UID}/systemd/private" ]; do sleep 3; done
 	fi
+	params=$(grep -o '^PARAM_[^=]*' "${LIMA_CIDATA_MNT}"/param.env | paste -sd ,)
 	for f in "${LIMA_CIDATA_MNT}"/provision.user/*; do
 		INFO "Executing $f (as user ${LIMA_CIDATA_USER})"
 		cp "$f" "${USER_SCRIPT}"
 		chown "${LIMA_CIDATA_USER}" "${USER_SCRIPT}"
 		chmod 755 "${USER_SCRIPT}"
-		if ! sudo -iu "${LIMA_CIDATA_USER}" "XDG_RUNTIME_DIR=/run/user/${LIMA_CIDATA_UID}" "${USER_SCRIPT}"; then
+		if ! sudo -iu "${LIMA_CIDATA_USER}" "--preserve-env=${params}" "XDG_RUNTIME_DIR=/run/user/${LIMA_CIDATA_UID}" "${USER_SCRIPT}"; then
 			WARNING "Failed to execute $f (as user ${LIMA_CIDATA_USER})"
 			CODE=1
 		fi
diff --git a/pkg/cidata/cidata.TEMPLATE.d/param.env b/pkg/cidata/cidata.TEMPLATE.d/param.env
new file mode 100644
index 00000000..e003b1c4
--- /dev/null
+++ b/pkg/cidata/cidata.TEMPLATE.d/param.env
@@ -0,0 +1,3 @@
+{{range $key, $val := .Param -}}
+PARAM_{{ $key }}={{ $val }}
+{{end -}}
diff --git a/pkg/cidata/cidata.TEMPLATE.d/user-data b/pkg/cidata/cidata.TEMPLATE.d/user-data
index 1f7c150f..5bbd9940 100644
--- a/pkg/cidata/cidata.TEMPLATE.d/user-data
+++ b/pkg/cidata/cidata.TEMPLATE.d/user-data
@@ -84,6 +84,12 @@ ca_certs:
 bootcmd:
   {{- range $cmd := $.BootCmds }}
 - |
+  # We need to embed the params.env as a here-doc because /mnt/lima-cidata is not yet mounted
+  while read -r line; do [ -n "$line" ] && export "$line"; done <<'EOF'
+    {{- range $key, $val := $.Param }}
+  PARAM_{{ $key }}={{ $val }}
+    {{- end }}
+  EOF
     {{- range $line := $cmd.Lines }}
   {{ $line }}
     {{- end }}
diff --git a/pkg/cidata/cidata.go b/pkg/cidata/cidata.go
index 71096c1d..5742cc54 100644
--- a/pkg/cidata/cidata.go
+++ b/pkg/cidata/cidata.go
@@ -140,6 +140,7 @@ func GenerateISO9660(instDir, name string, y *limayaml.LimaYAML, udpDNSLocalPort
 		VirtioPort:     virtioPort,
 		Plain:          *y.Plain,
 		TimeZone:       *y.TimeZone,
+		Param:          y.Param,
 	}
 
 	firstUsernetIndex := limayaml.FirstUsernetIndex(y)
diff --git a/pkg/cidata/template.go b/pkg/cidata/template.go
index b9aa9618..aedc5caa 100644
--- a/pkg/cidata/template.go
+++ b/pkg/cidata/template.go
@@ -73,6 +73,7 @@ type TemplateArgs struct {
 	UDPDNSLocalPort                 int
 	TCPDNSLocalPort                 int
 	Env                             map[string]string
+	Param                           map[string]string
 	DNSAddresses                    []string
 	CACerts                         CACerts
 	HostHomeMountPoint              string
diff --git a/pkg/hostagent/requirements.go b/pkg/hostagent/requirements.go
index 952e1e6d..74ad0f02 100644
--- a/pkg/hostagent/requirements.go
+++ b/pkg/hostagent/requirements.go
@@ -41,9 +41,52 @@ func (a *HostAgent) waitForRequirements(label string, requirements []requirement
 	return errors.Join(errs...)
 }
 
+// prefixExportParam will modify a script to be executed by ssh.ExecuteScript so that it exports
+// all the variables from /mnt/lima-cidata/param.env before invoking the actual interpreter.
+//
+//   - The script is executed in user mode, so needs to read the file using `sudo`.
+//
+//   - `sudo cat param.env | while …; do export …; done` does not work because the piping
+//     creates a subshell, and the exported variables are not visible to the parent process.
+//
+//   - The `<<<"$string"` redirection is not available on alpine-lima, where /bin/bash is
+//     just a wrapper around busybox ash.
+//
+// A script that will start with `#!/usr/bin/env ruby` will be modified to look like this:
+//
+//	while read -r line; do
+//	    [ -n "$line" ] && export "$line"
+//	done<<EOF
+//	$(sudo cat /mnt/lima-cidata/param.env)
+//	EOF
+//	/usr/bin/env ruby
+//
+// ssh.ExecuteScript will strip the `#!` prefix from the first line and invoke the rest
+// of the line as the command. The full script is then passed via STDIN. We use the $' '
+// form of shell quoting to be able to use \n as newline escapes to fit everything on a
+// single line:
+//
+//	#!/bin/bash -c $'while … done<<EOF\n$(sudo …)\nEOF\n/usr/bin/env ruby'
+//	#!/usr/bin/env ruby
+//	…
+func prefixExportParam(script string) (string, error) {
+	interpreter, err := ssh.ParseScriptInterpreter(script)
+	if err != nil {
+		return "", err
+	}
+
+	// TODO we should have a symbolic constant for `/mnt/lima-cidata`
+	exportParam := `while read -r line; do [ -n "$line" ] && export "$line"; done<<EOF\n$(sudo cat /mnt/lima-cidata/param.env)\nEOF\n`
+	return fmt.Sprintf("#!/bin/bash -c $'%s%s'\n%s", exportParam, interpreter, script), nil
+}
+
 func (a *HostAgent) waitForRequirement(r requirement) error {
 	logrus.Debugf("executing script %q", r.description)
-	stdout, stderr, err := ssh.ExecuteScript(a.instSSHAddress, a.sshLocalPort, a.sshConfig, r.script, r.description)
+	script, err := prefixExportParam(r.script)
+	if err != nil {
+		return err
+	}
+	stdout, stderr, err := ssh.ExecuteScript(a.instSSHAddress, a.sshLocalPort, a.sshConfig, script, r.description)
 	logrus.Debugf("stdout=%q, stderr=%q, err=%v", stdout, stderr, err)
 	if err != nil {
 		return fmt.Errorf("stdout=%q, stderr=%q: %w", stdout, stderr, err)
diff --git a/pkg/limayaml/validate.go b/pkg/limayaml/validate.go
index 0cfee5e7..b70fe5a7 100644
--- a/pkg/limayaml/validate.go
+++ b/pkg/limayaml/validate.go
@@ -10,6 +10,7 @@ import (
 	"regexp"
 	"runtime"
 	"strings"
+	"unicode"
 
 	"github.com/docker/go-units"
 	"github.com/lima-vm/lima/pkg/localpathutil"
@@ -205,11 +206,13 @@ func Validate(y *LimaYAML, warn bool) error {
 		}
 	}
 	for i, p := range y.Probes {
+		if !strings.HasPrefix(p.Script, "#!") {
+			return fmt.Errorf("field `probe[%d].script` must start with a '#!' line", i)
+		}
 		switch p.Mode {
 		case ProbeModeReadiness:
 		default:
-			return fmt.Errorf("field `probe[%d].mode` can only be %q",
-				i, ProbeModeReadiness)
+			return fmt.Errorf("field `probe[%d].mode` can only be %q", i, ProbeModeReadiness)
 		}
 	}
 	for i, rule := range y.PortForwards {
@@ -315,6 +318,21 @@ func Validate(y *LimaYAML, warn bool) error {
 	if warn {
 		warnExperimental(y)
 	}
+
+	// Validate Param settings
+	// Names must start with a letter, followed by any number of letters, digits, or underscores
+	validParamName := regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_]*$`)
+	for param, value := range y.Param {
+		if !validParamName.MatchString(param) {
+			return fmt.Errorf("param %q name does not match regex %q", param, validParamName.String())
+		}
+		for _, r := range value {
+			if !unicode.IsPrint(r) && r != '\t' && r != ' ' {
+				return fmt.Errorf("param %q value contains unprintable character %q", param, r)
+			}
+		}
+	}
+
 	return nil
 }
 
@@ -397,7 +415,7 @@ func validateNetwork(y *LimaYAML) error {
 // It should be called before the `y` parameter is passed to FillDefault() that execute template.
 func ValidateParamIsUsed(y *LimaYAML) error {
 	for key := range y.Param {
-		re, err := regexp.Compile(`{{[^}]*\.Param\.` + key + `[^}]*}}`)
+		re, err := regexp.Compile(`{{[^}]*\.Param\.` + key + `[^}]*}}|\bPARAM_` + key + `\b`)
 		if err != nil {
 			return fmt.Errorf("field to compile regexp for key %q: %w", key, err)
 		}
diff --git a/website/content/en/docs/dev/internals/_index.md b/website/content/en/docs/dev/internals/_index.md
index bc208100..03d94eaf 100644
--- a/website/content/en/docs/dev/internals/_index.md
+++ b/website/content/en/docs/dev/internals/_index.md
@@ -157,6 +157,7 @@ See [Building Ansible inventories](https://docs.ansible.com/ansible/latest/inven
 - `meta-data`: [Cloud-init meta-data](https://docs.cloud-init.io/en/latest/explanation/instancedata.html)
 - `network-config`: [Cloud-init Networking Config Version 2](https://docs.cloud-init.io/en/latest/reference/network-config-format-v2.html)
 - `lima.env`: The `LIMA_CIDATA_*` environment variables (see below) available during `boot.sh` processing
+- `param.env`: The `PARAM_*` environment variables corresponding to the `param` settings from `lima.yaml`
 - `lima-guestagent`: Lima guest agent binary
 - `nerdctl-full.tgz`: [`nerdctl-full-<VERSION>-<OS>-<ARCH>.tar.gz`](https://github.com/containerd/nerdctl/releases)
 - `boot.sh`: Boot script


Test patch:
diff --git a/hack/test-templates.sh b/hack/test-templates.sh
index 28dd1490..e36eafe8 100755
--- a/hack/test-templates.sh
+++ b/hack/test-templates.sh
@@ -36,6 +36,7 @@ declare -A CHECKS=(
 	["user-v2"]=""
 	["mount-path-with-spaces"]=""
 	["provision-ansible"]=""
+	["param-env-variables"]=""
 )
 
 case "$NAME" in
@@ -64,6 +65,7 @@ case "$NAME" in
 	CHECKS["snapshot-offline"]="1"
 	CHECKS["mount-path-with-spaces"]="1"
 	CHECKS["provision-ansible"]="1"
+	CHECKS["param-env-variables"]="1"
 	;;
 "net-user-v2")
 	CHECKS["port-forwards"]=""
@@ -152,6 +154,15 @@ if [[ -n ${CHECKS["provision-ansible"]} ]]; then
 	limactl shell "$NAME" test -e /tmp/ansible
 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
+fi
+
 INFO "Testing proxy settings are imported"
 got=$(limactl shell "$NAME" env | grep FTP_PROXY)
 # Expected: FTP_PROXY is set in addition to ftp_proxy, localhost is replaced
diff --git a/hack/test-templates/test-misc.yaml b/hack/test-templates/test-misc.yaml
index 2bf6cc8b..2f2c17ca 100644
--- a/hack/test-templates/test-misc.yaml
+++ b/hack/test-templates/test-misc.yaml
@@ -2,7 +2,7 @@
 # - disk
 # - (More to come)
 #
-# This template requires Lima v0.14.0 or later.
+# This template requires Lima v1.0.0-alpha.0 or later.
 images:
 # Try to use release-yyyyMMdd image if available. Note that release-yyyyMMdd will be removed after several months.
 - location: "https://cloud-images.ubuntu.com/releases/22.04/release-20220902/ubuntu-22.04-server-cloudimg-amd64.img"
@@ -26,9 +26,30 @@ mounts:
 - location: "/tmp/lima"
   writable: true
 
+param:
+  BOOT: boot
+  DEPENDENCY: dependency
+  PROBE: probe
+  SYSTEM: system
+  USER: user
+
 provision:
 - mode: ansible
   playbook: ./hack/ansible-test.yaml
+- mode: boot
+  script: "touch /tmp/param-$PARAM_BOOT"
+- mode: dependency
+  script: "touch /tmp/param-$PARAM_DEPENDENCY"
+- mode: system
+  script: "touch /tmp/param-$PARAM_SYSTEM"
+- mode: user
+  script: "touch /tmp/param-$PARAM_USER"
+
+probes:
+- mode: readiness
+  script: |
+    #!/bin/sh
+    touch /tmp/param-$PARAM_PROBE
 
 # in order to use this example, you must first create the disk "data". run:
 # $ limactl disk create data --size 10G
diff --git a/pkg/limayaml/validate_test.go b/pkg/limayaml/validate_test.go
index 347ee7df..b45ae18d 100644
--- a/pkg/limayaml/validate_test.go
+++ b/pkg/limayaml/validate_test.go
@@ -31,6 +31,84 @@ func TestValidateDefault(t *testing.T) {
 	assert.NilError(t, err)
 }
 
+func TestValidateProbes(t *testing.T) {
+	images := `images: [{"location": "/"}]`
+	validProbe := `probes: ["script": "#!foo"]`
+	y, err := Load([]byte(validProbe+"\n"+images), "lima.yaml")
+	assert.NilError(t, err)
+
+	err = Validate(y, false)
+	assert.NilError(t, err)
+
+	invalidProbe := `probes: ["script": "foo"]`
+	y, err = Load([]byte(invalidProbe+"\n"+images), "lima.yaml")
+	assert.NilError(t, err)
+
+	err = Validate(y, false)
+	assert.Error(t, err, "field `probe[0].script` must start with a '#!' line")
+}
+
+func TestValidateParamName(t *testing.T) {
+	images := `images: [{"location": "/"}]`
+	validProvision := `provision: [{"script": "echo $PARAM_name $PARAM_NAME $PARAM_Name_123"}]`
+	validParam := []string{
+		`param: {"name": "value"}`,
+		`param: {"NAME": "value"}`,
+		`param: {"Name_123": "value"}`,
+	}
+	for _, param := range validParam {
+		y, err := Load([]byte(param+"\n"+validProvision+"\n"+images), "lima.yaml")
+		assert.NilError(t, err)
+
+		err = Validate(y, false)
+		assert.NilError(t, err)
+	}
+
+	invalidProvision := `provision: [{"script": "echo $PARAM__Name $PARAM_3Name $PARAM_Last.Name"}]`
+	invalidParam := []string{
+		`param: {"_Name": "value"}`,
+		`param: {"3Name": "value"}`,
+		`param: {"Last.Name": "value"}`,
+	}
+	for _, param := range invalidParam {
+		y, err := Load([]byte(param+"\n"+invalidProvision+"\n"+images), "lima.yaml")
+		assert.NilError(t, err)
+
+		err = Validate(y, false)
+		assert.ErrorContains(t, err, "name does not match regex")
+	}
+}
+
+func TestValidateParamValue(t *testing.T) {
+	images := `images: [{"location": "/"}]`
+	provision := `provision: [{"script": "echo $PARAM_name"}]`
+	validParam := []string{
+		`param: {"name": ""}`,
+		`param: {"name": "foo bar"}`,
+		`param: {"name": "foo\tbar"}`,
+		`param: {"name": "Symbols ½ and emoji → 👀"}`,
+	}
+	for _, param := range validParam {
+		y, err := Load([]byte(param+"\n"+provision+"\n"+images), "lima.yaml")
+		assert.NilError(t, err)
+
+		err = Validate(y, false)
+		assert.NilError(t, err)
+	}
+
+	invalidParam := []string{
+		`param: {"name": "The end.\n"}`,
+		`param: {"name": "\r"}`,
+	}
+	for _, param := range invalidParam {
+		y, err := Load([]byte(param+"\n"+provision+"\n"+images), "lima.yaml")
+		assert.NilError(t, err)
+
+		err = Validate(y, false)
+		assert.ErrorContains(t, err, "value contains unprintable character")
+	}
+}
+
 func TestValidateParamIsUsed(t *testing.T) {
 	paramYaml := `param:
   name: value`
@@ -41,7 +119,9 @@ func TestValidateParamIsUsed(t *testing.T) {
 		`mounts: [{"location": "/tmp/{{ .Param.name }}"}]`,
 		`mounts: [{"location": "/tmp", mountPoint: "/tmp/{{ .Param.name }}"}]`,
 		`provision: [{"script": "echo {{ .Param.name }}"}]`,
+		`provision: [{"script": "echo $PARAM_name"}]`,
 		`probes: [{"script": "echo {{ .Param.name }}"}]`,
+		`probes: [{"script": "echo $PARAM_name"}]`,
 		`copyToHost: [{"guest": "/tmp/{{ .Param.name }}", "host": "/tmp"}]`,
 		`copyToHost: [{"guest": "/tmp", "host": "/tmp/{{ .Param.name }}"}]`,
 		`portForwards: [{"guestSocket": "/tmp/{{ .Param.name }}", "hostSocket": "/tmp"}]`,
@@ -53,7 +133,7 @@ func TestValidateParamIsUsed(t *testing.T) {
 		assert.NilError(t, err)
 	}
 
-	// use "{{if .Param.rootful \"true\"}}{{else}}{{end}}"" in provision, probe, copyToHost, and portForward
+	// use "{{if eq .Param.rootful \"true\"}}…{{else}}…{{end}}" in provision, probe, copyToHost, and portForward
 	rootfulYaml := `param:
   rootful: true`
 	fieldsUsingIfParamRootfulTrue := []string{
@@ -64,7 +144,7 @@ func TestValidateParamIsUsed(t *testing.T) {
 		`copyToHost: [{"guest": "/tmp/{{if eq .Param.rootful \"true\"}}rootful{{else}}rootless{{end}}", "host": "/tmp"}]`,
 		`copyToHost: [{"guest": "/tmp", "host": "/tmp/{{if eq .Param.rootful \"true\"}}rootful{{else}}rootless{{end}}"}]`,
 		`portForwards: [{"guestSocket": "{{if eq .Param.rootful \"true\"}}/var/run{{else}}/run/user/{{.UID}}{{end}}/docker.sock", "hostSocket": "{{.Dir}}/sock/docker.sock"}]`,
-		`portForwards: [{"guestSocket": "/var/run/docker.sock", "hostSocket": "{{.Dir}}/sock/docker-{{if eq .Param.rootful \"true\"}}rootfule{{else}}rootless{{end}}.sock"}]`,
+		`portForwards: [{"guestSocket": "/var/run/docker.sock", "hostSocket": "{{.Dir}}/sock/docker-{{if eq .Param.rootful \"true\"}}rootful{{else}}rootless{{end}}.sock"}]`,
 	}
 	for _, fieldUsingIfParamRootfulTrue := range fieldsUsingIfParamRootfulTrue {
 		_, err = Load([]byte(fieldUsingIfParamRootfulTrue+"\n"+rootfulYaml), "paramIsUsed.yaml")