Skip to content

Commit

Permalink
Add VM integration
Browse files Browse the repository at this point in the history
Adds full integration for managing virtual machines in development via https://github.com/lima-vm/lima as an alternative to Vagrant. Lima stands for "Linux virtual machines (on macOS, in most cases)" and that's exactly our use case.

Lima isn't just a replacement for Vagrant, it also replaces the VM provider like VirtualBox or Parallels too. Lima supports two ways of running guest machines:

* [QEMU](https://www.qemu.org/)
* macOS Virtualization.Framework ("vz")

For this initial integration, we're only supporting the macOS Virtualization.Framework because it offers near-native performance, and that includes file syncing via `virtiofs`.

For macOS Ventura (13.0+) users, we'll eventually recommend using trellis-cli's VM feature over Vagrant as the default since it's easier and faster. We'll look into expanding support for Linux users as well depending on performance of the file mounts on the VM.

Requirements:
* Intel or Apple Silicon
* macOS 13 (Ventura)
* Lima >= 0.14

Usage:
There's 5 new commands:

* `trellis vm start`
* `trellis vm stop`
* `trellis vm delete`
* `trellis vm shell`
* `trellis vm sudoers`

Under the hood, those commands wrap equivalent `limactl` features. Just like the previous Vagrant integration, you can always run `limactl` directly to manage your VMs.

For default use cases, `trellis vm start` can be run without any customization first. It will create a new virtual machine (using Lima) from a generated config file (`project/trellis/.trellis/lima/config/<name>.yml`). The site's `local_path` will be automatically mounted on the VM and your `/etc/hosts` file will be updated.

Note: run `trellis vm sudoers -h` to make `/etc/hosts` file passwordless:
```bash
$ trellis sudoers | sudo tee /etc/sudoers.d/trellis
```

Configuration:
Right now configuration options for the VM are limited. The intention is to the most common use cases without any configuration needed.

A trellis-cli config file (global or project level) supports a new `vm` option. The only useful config option right now is `images` (to switch between Ubuntu 20.04 and 22.04 for example). 20.04.

Here's an example of specifying Jammy 22.04 for ARM (`aarch64`) only, since that's what I use):

```yml
vm:
  images:
    - location: https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-arm64.img
      arch: aarch64
```
  • Loading branch information
swalkinshaw committed Mar 3, 2023
1 parent d02471d commit d3894d6
Show file tree
Hide file tree
Showing 36 changed files with 2,550 additions and 27 deletions.
28 changes: 26 additions & 2 deletions app_paths/app_paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ const (
xdgDataHome = "XDG_DATA_HOME"
)

// Config path precedence: TRELLIS_CONFIG_DIR, XDG_CONFIG_HOME, AppData (windows only), HOME.
// Config path precedence:
// 1. TRELLIS_CONFIG_DIR
// 2. XDG_CONFIG_HOME
// 3. AppData (windows only)
// 4. HOME
func ConfigDir() string {
var path string

Expand All @@ -37,7 +41,10 @@ func ConfigPath(path string) string {
return filepath.Join(ConfigDir(), path)
}

// Cache path precedence: XDG_CACHE_HOME, LocalAppData (windows only), HOME.
// Cache path precedence:
// 1. XDG_CACHE_HOME
// 2. LocalAppData (windows only)
// 3. HOME
func CacheDir() string {
var path string
if a := os.Getenv(xdgCacheHome); a != "" {
Expand All @@ -50,3 +57,20 @@ func CacheDir() string {
}
return path
}

// Data path precedence:
// 1. XDG_DATA_HOME
// 2. LocalAppData (windows only)
// 3. HOME
func DataDir() string {
var path string
if a := os.Getenv(xdgDataHome); a != "" {
path = filepath.Join(a, "trellis")
} else if b := os.Getenv(localAppData); runtime.GOOS == "windows" && b != "" {
path = filepath.Join(b, "Trellis CLI")
} else {
c, _ := os.UserHomeDir()
path = filepath.Join(c, ".local", "share", "trellis")
}
return path
}
40 changes: 33 additions & 7 deletions cli_config/cli_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,32 @@ import (
"gopkg.in/yaml.v2"
)

type VmImage struct {
Location string `yaml:"location"`
Arch string `yaml:"arch"`
}

type VmConfig struct {
Manager string `yaml:"manager"`
HostsResolver string `yaml:"hosts_resolver"`
Images []VmImage `yaml:"images"`
Ubuntu string `yaml:"ubuntu"`
}

type Config struct {
AllowDevelopmentDeploys bool `yaml:"allow_development_deploys"`
AskVaultPass bool `yaml:"ask_vault_pass"`
CheckForUpdates bool `yaml:"check_for_updates"`
LoadPlugins bool `yaml:"load_plugins"`
Open map[string]string `yaml:"open"`
VirtualenvIntegration bool `yaml:"virtualenv_integration"`
Vm VmConfig `yaml:"vm"`
}

var (
ErrUnsupportedType = errors.New("Invalid env var config setting: value is an unsupported type.")
ErrCouldNotParse = errors.New("Invalid env var config setting: failed to parse value")
UnsupportedTypeErr = errors.New("Invalid env var config setting: value is an unsupported type.")
CouldNotParseErr = errors.New("Invalid env var config setting: failed to parse value")
InvalidConfigErr = errors.New("Invalid config file")
)

func NewConfig(defaultConfig Config) Config {
Expand All @@ -37,7 +51,19 @@ func (c *Config) LoadFile(path string) error {
}

if err := yaml.Unmarshal(configYaml, &c); err != nil {
return err
return fmt.Errorf("%w: %s", InvalidConfigErr, err)
}

if c.Vm.Manager != "lima" && c.Vm.Manager != "auto" && c.Vm.Manager != "mock" {
return fmt.Errorf("%w: unsupported value for `vm.manager`. Must be one of: auto, lima", InvalidConfigErr)
}

if c.Vm.Ubuntu != "18.04" && c.Vm.Ubuntu != "20.04" && c.Vm.Ubuntu != "22.04" {
return fmt.Errorf("%w: unsupported value for `vm.ubuntu`. Must be one of: 18.04, 20.04, 22.04", InvalidConfigErr)
}

if c.Vm.HostsResolver != "hosts_file" {
return fmt.Errorf("%w: unsupported value for `vm.hosts_resolver`. Must be one of: hosts_file", InvalidConfigErr)
}

return nil
Expand Down Expand Up @@ -72,27 +98,27 @@ func (c *Config) LoadEnv(prefix string) error {
val, err := strconv.ParseBool(value)

if err != nil {
return fmt.Errorf("%w '%s'\n'%s' can't be parsed as a boolean", ErrCouldNotParse, env, value)
return fmt.Errorf("%w '%s'\n'%s' can't be parsed as a boolean", CouldNotParseErr, env, value)
}

structValue.SetBool(val)
case reflect.Int:
val, err := strconv.ParseInt(value, 10, 32)

if err != nil {
return fmt.Errorf("%w '%s'\n'%s' can't be parsed as an integer", ErrCouldNotParse, env, value)
return fmt.Errorf("%w '%s'\n'%s' can't be parsed as an integer", CouldNotParseErr, env, value)
}

structValue.SetInt(val)
case reflect.Float32:
val, err := strconv.ParseFloat(value, 32)
if err != nil {
return fmt.Errorf("%w '%s'\n'%s' can't be parsed as a float", ErrCouldNotParse, env, value)
return fmt.Errorf("%w '%s'\n'%s' can't be parsed as a float", CouldNotParseErr, env, value)
}

structValue.SetFloat(val)
default:
return fmt.Errorf("%w\n%s setting of type %s is unsupported.", ErrUnsupportedType, env, field.Type.String())
return fmt.Errorf("%w\n%s setting of type %s is unsupported.", UnsupportedTypeErr, env, field.Type.String())
}
}
}
Expand Down
22 changes: 20 additions & 2 deletions cmd/provision.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,12 @@ func (c *ProvisionCommand) Run(args []string) int {
var playbookFile string = "server.yml"

if environment == "development" {
os.Setenv("ANSIBLE_HOST_KEY_CHECKING", "false")
playbookFile = "dev.yml"
devInventoryFile := c.findDevInventory()

if _, err := os.Stat(filepath.Join(c.Trellis.Path, VagrantInventoryFilePath)); err == nil {
playbookArgs = append(playbookArgs, "--inventory-file", VagrantInventoryFilePath)
if devInventoryFile != "" {
playbookArgs = append(playbookArgs, "--inventory-file", devInventoryFile)
}
}

Expand Down Expand Up @@ -160,3 +162,19 @@ func (c *ProvisionCommand) AutocompleteFlags() complete.Flags {
"--verbose": complete.PredictNothing,
}
}

func (c *ProvisionCommand) findDevInventory() string {
manager, managerErr := newVmManager(c.Trellis, c.UI)
if managerErr == nil {
_, vmInventoryErr := os.Stat(manager.InventoryPath())
if vmInventoryErr == nil {
return manager.InventoryPath()
}
}

if _, vagrantInventoryErr := os.Stat(filepath.Join(c.Trellis.Path, VagrantInventoryFilePath)); vagrantInventoryErr == nil {
return VagrantInventoryFilePath
}

return ""
}
1 change: 1 addition & 0 deletions cmd/provision_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ func TestProvisionRunValidations(t *testing.T) {
func TestProvisionRun(t *testing.T) {
defer trellis.LoadFixtureProject(t)()
trellis := trellis.NewTrellis()
trellis.CliConfig.Vm.Manager = "mock"

cases := []struct {
name string
Expand Down
29 changes: 29 additions & 0 deletions cmd/vm.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package cmd

import (
"fmt"
"runtime"

"github.com/mitchellh/cli"
"github.com/roots/trellis-cli/pkg/lima"
"github.com/roots/trellis-cli/pkg/vm"
"github.com/roots/trellis-cli/trellis"
)

func newVmManager(trellis *trellis.Trellis, ui cli.Ui) (manager vm.Manager, err error) {
switch trellis.CliConfig.Vm.Manager {
case "auto":
switch runtime.GOOS {
case "darwin":
return lima.NewManager(trellis, ui)
default:
return nil, fmt.Errorf("No VM managers are supported on %s yet.", runtime.GOOS)
}
case "lima":
return lima.NewManager(trellis, ui)
case "mock":
return vm.NewMockManager(trellis, ui)
}

return nil, fmt.Errorf("VM manager not found")
}
118 changes: 118 additions & 0 deletions cmd/vm_delete.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package cmd

import (
"flag"
"strings"

"github.com/manifoldco/promptui"
"github.com/mitchellh/cli"
"github.com/posener/complete"
"github.com/roots/trellis-cli/trellis"
)

type VmDeleteCommand struct {
UI cli.Ui
Trellis *trellis.Trellis
flags *flag.FlagSet
force bool
}

func NewVmDeleteCommand(ui cli.Ui, trellis *trellis.Trellis) *VmDeleteCommand {
c := &VmDeleteCommand{UI: ui, Trellis: trellis}
c.init()
return c
}

func (c *VmDeleteCommand) init() {
c.flags = flag.NewFlagSet("", flag.ContinueOnError)
c.flags.Usage = func() { c.UI.Info(c.Help()) }
c.flags.BoolVar(&c.force, "force", false, "Delete VM without confirmation.")
}

func (c *VmDeleteCommand) Run(args []string) int {
if err := c.Trellis.LoadProject(); err != nil {
c.UI.Error(err.Error())
return 1
}

c.Trellis.CheckVirtualenv(c.UI)

if err := c.flags.Parse(args); err != nil {
return 1
}

args = c.flags.Args()

commandArgumentValidator := &CommandArgumentValidator{required: 0, optional: 0}
commandArgumentErr := commandArgumentValidator.validate(args)
if commandArgumentErr != nil {
c.UI.Error(commandArgumentErr.Error())
c.UI.Output(c.Help())
return 1
}

siteName, err := c.Trellis.FindSiteNameFromEnvironment("development", "")
if err != nil {
c.UI.Error(err.Error())
return 1
}

manager, err := newVmManager(c.Trellis, c.UI)
if err != nil {
c.UI.Error("Error: " + err.Error())
return 1
}

if c.force || c.confirmDeletion() {
if err := manager.DeleteInstance(siteName); err != nil {
c.UI.Error("Error: " + err.Error())
return 1
}
}

return 0
}

func (c *VmDeleteCommand) Synopsis() string {
return "Deletes the development virtual machine."
}

func (c *VmDeleteCommand) Help() string {
helpText := `
Usage: trellis vm delete [options]
Deletes the development virtual machine.
VMs must be in a stopped state before they can be deleted.
Delete without prompting for confirmation:
$ trellis vm delete --force
Options:
--force Delete VM without confirmation.
-h, --help Show this help
`

return strings.TrimSpace(helpText)
}

func (c *VmDeleteCommand) AutocompleteFlags() complete.Flags {
return complete.Flags{
"--force": complete.PredictNothing,
}
}

func (c *VmDeleteCommand) confirmDeletion() bool {
prompt := promptui.Prompt{
Label: "Delete virtual machine",
IsConfirm: true,
}

_, err := prompt.Run()

if err != nil {
c.UI.Info("Aborted. Not deleting virtual machine.")
return false
}

return true
}
56 changes: 56 additions & 0 deletions cmd/vm_delete_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package cmd

import (
"strings"
"testing"

"github.com/mitchellh/cli"
"github.com/roots/trellis-cli/trellis"
)

func TestVmDeleteRunValidations(t *testing.T) {
defer trellis.LoadFixtureProject(t)()

cases := []struct {
name string
projectDetected bool
args []string
out string
code int
}{
{
"no_project",
false,
nil,
"No Trellis project detected",
1,
},
{
"too_many_args",
true,
[]string{"foo"},
"Error: too many arguments",
1,
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ui := cli.NewMockUi()
trellis := trellis.NewMockTrellis(tc.projectDetected)
vmDeleteCommand := NewVmDeleteCommand(ui, trellis)

code := vmDeleteCommand.Run(tc.args)

if code != tc.code {
t.Errorf("expected code %d to be %d", code, tc.code)
}

combined := ui.OutputWriter.String() + ui.ErrorWriter.String()

if !strings.Contains(combined, tc.out) {
t.Errorf("expected output %q to contain %q", combined, tc.out)
}
})
}
}

0 comments on commit d3894d6

Please sign in to comment.