Repository: roots/trellis-cli (go)

Issue:
Trellis Lima not adding new mounts
### Version

1.11.1

### What did you expect to happen?

After adding a new wordpress site to group_vars and running `trellis provision development` I expected trellis-cli to add the new local_path to lima mount points.

### What actually happens?

`trellis provision development` complains about missing composer.json and fails due to missing mount to local_path.

To solve the issue:
- `trellis vm stop`
- `limactl edit <instance-name>`
- add mountpoint to existing `mounts`list
- save and do not confirm start vm (had port problems doing so)
- `trellis vm start`
- `trellis provision development`

### Steps to reproduce

Add another site to `group_vars/development/wordpress_sites.yml`  in a existing project and run `trellis provision development`

### System info

macOS
trellis-cli
lima

### Log output

_No response_

### Please confirm this isn't a support request.

Yes

Pull request description:
Refactor Lima config management
Fixes #421

Changes how the CLI generates and manages Lima VM configs to make changes like mounted directories be accurately reflected in Lima.

trellis-cli used to generate a config for Lima in
`.trellis/lima/{name}.yml` which was used for the initial creation of the VM. However, even when providing a config to `limactl create`, Lima creates a separate config in its managed directory (`$HOME/.lima/{name}/lima.yaml`).

This meant that any subsequent config changes in Trellis might only be persisted to the CLI's managed config (in `.trellis`), which would do nothing, instead of Lima's config.

For example, if you added a new site to Trellis (or changed the path to an existing one), you'd either have to delete the VM entirely and start over, or manually make the changes to Lima's config.

This PR removes the old CLI managed config entirely and instead updates Lima's config directly (the one in `$HOME/.lima`). This means one less file to manage and a single source of truth which will allow proper VM udpates.

Now if you change any wordpress sites in development, stopping and restarting the VM will apply the new config changes.

Patch:
diff --git a/cmd/vm_start.go b/cmd/vm_start.go
index d694dbf..3c2128e 100644
--- a/cmd/vm_start.go
+++ b/cmd/vm_start.go
@@ -73,13 +73,19 @@ func (c *VmStartCommand) Run(args []string) int {
 		return 1
 	}
 
-	// VM doesn't exist yet, create it
+	// VM doesn't exist yet, create and start it
 	if err = manager.CreateInstance(siteName); err != nil {
 		c.UI.Error("Error creating VM.")
 		c.UI.Error(err.Error())
 		return 1
 	}
 
+	if err = manager.StartInstance(siteName); err != nil {
+		c.UI.Error("Error starting VM.")
+		c.UI.Error(err.Error())
+		return 1
+	}
+
 	c.UI.Info("\nProvisioning VM...")
 
 	provisionCmd := NewProvisionCommand(c.UI, c.Trellis)
diff --git a/pkg/lima/instance.go b/pkg/lima/instance.go
index 48368b9..8b32bcd 100644
--- a/pkg/lima/instance.go
+++ b/pkg/lima/instance.go
@@ -1,10 +1,12 @@
 package lima
 
 import (
+	"bytes"
 	_ "embed"
 	"errors"
 	"fmt"
 	"os"
+	"path/filepath"
 	"regexp"
 	"text/template"
 
@@ -40,7 +42,6 @@ type Config struct {
 }
 
 type Instance struct {
-	ConfigFile    string
 	InventoryFile string
 	Sites         map[string]*trellis.Site
 	Name          string `json:"name"`
@@ -55,16 +56,29 @@ type Instance struct {
 	Username      string `json:"username,omitempty"`
 }
 
-func (i *Instance) CreateConfig() error {
+func (i *Instance) ConfigFile() string {
+	return filepath.Join(i.Dir, "lima.yaml")
+}
+
+func (i *Instance) GenerateConfig() (*bytes.Buffer, error) {
+	var contents bytes.Buffer
+
 	tpl := template.Must(template.New("lima").Parse(ConfigTemplate))
 
-	file, err := os.Create(i.ConfigFile)
-	if err != nil {
-		return fmt.Errorf("%v: %w", ConfigErr, err)
+	if err := tpl.Execute(&contents, i); err != nil {
+		return &contents, fmt.Errorf("%v: %w", ConfigErr, err)
 	}
 
-	err = tpl.Execute(file, i)
+	return &contents, nil
+}
+
+func (i *Instance) UpdateConfig() error {
+	contents, err := i.GenerateConfig()
 	if err != nil {
+		return err
+	}
+
+	if err := os.WriteFile(i.ConfigFile(), contents.Bytes(), 0666); err != nil {
 		return fmt.Errorf("%v: %w", ConfigErr, err)
 	}
 
@@ -91,15 +105,6 @@ func (i *Instance) CreateInventoryFile() error {
 	return nil
 }
 
-func (i *Instance) DeleteConfig() error {
-	err := os.Remove(i.ConfigFile)
-	if err != nil {
-		return fmt.Errorf("Could not delete config file: %v", err)
-	}
-
-	return nil
-}
-
 /*
 Gets the IP address of the instance using the output of `ip route`:
   default via 192.168.64.1 proto dhcp src 192.168.64.2 metric 100
diff --git a/pkg/lima/manager.go b/pkg/lima/manager.go
index b437da4..9d90583 100644
--- a/pkg/lima/manager.go
+++ b/pkg/lima/manager.go
@@ -80,20 +80,18 @@ func (m *Manager) GetInstance(name string) (Instance, bool) {
 func (m *Manager) CreateInstance(name string) error {
 	instance := m.newInstance(name)
 
-	if err := instance.CreateConfig(); err != nil {
-		return err
-	}
-
-	err := command.WithOptions(
+	cmd := command.WithOptions(
 		command.WithTermOutput(),
 		command.WithLogging(m.ui),
-	).Cmd("limactl", []string{"start", "--tty=false", "--name=" + instance.Name, instance.ConfigFile}).Run()
+	).Cmd("limactl", []string{"create", "--tty=false", "--name=" + instance.Name, "-"})
 
+	configContents, err := instance.GenerateConfig()
 	if err != nil {
 		return err
 	}
 
-	return postStart(m, instance)
+	cmd.Stdin = configContents
+	return cmd.Run()
 }
 
 func (m *Manager) DeleteInstance(name string) error {
@@ -114,10 +112,6 @@ func (m *Manager) DeleteInstance(name string) error {
 			return err
 		}
 
-		if err := instance.DeleteConfig(); err != nil {
-			return err
-		}
-
 		return nil
 	} else {
 		return fmt.Errorf("Error: VM is running. Run `trellis vm stop` to stop it.")
@@ -158,6 +152,10 @@ func (m *Manager) StartInstance(name string) error {
 		return nil
 	}
 
+	if err := instance.UpdateConfig(); err != nil {
+		return err
+	}
+
 	err := command.WithOptions(
 		command.WithTermOutput(),
 		command.WithLogging(m.ui),
@@ -167,7 +165,24 @@ func (m *Manager) StartInstance(name string) error {
 		return err
 	}
 
-	return postStart(m, instance)
+	user, err := instance.getUsername()
+	if err != nil {
+		return fmt.Errorf("Could not get username: %v", err)
+	}
+
+	instance.Username = string(user)
+
+	// Hydrate instance with data from limactl that is only available after starting (mainly the forwarded SSH local port)
+	err = m.hydrateInstance(&instance)
+	if err != nil {
+		return err
+	}
+
+	if err = m.addHosts(instance); err != nil {
+		return err
+	}
+
+	return nil
 }
 
 func (m *Manager) StopInstance(name string) error {
@@ -213,7 +228,6 @@ func (m *Manager) hydrateInstance(instance *Instance) error {
 }
 
 func (m *Manager) initInstance(instance *Instance) {
-	instance.ConfigFile = filepath.Join(m.ConfigPath, instance.Name+".yml")
 	instance.InventoryFile = m.InventoryPath()
 	instance.Sites = m.Sites
 }
@@ -281,27 +295,6 @@ func (m *Manager) removeHosts(instance Instance) error {
 	return m.HostsResolver.RemoveHosts(instance.Name)
 }
 
-func postStart(manager *Manager, instance Instance) error {
-	user, err := instance.getUsername()
-	if err != nil {
-		return fmt.Errorf("Could not get username: %v", err)
-	}
-
-	instance.Username = string(user)
-
-	// Hydrate instance with data from limactl that is only available after starting (mainly the forwarded SSH local port)
-	err = manager.hydrateInstance(&instance)
-	if err != nil {
-		return err
-	}
-
-	if err = manager.addHosts(instance); err != nil {
-		return err
-	}
-
-	return nil
-}
-
 func getMacOSVersion() (string, error) {
 	cmd := command.Cmd("sw_vers", []string{"-productVersion"})
 	b, err := cmd.Output()


Test patch:
diff --git a/pkg/lima/instance_test.go b/pkg/lima/instance_test.go
index 8a00dbb..40b5558 100644
--- a/pkg/lima/instance_test.go
+++ b/pkg/lima/instance_test.go
@@ -10,7 +10,7 @@ import (
 	"github.com/roots/trellis-cli/trellis"
 )
 
-func TestCreateConfig(t *testing.T) {
+func TestGenerateConfig(t *testing.T) {
 	defer trellis.LoadFixtureProject(t)()
 	trellis := trellis.NewTrellis()
 	if err := trellis.LoadProject(); err != nil {
@@ -18,11 +18,9 @@ func TestCreateConfig(t *testing.T) {
 	}
 
 	dir := t.TempDir()
-	configFile := filepath.Join(dir, "lima.yaml")
 
 	instance := &Instance{
-		Dir:        dir,
-		ConfigFile: configFile,
+		Dir: dir,
 		Config: Config{
 			Images: []Image{
 				{
@@ -40,12 +38,83 @@ func TestCreateConfig(t *testing.T) {
 		Sites: trellis.Environments["development"].WordPressSites,
 	}
 
-	err := instance.CreateConfig()
+	content, err := instance.GenerateConfig()
 	if err != nil {
 		t.Fatal(err)
 	}
 
-	content, err := os.ReadFile(configFile)
+	absSitePath := filepath.Join(trellis.Path, "../site")
+
+	expected := fmt.Sprintf(`vmType: "vz"
+rosetta:
+  enabled: false
+images:
+- location: http://ubuntu.com/focal
+  arch: aarch64
+
+mounts:
+- location: %s
+  mountPoint: /srv/www/example.com/current
+  writable: true
+
+mountType: "virtiofs"
+ssh:
+  forwardAgent: true
+networks:
+- vzNAT: true
+
+portForwards:
+- guestPort: 80
+  hostPort: 1234
+
+containerd:
+  user: false
+provision:
+- mode: system
+  script: |
+    #!/bin/bash
+    echo "127.0.0.1 $(hostname)" >> /etc/hosts
+`, absSitePath)
+
+	if content.String() != expected {
+		t.Errorf("expected %s\ngot %s", expected, content.String())
+	}
+}
+
+func TestUpdateConfig(t *testing.T) {
+	defer trellis.LoadFixtureProject(t)()
+	trellis := trellis.NewTrellis()
+	if err := trellis.LoadProject(); err != nil {
+		t.Fatal(err)
+	}
+
+	dir := t.TempDir()
+
+	instance := &Instance{
+		Dir: dir,
+		Config: Config{
+			Images: []Image{
+				{
+					Location: "http://ubuntu.com/focal",
+					Arch:     "aarch64",
+				},
+			},
+			PortForwards: []PortForward{
+				{
+					HostPort:  1234,
+					GuestPort: 80,
+				},
+			},
+		},
+		Sites: trellis.Environments["development"].WordPressSites,
+	}
+
+	err := instance.UpdateConfig()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	content, err := os.ReadFile(instance.ConfigFile())
 
 	if err != nil {
 		t.Fatal(err)
diff --git a/pkg/lima/manager_test.go b/pkg/lima/manager_test.go
index 31371d8..db4fc3e 100644
--- a/pkg/lima/manager_test.go
+++ b/pkg/lima/manager_test.go
@@ -202,12 +202,11 @@ func TestCreateInstance(t *testing.T) {
 	sshPort := 60720
 	username := "user1"
 	ip := "192.168.64.2"
-	configFile := filepath.Join(manager.ConfigPath, instanceName+".yml")
 
 	commands := []command.MockCommand{
 		{
 			Command: "limactl",
-			Args:    []string{"start", "--tty=false", "--name=" + instanceName, configFile},
+			Args:    []string{"create", "--tty=false", "--name=" + instanceName, "-"},
 			Output:  ``,
 		},
 		{
@@ -218,7 +217,7 @@ func TestCreateInstance(t *testing.T) {
 		{
 			Command: "limactl",
 			Args:    []string{"ls", "--format=json"},
-			Output:  fmt.Sprintf(`{"name":"%s","status":"Running","dir":"/foo/test","vmType":"vz","arch":"aarch64","cpuType":"","cpus":4,"memory":4294967296,"disk":107374182400,"network":[{"vzNAT":true,"macAddress":"52:55:55:6f:d9:e3","interface":"lima0"}],"sshLocalPort":%d,"hostAgentPID":9390,"driverPID":9390}`, instanceName, sshPort),
+			Output:  fmt.Sprintf(`{"name":"%s","status":"Stopped","dir":"/foo/test","vmType":"vz","arch":"aarch64","cpuType":"","cpus":4,"memory":4294967296,"disk":107374182400,"network":[{"vzNAT":true,"macAddress":"52:55:55:6f:d9:e3","interface":"lima0"}],"sshLocalPort":%d,"hostAgentPID":9390,"driverPID":9390}`, instanceName, sshPort),
 		},
 		{
 			Command: "limactl",
@@ -236,6 +235,79 @@ func TestCreateInstance(t *testing.T) {
 		t.Fatal(err)
 	}
 
+	_, ok := manager.GetInstance(instanceName)
+
+	if !ok {
+		t.Errorf("expected instance to be found")
+	}
+}
+
+func TestStartInstance(t *testing.T) {
+	defer trellis.LoadFixtureProject(t)()
+	trellis := trellis.NewTrellis()
+	if err := trellis.LoadProject(); err != nil {
+		t.Fatal(err)
+	}
+
+	t.Setenv("TRELLIS_BYPASS_LIMA_REQUIREMENTS", "1")
+
+	ui := cli.NewMockUi()
+	manager, err := NewManager(trellis, ui)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	tmpDir := t.TempDir()
+
+	hostsStorage := make(map[string]string)
+	manager.HostsResolver = &MockHostsResolver{Hosts: hostsStorage}
+
+	instanceName := "test"
+	sshPort := 60720
+	username := "user1"
+	ip := "192.168.64.2"
+
+	commands := []command.MockCommand{
+		{
+			Command: "limactl",
+			Args:    []string{"create", "--tty=false", "--name=" + instanceName, "-"},
+			Output:  ``,
+		},
+		{
+			Command: "limactl",
+			Args:    []string{"shell", instanceName, "whoami"},
+			Output:  username,
+		},
+		{
+			Command: "limactl",
+			Args:    []string{"ls", "--format=json"},
+			Output:  fmt.Sprintf(`{"name":"%s","status":"Stopped","dir":"%s","vmType":"vz","arch":"aarch64","cpuType":"","cpus":4,"memory":4294967296,"disk":107374182400,"network":[{"vzNAT":true,"macAddress":"52:55:55:6f:d9:e3","interface":"lima0"}],"sshLocalPort":%d,"hostAgentPID":9390,"driverPID":9390}`, instanceName, tmpDir, sshPort),
+		},
+		{
+			Command: "limactl",
+			Args:    []string{"shell", "--workdir", "/", instanceName, "ip", "route", "show", "dev", "lima0"},
+			Output: fmt.Sprintf(`default via 192.168.64.1 proto dhcp src %s metric 100
+192.168.64.0/24 proto kernel scope link src 192.168.64.2
+192.168.64.1 proto dhcp scope link src 192.168.64.2 metric 100
+`, ip),
+		},
+		{
+			Command: "limactl",
+			Args:    []string{"start", instanceName},
+			Output:  ``,
+		},
+	}
+
+	defer command.MockExecCommands(t, commands)()
+
+	if err = manager.CreateInstance(instanceName); err != nil {
+		t.Fatal(err)
+	}
+
+	if err = manager.StartInstance(instanceName); err != nil {
+		t.Fatal(err)
+	}
+
 	inventoryContents, err := os.ReadFile(manager.InventoryPath())
 	if err != nil {
 		t.Fatal(err)