diff --git a/cli_config/cli_config.go b/cli_config/cli_config.go index 11b9048a..ace80009 100644 --- a/cli_config/cli_config.go +++ b/cli_config/cli_config.go @@ -17,10 +17,10 @@ type VmImage struct { } type VmConfig struct { - Manager string `yaml:"manager"` - HostsResolver string `yaml:"hosts_resolver"` - Images []VmImage `yaml:"images"` - Ubuntu string `yaml:"ubuntu"` + Manager string `yaml:"manager"` + HostsResolver string `yaml:"hosts_resolver"` + Ubuntu string `yaml:"ubuntu"` + InstanceName string `yaml:"instance_name"` // Custom name for the Lima VM instance } type Config struct { diff --git a/cmd/vm_delete.go b/cmd/vm_delete.go index fa00db84..44155212 100644 --- a/cmd/vm_delete.go +++ b/cmd/vm_delete.go @@ -3,6 +3,8 @@ package cmd import ( "flag" "strings" + "os" + "path/filepath" "github.com/manifoldco/promptui" "github.com/mitchellh/cli" @@ -67,7 +69,11 @@ func (c *VmDeleteCommand) Run(args []string) int { if err := manager.DeleteInstance(siteName); err != nil { c.UI.Error("Error: " + err.Error()) return 1 - } + } + + // Remove instance file if it exists + instancePath := filepath.Join(c.Trellis.ConfigPath(), "lima", "instance") + os.Remove(instancePath) // Ignore errors as file may not exist } return 0 diff --git a/cmd/vm_delete_test.go b/cmd/vm_delete_test.go index 581a1b12..974c7b02 100644 --- a/cmd/vm_delete_test.go +++ b/cmd/vm_delete_test.go @@ -1,6 +1,8 @@ package cmd import ( + "os" + "path/filepath" "strings" "testing" @@ -54,3 +56,50 @@ func TestVmDeleteRunValidations(t *testing.T) { }) } } + +func TestVmDeleteRemovesInstanceFile(t *testing.T) { + cleanup := trellis.LoadFixtureProject(t) + defer cleanup() + + // Setup test environment + ui := cli.NewMockUi() + mockTrellis := trellis.NewTrellis() + mockTrellis.LoadProject() + + // Create the lima directory and instance file + limaDir := filepath.Join(mockTrellis.ConfigPath(), "lima") + os.MkdirAll(limaDir, 0755) + instancePath := filepath.Join(limaDir, "instance") + os.WriteFile(instancePath, []byte("example.com"), 0644) + + // Verify file exists before test + if _, err := os.Stat(instancePath); os.IsNotExist(err) { + t.Fatalf("failed to create test instance file") + } + + // Create command + vmDeleteCommand := NewVmDeleteCommand(ui, mockTrellis) + vmDeleteCommand.force = true // Skip confirmation prompt + + // Replace VM manager with mock + originalNewVmManager := newVmManager + mockManager := &MockVmManager{} + newVmManager = func(t *trellis.Trellis, ui cli.Ui) (vm.Manager, error) { + return mockManager, nil + } + defer func() { newVmManager = originalNewVmManager }() + + // Run command + code := vmDeleteCommand.Run([]string{}) + + // Check command succeeded + if code != 0 { + t.Errorf("expected exit code 0, got %d", code) + } + + // Check instance file was removed + _, err := os.Stat(instancePath) + if !os.IsNotExist(err) { + t.Error("expected instance file to be deleted") + } +} diff --git a/cmd/vm_start.go b/cmd/vm_start.go index 3c2128eb..9fb1b7d6 100644 --- a/cmd/vm_start.go +++ b/cmd/vm_start.go @@ -67,7 +67,7 @@ func (c *VmStartCommand) Run(args []string) int { return 0 } - if !errors.Is(err, vm.VmNotFoundErr) { + if (!errors.Is(err, vm.VmNotFoundErr)) { c.UI.Error("Error starting VM.") c.UI.Error(err.Error()) return 1 @@ -80,6 +80,11 @@ func (c *VmStartCommand) Run(args []string) int { return 1 } + // Save the instance name for future reference + if err = c.Trellis.SaveVMInstanceName(siteName); err != nil { + c.UI.Warn("Warning: Failed to save VM instance name. VM was created successfully, but future commands may not recognize it.") + } + if err = manager.StartInstance(siteName); err != nil { c.UI.Error("Error starting VM.") c.UI.Error(err.Error()) diff --git a/cmd/vm_start_test.go b/cmd/vm_start_test.go index 143ac68e..087a9d3a 100644 --- a/cmd/vm_start_test.go +++ b/cmd/vm_start_test.go @@ -1,10 +1,13 @@ package cmd import ( + "os" + "path/filepath" "strings" "testing" "github.com/mitchellh/cli" + "github.com/roots/trellis-cli/pkg/vm" "github.com/roots/trellis-cli/trellis" ) @@ -54,3 +57,92 @@ func TestVmStartRunValidations(t *testing.T) { }) } } + +// MockVmManager for testing +type MockVmManager struct { + createCalled bool + startCalled bool + siteName string +} + +func (m *MockVmManager) CreateInstance(name string) error { + m.createCalled = true + m.siteName = name + return nil +} + +func (m *MockVmManager) StartInstance(name string) error { + m.startCalled = true + m.siteName = name + // First call returns VmNotFoundErr to trigger creation + if !m.createCalled { + return vm.VmNotFoundErr + } + return nil +} + +func (m *MockVmManager) StopInstance(name string) error { + return nil +} + +func (m *MockVmManager) DeleteInstance(name string) error { + return nil +} + +func TestVmStartSavesInstanceName(t *testing.T) { + cleanup := trellis.LoadFixtureProject(t) + defer cleanup() + + // Setup test environment + ui := cli.NewMockUi() + mockTrellis := trellis.NewTrellis() + mockTrellis.LoadProject() + + // Create command + vmStartCommand := NewVmStartCommand(ui, mockTrellis) + + // Replace VM manager with mock + originalNewVmManager := newVmManager + mockManager := &MockVmManager{} + newVmManager = func(t *trellis.Trellis, ui cli.Ui) (vm.Manager, error) { + return mockManager, nil + } + defer func() { newVmManager = originalNewVmManager }() + + // Mock provision command to return success + originalNewProvisionCommand := NewProvisionCommand + NewProvisionCommand = func(ui cli.Ui, trellis *trellis.Trellis) *ProvisionCommand { + cmd := &ProvisionCommand{UI: ui, Trellis: trellis} + cmd.Run = func(args []string) int { + return 0 + } + return cmd + } + defer func() { NewProvisionCommand = originalNewProvisionCommand }() + + // Run command + code := vmStartCommand.Run([]string{}) + + // Check VM was created and started + if code != 0 { + t.Errorf("expected exit code 0, got %d", code) + } + if !mockManager.createCalled { + t.Error("expected CreateInstance to be called") + } + if !mockManager.startCalled { + t.Error("expected StartInstance to be called") + } + + // Check instance file was created + instancePath := filepath.Join(mockTrellis.ConfigPath(), "lima", "instance") + data, err := os.ReadFile(instancePath) + if err != nil { + t.Errorf("expected instance file to exist: %v", err) + } + + instanceName := strings.TrimSpace(string(data)) + if instanceName != mockManager.siteName { + t.Errorf("expected instance name %q, got %q", mockManager.siteName, instanceName) + } +} diff --git a/trellis/trellis.go b/trellis/trellis.go index 83e229cc..eaf5cebb 100644 --- a/trellis/trellis.go +++ b/trellis/trellis.go @@ -263,16 +263,28 @@ func (t *Trellis) FindSiteNameFromEnvironment(environment string, siteNameArg st return "", fmt.Errorf("Error: %s is not a valid site. Valid options are %s", siteNameArg, siteNames) } -func (t *Trellis) MainSiteFromEnvironment(environment string) (string, *Site, error) { - sites := t.SiteNamesFromEnvironment(environment) - - if len(sites) == 0 { - return "", nil, fmt.Errorf("Error: No sites found in %s environment", environment) +func (t *Trellis) MainSiteFromEnvironment(env string) (string, *Site, error) { + if _, ok := t.Environments[env]; !ok { + return "", nil, fmt.Errorf("environment %s not found", env) } - name := sites[0] - - return name, t.Environments[environment].WordPressSites[name], nil + // Get the instance name using our new priority system + siteName, err := t.GetVMInstanceName() + if err != nil || siteName == "" { + // Fall back to using the first site name if there's an error or empty result + siteNames := t.SiteNamesFromEnvironment(env) + if len(siteNames) == 0 { + return "", nil, fmt.Errorf("no sites found in environment %s", env) + } + siteName = siteNames[0] + } + + site, ok := t.Environments[env].WordPressSites[siteName] + if !ok { + return "", nil, fmt.Errorf("site %s not found in environment %s", siteName, env) + } + + return siteName, site, nil } func (t *Trellis) getDefaultSiteNameFromEnvironment(environment string) (siteName string, err error) { diff --git a/trellis/trellis_test.go b/trellis/trellis_test.go index b51a62e7..2dd8f96c 100644 --- a/trellis/trellis_test.go +++ b/trellis/trellis_test.go @@ -444,3 +444,86 @@ ask_vault_pass: true t.Errorf("expected load project to load project CLI config file") } } + +func TestGetVMInstanceName(t *testing.T) { + defer LoadFixtureProject(t)() + + tp := NewTrellis() + err := tp.LoadProject() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Test case 1: No instance file and no config setting - should use first site + instanceName, err := tp.GetVMInstanceName() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expectedName := tp.SiteNamesFromEnvironment("development")[0] + if instanceName != expectedName { + t.Errorf("expected instance name %q, got %q", expectedName, instanceName) + } + + // Test case 2: With config setting - should use config value + tp.CliConfig.Vm.InstanceName = "configured-name" + instanceName, err = tp.GetVMInstanceName() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if instanceName != "configured-name" { + t.Errorf("expected instance name %q, got %q", "configured-name", instanceName) + } + + // Test case 3: With instance file - should use file value (highest priority) + // Create the instance file + limaDir := filepath.Join(tp.ConfigPath(), "lima") + if err := os.MkdirAll(limaDir, 0755); err != nil { + t.Fatalf("failed to create lima directory: %v", err) + } + instancePath := filepath.Join(limaDir, "instance") + if err := os.WriteFile(instancePath, []byte("file-instance-name"), 0644); err != nil { + t.Fatalf("failed to write instance file: %v", err) + } + + instanceName, err = tp.GetVMInstanceName() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if instanceName != "file-instance-name" { + t.Errorf("expected instance name %q, got %q", "file-instance-name", instanceName) + } + + // Clean up + os.Remove(instancePath) + tp.CliConfig.Vm.InstanceName = "" +} + +func TestSaveVMInstanceName(t *testing.T) { + defer LoadFixtureProject(t)() + + tp := NewTrellis() + err := tp.LoadProject() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Test saving the instance name + err = tp.SaveVMInstanceName("test-instance") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify file was created and contains correct content + instancePath := filepath.Join(tp.ConfigPath(), "lima", "instance") + data, err := os.ReadFile(instancePath) + if err != nil { + t.Fatalf("failed to read instance file: %v", err) + } + + if string(data) != "test-instance" { + t.Errorf("expected instance file to contain %q, got %q", "test-instance", string(data)) + } + + // Clean up + os.Remove(instancePath) +} diff --git a/trellis/vm_instance.go b/trellis/vm_instance.go new file mode 100644 index 00000000..bb6f9663 --- /dev/null +++ b/trellis/vm_instance.go @@ -0,0 +1,68 @@ +package trellis + +import ( + "os" + "path/filepath" + "strings" +) + +const ( + LimaDirName = "lima" + InstanceFile = "instance" +) + +// GetVMInstanceName returns the VM instance name based on the following priority: +// 1. Instance file in .trellis/lima/instance +// 2. CliConfig instance_name setting +// 3. First site in development environment's wordpress_sites.yml +func (t *Trellis) GetVMInstanceName() (string, error) { + // 1. Check for instance file + instanceName, err := t.readInstanceNameFromFile() + if err == nil && instanceName != "" { + return instanceName, nil + } + + // 2. Check CLI config for instance_name + if t.CliConfig.Vm.InstanceName != "" { + return t.CliConfig.Vm.InstanceName, nil + } + + // 3. Simply use the first site in the development environment + config := t.Environments["development"] + if config == nil || len(config.WordPressSites) == 0 { + return "", nil + } + + // Get the first site name alphabetically (which is the default behavior) + siteNames := t.SiteNamesFromEnvironment("development") + if len(siteNames) > 0 { + return siteNames[0], nil + } + + return "", nil +} + +// SaveVMInstanceName writes the VM instance name to the instance file +func (t *Trellis) SaveVMInstanceName(instanceName string) error { + limaDir := filepath.Join(t.ConfigPath(), LimaDirName) + + // Create the lima directory if it doesn't exist + if err := os.MkdirAll(limaDir, 0755); err != nil && !os.IsExist(err) { + return err + } + + instancePath := filepath.Join(limaDir, InstanceFile) + return os.WriteFile(instancePath, []byte(instanceName), 0644) +} + +// readInstanceNameFromFile reads the VM instance name from the instance file +func (t *Trellis) readInstanceNameFromFile() (string, error) { + instancePath := filepath.Join(t.ConfigPath(), LimaDirName, InstanceFile) + + data, err := os.ReadFile(instancePath) + if err != nil { + return "", err + } + + return strings.TrimSpace(string(data)), nil +} diff --git a/trellis/vm_instance_test.go b/trellis/vm_instance_test.go new file mode 100644 index 00000000..25039a2b --- /dev/null +++ b/trellis/vm_instance_test.go @@ -0,0 +1,181 @@ +package trellis + +import ( + "os" + "path/filepath" + "testing" +) + +func TestGetVMInstanceName(t *testing.T) { + tempDir := t.TempDir() + defer TestChdir(t, tempDir)() + + // Create a mock Trellis structure + tp := &Trellis{ + ConfigDir: ".trellis", + Path: tempDir, + Environments: map[string]*Config{ + "development": { + WordPressSites: map[string]*Site{ + "example.com": {}, + "another-site.com": {}, + }, + }, + }, + } + + // Create config directory + if err := tp.CreateConfigDir(); err != nil { + t.Fatalf("Failed to create config directory: %v", err) + } + + // Test case 1: No instance file, no config setting + // Should return the first site alphabetically (another-site.com) + name, err := tp.GetVMInstanceName() + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if name != "another-site.com" { + t.Errorf("Expected 'another-site.com', got '%s'", name) + } + + // Test case 2: With config setting + tp.CliConfig.Vm.InstanceName = "custom-name" + name, err = tp.GetVMInstanceName() + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if name != "custom-name" { + t.Errorf("Expected 'custom-name', got '%s'", name) + } + + // Test case 3: With instance file (highest priority) + limaDir := filepath.Join(tp.ConfigPath(), LimaDirName) + if err := os.MkdirAll(limaDir, 0755); err != nil { + t.Fatalf("Failed to create lima directory: %v", err) + } + instancePath := filepath.Join(limaDir, InstanceFile) + if err := os.WriteFile(instancePath, []byte("instance-file-name"), 0644); err != nil { + t.Fatalf("Failed to write instance file: %v", err) + } + + name, err = tp.GetVMInstanceName() + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if name != "instance-file-name" { + t.Errorf("Expected 'instance-file-name', got '%s'", name) + } + + // Clean up + tp.CliConfig.Vm.InstanceName = "" +} + +func TestSaveVMInstanceName(t *testing.T) { + tempDir := t.TempDir() + defer TestChdir(t, tempDir)() + + // Create a mock Trellis structure + tp := &Trellis{ + ConfigDir: ".trellis", + Path: tempDir, + } + + // Create config directory + if err := tp.CreateConfigDir(); err != nil { + t.Fatalf("Failed to create config directory: %v", err) + } + + // Save instance name + instanceName := "test-vm-instance" + if err := tp.SaveVMInstanceName(instanceName); err != nil { + t.Fatalf("Failed to save instance name: %v", err) + } + + // Verify file was created + instancePath := filepath.Join(tp.ConfigPath(), LimaDirName, InstanceFile) + data, err := os.ReadFile(instancePath) + if err != nil { + t.Fatalf("Failed to read instance file: %v", err) + } + + // Verify content + if string(data) != instanceName { + t.Errorf("Expected '%s', got '%s'", instanceName, string(data)) + } + + // Test updating existing file + newInstanceName := "updated-name" + if err := tp.SaveVMInstanceName(newInstanceName); err != nil { + t.Fatalf("Failed to update instance name: %v", err) + } + + // Verify update + data, err = os.ReadFile(instancePath) + if err != nil { + t.Fatalf("Failed to read instance file: %v", err) + } + + if string(data) != newInstanceName { + t.Errorf("Expected '%s', got '%s'", newInstanceName, string(data)) + } +} + +func TestReadInstanceNameFromFile(t *testing.T) { + tempDir := t.TempDir() + defer TestChdir(t, tempDir)() + + // Create a mock Trellis structure + tp := &Trellis{ + ConfigDir: ".trellis", + Path: tempDir, + } + + // Create config directory + if err := tp.CreateConfigDir(); err != nil { + t.Fatalf("Failed to create config directory: %v", err) + } + + // Test reading non-existent file + name, err := tp.readInstanceNameFromFile() + if err == nil { + t.Error("Expected error when reading non-existent file") + } + if name != "" { + t.Errorf("Expected empty string, got '%s'", name) + } + + // Create instance file + limaDir := filepath.Join(tp.ConfigPath(), LimaDirName) + if err := os.MkdirAll(limaDir, 0755); err != nil { + t.Fatalf("Failed to create lima directory: %v", err) + } + instancePath := filepath.Join(limaDir, InstanceFile) + expectedName := "instance-file-name" + if err := os.WriteFile(instancePath, []byte(expectedName), 0644); err != nil { + t.Fatalf("Failed to write instance file: %v", err) + } + + // Test reading existing file + name, err = tp.readInstanceNameFromFile() + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if name != expectedName { + t.Errorf("Expected '%s', got '%s'", expectedName, name) + } + + // Test with trailing whitespace + expectedName = "trimmed-name" + if err := os.WriteFile(instancePath, []byte(expectedName+"\n"), 0644); err != nil { + t.Fatalf("Failed to write instance file: %v", err) + } + + name, err = tp.readInstanceNameFromFile() + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if name != expectedName { + t.Errorf("Expected '%s', got '%s'", expectedName, name) + } +}