Repository: lima-vm/lima

Issue:
Losing whitespace/indentation in bootcmd scripts
I'm attempting to write a bootcmd that uses a bash heredoc to write some YAML to provide some config overrides to cloudinit before the network is initialized, but the problem is that lima strips whitespace when writing bootcmds to the bootcmd in the user-data:

https://github.com/lima-vm/lima/blob/e21b6346eb6a7cf4b6c2b28fcf1fa2215ae3cf18/pkg/cidata/cidata.go#L491-L499

Here's the relevant portion of my lima.yaml:
```
provision:
- mode: boot
  script: |
    cat <<EOF > /etc/cloud/cloud.cfg.d/99-lima1-disable-route.cfg
    ethernets:
      lima1:
        dhcp4-overrides:
          use-routes: false
    EOF
```


And here's what gets rendered in user-data:
```
bootcmd:
- |
  # 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'
  EOF
  cat <<EOF > /etc/cloud/cloud.cfg.d/99-lima1-disable-route.cfg
  ethernets:
  lima1:
  dhcp4-overrides:
  use-routes: false
  EOF
```


Hints from issue comments:
@songponssw: I think there is a `mode: data` for writing configuration during provision. [Example](https://github.com/lima-vm/lima/blob/5843a5cfe59bdf39174a4ab47a048388ebd85412/templates/default.yaml#L239C1-L245C21)

**Instance config.yml**
```
provision:
- mode: data
  path: /etc/cloud/cloud.cfg.d/99-lima1-disable-route.cfg
  owner: root:root
  permissions: '0755'
  content: |
      ethernets:
        lima1:
          dhcp4-overrides:
            use-routes: false

```
**Result**
```
cat /etc/cloud/cloud.cfg.d/99-lima1-disable-route.cfg
ethernets:
  lima1:
    dhcp4-overrides:
      use-routes: false
```@jandubois: > that lima strips whitespace when writing bootcmds to the bootcmd in the user-data

That sounds like a bug to me.

> I think there is a mode: data for writing configuration during provision.

There is, but this probably happens too late in the boot sequence to write network config files.@jandubois: @chancez I've opened #4381 to address this. If you have time, could you test it? Also please confirm that having blank lines in boot commands is not an issue either?

Solution patch:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index d4a1ef5d..00d4cf9c 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -45,7 +45,7 @@ jobs:
         # macOS SDK version
         xcrun --show-sdk-version || true
     - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd  # v5.0.1
-    - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00  # v6.0.0
+    - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c  # v6.1.0
       with:
         go-version: 1.25.x
     - name: Make darwin artifacts
@@ -72,7 +72,7 @@ jobs:
       with:
         name: artifacts-darwin
         path: _artifacts/
-    - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00  # v6.0.0
+    - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c  # v6.1.0
       with:
         go-version: 1.25.x
     - name: Install gcc
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index b4cb9d16..21b97b0a 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -27,7 +27,7 @@ jobs:
     timeout-minutes: 30
     steps:
     - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd  # v5.0.1
-    - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00  # v6.0.0
+    - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c  # v6.1.0
       with:
         go-version: 1.25.x
     - name: Install protoc and Go plugins
@@ -80,7 +80,7 @@ jobs:
         git config --global core.autocrlf false
         git config --global core.eol lf
     - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd  # v5.0.1
-    - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00  # v6.0.0
+    - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c  # v6.1.0
       with:
         go-version: 1.25.x
     - id: golangci-lint-version
@@ -116,7 +116,7 @@ jobs:
         sudo apt-get update
         sudo apt-get install -y --no-install-recommends qemu-utils
     - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd  # v5.0.1
-    - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00  # v6.0.0
+    - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c  # v6.1.0
       with:
         go-version: ${{ matrix.go-version }}
     - name: Unit tests
@@ -167,7 +167,7 @@ jobs:
         git config --global core.autocrlf false
         git config --global core.eol lf
     - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd  # v5.0.1
-    - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00  # v6.0.0
+    - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c  # v6.1.0
       with:
         go-version: 1.25.x
     - name: Unit tests
@@ -195,7 +195,7 @@ jobs:
         git config --global core.autocrlf false
         git config --global core.eol lf
     - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd  # v5.0.1
-    - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00  # v6.0.0
+    - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c  # v6.1.0
       with:
         go-version: 1.25.x
     - name: Make
@@ -228,7 +228,7 @@ jobs:
         path: homebrew-core
         fetch-depth: 0
         filter: tree:0
-    - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00  # v6.0.0
+    - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c  # v6.1.0
       with:
         go-version: 1.25.x
     - name: Unit tests
@@ -308,7 +308,7 @@ jobs:
         - ../hack/test-templates/test-misc.yaml  # TODO: merge net-user-v2 into test-misc
     steps:
     - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd  # v5.0.1
-    - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00  # v6.0.0
+    - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c  # v6.1.0
       with:
         go-version: 1.25.x
     - name: Make
@@ -355,7 +355,7 @@ jobs:
         # msg="treating lima version \"ea336ae\" from \"/Users/runner/.lima-bats/dummy/lima-version\" as very latest release"
         fetch-depth: 0
         submodules: true
-    - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00  # v6.0.0
+    - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c  # v6.1.0
       with:
         go-version: 1.25.x
     - name: Make
@@ -399,7 +399,7 @@ jobs:
         # checks Lima's version and requires proper semantic version format
         fetch-depth: 0
         ref: ${{ github.event.pull_request.head.sha }}
-    - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00  # v6.0.0
+    - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c  # v6.1.0
       with:
         go-version: 1.25.x
     - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830  # v4.3.0
@@ -447,7 +447,7 @@ jobs:
         path: homebrew-core
         fetch-depth: 0
         filter: tree:0
-    - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00  # v6.0.0
+    - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c  # v6.1.0
       with:
         go-version: 1.25.x
     - name: Make
@@ -518,7 +518,7 @@ jobs:
         path: homebrew-core
         fetch-depth: 0
         filter: tree:0
-    - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00  # v6.0.0
+    - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c  # v6.1.0
       with:
         go-version: 1.25.x
     - name: Cache image used by ${{ matrix.oldver }}/examples/ubuntu-lts.yaml
@@ -557,7 +557,7 @@ jobs:
       # --cpus=1 is needed for running vz on GHA: https://github.com/lima-vm/lima/pull/1511#issuecomment-1574937888
       run: echo "LIMACTL_CREATE_ARGS=${LIMACTL_CREATE_ARGS} --cpus 1 --memory 1" >>$GITHUB_ENV
     - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd  # v5.0.1
-    - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00  # v6.0.0
+    - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c  # v6.1.0
       with:
         go-version: 1.25.x
     - name: Make
@@ -590,7 +590,7 @@ jobs:
     timeout-minutes: 30
     steps:
     - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd  # v5.0.1
-    - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00  # v6.0.0
+    - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c  # v6.1.0
       with:
         go-version: 1.25.x
     - name: Install gomodjail
@@ -617,7 +617,7 @@ jobs:
     timeout-minutes: 30
     steps:
     - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd  # v5.0.1
-    - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00  # v6.0.0
+    - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c  # v6.1.0
       with:
         go-version: 1.25.x
     - run: GOOS=netbsd go build ./...
@@ -629,7 +629,7 @@ jobs:
     timeout-minutes: 30
     steps:
     - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd  # v5.0.1
-    - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00  # v6.0.0
+    - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c  # v6.1.0
       with:
         go-version: 1.25.x
     - name: Make
diff --git a/.golangci.yml b/.golangci.yml
index 278cd8c5..841d9606 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -75,7 +75,6 @@ linters:
     modernize:
       disable:
       - omitzero
-      - stringsbuilder
     perfsprint:
       int-conversion: false
       err-error: false
diff --git a/cmd/limactl/editflags/editflags.go b/cmd/limactl/editflags/editflags.go
index 03140256..57513456 100644
--- a/cmd/limactl/editflags/editflags.go
+++ b/cmd/limactl/editflags/editflags.go
@@ -155,36 +155,29 @@ func BuildPortForwardExpression(portForwards []string) (string, error) {
 		return "", nil
 	}
 
-	expr := `.portForwards += [`
+	ports := make([]string, len(portForwards))
 	for i, spec := range portForwards {
 		hostPort, guestPort, isStatic, err := ParsePortForward(spec)
 		if err != nil {
 			return "", err
 		}
-		expr += fmt.Sprintf(`{"guestPort": %q, "hostPort": %q, "static": %v}`, guestPort, hostPort, isStatic)
-		if i < len(portForwards)-1 {
-			expr += ","
-		}
+		ports[i] = fmt.Sprintf(`{"guestPort": %q, "hostPort": %q, "static": %v}`, guestPort, hostPort, isStatic)
 	}
-	expr += `]`
+	expr := fmt.Sprintf(".portForwards += [%s]", strings.Join(ports, ","))
 	return expr, nil
 }
 
 func buildMountListExpression(ss []string) (string, error) {
-	expr := `[`
+	mounts := make([]string, len(ss))
 	for i, s := range ss {
 		writable := strings.HasSuffix(s, ":w")
-		loc := strings.TrimSuffix(s, ":w")
-		loc, err := localpathutil.Expand(loc)
+		loc, err := localpathutil.Expand(strings.TrimSuffix(s, ":w"))
 		if err != nil {
 			return "", err
 		}
-		expr += fmt.Sprintf(`{"location": %q, "mountPoint": %q, "writable": %v}`, loc, loc, writable)
-		if i < len(ss)-1 {
-			expr += ","
-		}
+		mounts[i] = fmt.Sprintf(`{"location": %q, "mountPoint": %q, "writable": %v}`, loc, loc, writable)
 	}
-	expr += `]`
+	expr := fmt.Sprintf("[%s]", strings.Join(mounts, ","))
 	return expr, nil
 }
 
@@ -206,14 +199,11 @@ func YQExpressions(flags *flag.FlagSet, newInstance bool) ([]string, error) {
 				if err != nil {
 					return nil, err
 				}
-				expr := `.dns += [`
+				ips := make([]string, len(ipSlice))
 				for i, ip := range ipSlice {
-					expr += fmt.Sprintf("%q", ip)
-					if i < len(ipSlice)-1 {
-						expr += ","
-					}
+					ips[i] = `"` + ip.String() + `"`
 				}
-				expr += `] | .dns |= unique | .hostResolver.enabled=false`
+				expr := fmt.Sprintf(".dns += [%s] | .dns |= unique | .hostResolver.enabled=false", strings.Join(ips, ","))
 				logrus.Warnf("Disabling HostResolver, as custom DNS addresses (%v) are specified", ipSlice)
 				return []string{expr}, nil
 			},
@@ -293,23 +283,20 @@ func YQExpressions(flags *flag.FlagSet, newInstance bool) ([]string, error) {
 				if err != nil {
 					return nil, err
 				}
-				expr := `.networks += [`
+				networks := make([]string, len(ss))
 				for i, s := range ss {
 					// CLI syntax is still experimental (YAML syntax is out of experimental)
 					switch {
 					case s == "vzNAT":
-						expr += `{"vzNAT": true}`
+						networks[i] = `{"vzNAT": true}`
 					case strings.HasPrefix(s, "lima:"):
 						network := strings.TrimPrefix(s, "lima:")
-						expr += fmt.Sprintf(`{"lima": %q}`, network)
+						networks[i] = fmt.Sprintf(`{"lima": %q}`, network)
 					default:
-						return nil, fmt.Errorf("network name must be \"vzNAT\" or \"lima:*\", got %q", s)
-					}
-					if i < len(ss)-1 {
-						expr += ","
+						return nil, fmt.Errorf(`network name must be "vzNAT" or "lima:*", got %q`, s)
 					}
 				}
-				expr += `] | .networks |= unique_by(.lima)`
+				expr := fmt.Sprintf(`.networks += [%s] | .networks |= unique_by(.lima)`, strings.Join(networks, ","))
 				return []string{expr}, nil
 			},
 			false,
diff --git a/cmd/limactl/network.go b/cmd/limactl/network.go
index 492237c4..0daaffae 100644
--- a/cmd/limactl/network.go
+++ b/cmd/limactl/network.go
@@ -256,13 +256,11 @@ func networkDeleteAction(cmd *cobra.Command, args []string) error {
 		// Because the command currently does not check whether the network being removed is in use
 	}
 
-	var yq string
+	networks := make([]string, len(args))
 	for i, name := range args {
-		yq += fmt.Sprintf("del(.networks.%q)", name)
-		if i < len(args)-1 {
-			yq += " | "
-		}
+		networks[i] = fmt.Sprintf("del(.networks.%q)", name)
 	}
+	yq := strings.Join(networks, " | ")
 	return networkApplyYQ(yq)
 }
 
diff --git a/pkg/cidata/cidata.go b/pkg/cidata/cidata.go
index dbc88316..ab5a92ec 100644
--- a/pkg/cidata/cidata.go
+++ b/pkg/cidata/cidata.go
@@ -489,14 +489,7 @@ func getBootCmds(p []limatype.Provision) []BootCmds {
 	var bootCmds []BootCmds
 	for _, f := range p {
 		if f.Mode == limatype.ProvisionModeBoot {
-			lines := []string{}
-			for line := range strings.SplitSeq(*f.Script, "\n") {
-				if line == "" {
-					continue
-				}
-				lines = append(lines, strings.TrimSpace(line))
-			}
-			bootCmds = append(bootCmds, BootCmds{Lines: lines})
+			bootCmds = append(bootCmds, BootCmds{Lines: strings.Split(*f.Script, "\n")})
 		}
 	}
 	return bootCmds
diff --git a/pkg/editutil/editutil.go b/pkg/editutil/editutil.go
index 14ef22a7..ec0e194e 100644
--- a/pkg/editutil/editutil.go
+++ b/pkg/editutil/editutil.go
@@ -24,19 +24,19 @@ func fileWarning(filename string) string {
 	if err != nil || len(b) == 0 {
 		return ""
 	}
-	s := "# WARNING: " + filename + " includes the following settings,\n"
-	s += "# which are applied before applying this YAML:\n"
-	s += "# -----------\n"
+	var sb strings.Builder
+	sb.WriteString("# WARNING: " + filename + " includes the following settings,\n")
+	sb.WriteString("# which are applied before applying this YAML:\n")
+	sb.WriteString("# -----------\n")
 	for line := range strings.SplitSeq(strings.TrimSuffix(string(b), "\n"), "\n") {
-		s += "#"
+		sb.WriteByte('#')
 		if line != "" {
-			s += " " + line
+			sb.WriteString(" " + line)
 		}
-		s += "\n"
+		sb.WriteByte('\n')
 	}
-	s += "# -----------\n"
-	s += "\n"
-	return s
+	sb.WriteString("# -----------\n\n")
+	return sb.String()
 }
 
 // GenerateEditorWarningHeader generates the editor warning header.
diff --git a/pkg/hostagent/hostagent.go b/pkg/hostagent/hostagent.go
index 59952b9b..957b7f51 100644
--- a/pkg/hostagent/hostagent.go
+++ b/pkg/hostagent/hostagent.go
@@ -500,7 +500,7 @@ func (a *HostAgent) startHostAgentRoutines(ctx context.Context) error {
 		msg := "Running in plain mode. Mounts, dynamic port forwarding, containerd, etc. will be ignored. Guest agent will not be running."
 		for _, port := range a.instConfig.PortForwards {
 			if port.Static {
-				msg += " Static port forwarding is allowed."
+				msg += " Static port forwarding is allowed." //nolint:modernize // stringsbuilder is not needed
 				break
 			}
 		}



Test patch:
diff --git a/cmd/limactl/editflags/editflags_test.go b/cmd/limactl/editflags/editflags_test.go
index c580bb0d..3d3ceea4 100644
--- a/cmd/limactl/editflags/editflags_test.go
+++ b/cmd/limactl/editflags/editflags_test.go
@@ -195,6 +195,36 @@ func TestYQExpressions(t *testing.T) {
 			newInstance: false,
 			expectError: "flag `--mount` conflicts with `--mount-only`",
 		},
+		{
+			name:        "dns",
+			args:        []string{"--dns", "8.8.8.8", "--dns", "8.8.4.4", "--dns", "1.1.1.1"},
+			newInstance: false,
+			expected:    []string{`.dns += ["8.8.8.8","8.8.4.4","1.1.1.1"] | .dns |= unique | .hostResolver.enabled=false`},
+		},
+		{
+			name:        "network vzNAT",
+			args:        []string{"--network", "vzNAT"},
+			newInstance: true,
+			expected:    []string{`.networks += [{"vzNAT": true}] | .networks |= unique_by(.lima)`},
+		},
+		{
+			name:        "network lima:shared",
+			args:        []string{"--network", "lima:shared"},
+			newInstance: true,
+			expected:    []string{`.networks += [{"lima": "shared"}] | .networks |= unique_by(.lima)`},
+		},
+		{
+			name:        "multiple networks",
+			args:        []string{"--network", "vzNAT", "--network", "lima:shared", "--network", "lima:bridged"},
+			newInstance: true,
+			expected:    []string{`.networks += [{"vzNAT": true},{"lima": "shared"},{"lima": "bridged"}] | .networks |= unique_by(.lima)`},
+		},
+		{
+			name:        "invalid network",
+			args:        []string{"--network", "invalid"},
+			newInstance: true,
+			expectError: `network name must be "vzNAT" or "lima:*", got "invalid"`,
+		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {