From e16e9ec5144818409c5e8649fddb86ad42783f6a Mon Sep 17 00:00:00 2001 From: Scott Walkinshaw Date: Sat, 25 Oct 2025 10:22:13 -0400 Subject: [PATCH] Add LXD integration Adds support for LXD for VM management on Linux. --- cli_config/cli_config.go | 2 +- cmd/vm.go | 5 + pkg/lxd/files/config.yml | 20 ++ pkg/lxd/files/inventory.txt | 7 + pkg/lxd/instance.go | 109 +++++++++++ pkg/lxd/instance_test.go | 78 ++++++++ pkg/lxd/lxd.go | 36 ++++ pkg/lxd/manager.go | 368 ++++++++++++++++++++++++++++++++++++ pkg/lxd/manager_test.go | 96 ++++++++++ 9 files changed, 720 insertions(+), 1 deletion(-) create mode 100644 pkg/lxd/files/config.yml create mode 100644 pkg/lxd/files/inventory.txt create mode 100644 pkg/lxd/instance.go create mode 100644 pkg/lxd/instance_test.go create mode 100644 pkg/lxd/lxd.go create mode 100644 pkg/lxd/manager.go create mode 100644 pkg/lxd/manager_test.go diff --git a/cli_config/cli_config.go b/cli_config/cli_config.go index a2c3c7af..ca35688b 100644 --- a/cli_config/cli_config.go +++ b/cli_config/cli_config.go @@ -57,7 +57,7 @@ func (c *Config) LoadFile(path string) error { return fmt.Errorf("%w: %s", InvalidConfigErr, err) } - if c.Vm.Manager != "" && c.Vm.Manager != "lima" && c.Vm.Manager != "auto" && c.Vm.Manager != "mock" { + if c.Vm.Manager != "" && c.Vm.Manager != "lima" && c.Vm.Manager != "auto" && c.Vm.Manager != "mock" && c.Vm.Manager != "lxd" { return fmt.Errorf("%w: unsupported value for `vm.manager`. Must be one of: auto, lima", InvalidConfigErr) } diff --git a/cmd/vm.go b/cmd/vm.go index 7875d7ef..6454c60f 100644 --- a/cmd/vm.go +++ b/cmd/vm.go @@ -7,6 +7,7 @@ import ( "github.com/hashicorp/cli" "github.com/roots/trellis-cli/pkg/lima" + "github.com/roots/trellis-cli/pkg/lxd" "github.com/roots/trellis-cli/pkg/vm" "github.com/roots/trellis-cli/trellis" ) @@ -17,11 +18,15 @@ func newVmManager(trellis *trellis.Trellis, ui cli.Ui) (manager vm.Manager, err switch runtime.GOOS { case "darwin": return lima.NewManager(trellis, ui) + case "linux": + return lxd.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 "lxd": + return lxd.NewManager(trellis, ui) case "mock": return vm.NewMockManager(trellis, ui) } diff --git a/pkg/lxd/files/config.yml b/pkg/lxd/files/config.yml new file mode 100644 index 00000000..6c63e833 --- /dev/null +++ b/pkg/lxd/files/config.yml @@ -0,0 +1,20 @@ +config: + raw.idmap: | + uid {{ .Uid }} 1000 + gid {{ .Gid }} 1000 + user.vendor-data: | + #cloud-config + manage_etc_hosts: localhost + users: + - name: {{ .Username }} + groups: sudo + sudo: ['ALL=(ALL) NOPASSWD:ALL'] + ssh_authorized_keys: + - {{ .SshPublicKey }} +devices: +{{ range $name, $device := .Devices }} + {{ $name }}: + type: disk + source: {{ $device.Source }} + path: {{ $device.Dest }} +{{ end }} diff --git a/pkg/lxd/files/inventory.txt b/pkg/lxd/files/inventory.txt new file mode 100644 index 00000000..efa461f0 --- /dev/null +++ b/pkg/lxd/files/inventory.txt @@ -0,0 +1,7 @@ +default ansible_host={{ .IP }} ansible_user={{ .Username }} ansible_ssh_common_args='-o StrictHostKeyChecking=no' + +[development] +default + +[web] +default diff --git a/pkg/lxd/instance.go b/pkg/lxd/instance.go new file mode 100644 index 00000000..711980ec --- /dev/null +++ b/pkg/lxd/instance.go @@ -0,0 +1,109 @@ +package lxd + +import ( + _ "embed" + "errors" + "fmt" + "os" + "text/template" + + "github.com/roots/trellis-cli/trellis" +) + +//go:embed files/config.yml +var ConfigTemplate string + +//go:embed files/inventory.txt +var inventoryTemplate string + +var ( + ConfigErr = errors.New("Could not write LXD config file") + IpErr = errors.New("Could not determine IP address for VM") +) + +type Device struct { + Source string + Dest string +} + +type NetworkAddress struct { + Family string `json:"family"` + Address string `json:"address"` +} + +type Network struct { + Addresses []NetworkAddress `json:"addresses"` +} + +type State struct { + Status string `json:"status"` + Network map[string]Network `json:"network"` +} + +type Instance struct { + ConfigFile string + InventoryFile string + Sites map[string]*trellis.Site + Name string `json:"name"` + State State `json:"state"` + Username string `json:"username,omitempty"` + Uid int + Gid int + SshPublicKey string + Devices map[string]Device +} + +func (i *Instance) CreateConfig() error { + tpl := template.Must(template.New("lxc").Parse(ConfigTemplate)) + + file, err := os.Create(i.ConfigFile) + if err != nil { + return fmt.Errorf("%v: %w", ConfigErr, err) + } + + err = tpl.Execute(file, i) + if err != nil { + return fmt.Errorf("%v: %w", ConfigErr, err) + } + + return nil +} + +func (i *Instance) CreateInventoryFile() error { + tpl := template.Must(template.New("lxd").Parse(inventoryTemplate)) + + file, err := os.Create(i.InventoryFile) + if err != nil { + return fmt.Errorf("Could not create Ansible inventory file: %v", err) + } + + err = tpl.Execute(file, i) + if err != nil { + return fmt.Errorf("Could not template Ansible inventory file: %v", err) + } + + return nil +} + +func (i *Instance) IP() (ip string, err error) { + network, ok := i.State.Network["eth0"] + if !ok { + return "", fmt.Errorf("%v: eth0 network not found", IpErr) + } + + for _, address := range network.Addresses { + if address.Family == "inet" && address.Address != "" { + return address.Address, nil + } + } + + return "", fmt.Errorf("%v: inet address family not found", IpErr) +} + +func (i *Instance) Running() bool { + return i.State.Status == "Running" +} + +func (i *Instance) Stopped() bool { + return i.State.Status == "Stopped" +} diff --git a/pkg/lxd/instance_test.go b/pkg/lxd/instance_test.go new file mode 100644 index 00000000..1084f7f3 --- /dev/null +++ b/pkg/lxd/instance_test.go @@ -0,0 +1,78 @@ +package lxd + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/roots/trellis-cli/command" +) + +func TestCreateInventoryFile(t *testing.T) { + dir := t.TempDir() + + expectedIP := "1.2.3.4" + + instance := &Instance{ + InventoryFile: filepath.Join(dir, "inventory"), + Username: "dev", + State: State{ + Network: map[string]Network{ + "eth0": { + Addresses: []NetworkAddress{{Address: expectedIP, Family: "inet"}}}, + }, + }, + } + + err := instance.CreateInventoryFile() + if err != nil { + t.Fatal(err) + } + + content, err := os.ReadFile(instance.InventoryFile) + + if err != nil { + t.Fatal(err) + } + + expected := fmt.Sprintf(`default ansible_host=%s ansible_user=dev ansible_ssh_common_args='-o StrictHostKeyChecking=no' + +[development] +default + +[web] +default +`, expectedIP) + + if string(content) != expected { + t.Errorf("expected %s\ngot %s", expected, string(content)) + } +} + +func TestIP(t *testing.T) { + expectedIP := "10.99.30.5" + + instance := &Instance{ + Name: "test", + State: State{ + Network: map[string]Network{ + "eth0": { + Addresses: []NetworkAddress{{Address: expectedIP, Family: "inet"}}}, + }, + }, + } + + ip, err := instance.IP() + if err != nil { + t.Fatal(err) + } + + if ip != expectedIP { + t.Errorf("expected %s\ngot %s", expectedIP, ip) + } +} + +func TestCommandHelperProcess(t *testing.T) { + command.CommandHelperProcess(t) +} diff --git a/pkg/lxd/lxd.go b/pkg/lxd/lxd.go new file mode 100644 index 00000000..a88e4c10 --- /dev/null +++ b/pkg/lxd/lxd.go @@ -0,0 +1,36 @@ +package lxd + +import ( + "fmt" + "os/exec" + "regexp" + + "github.com/mcuadros/go-version" + "github.com/roots/trellis-cli/command" +) + +const ( + VersionRequired = ">= 0.14.0" +) + +func Installed() error { + if _, err := exec.LookPath("lxc"); err != nil { + return fmt.Errorf("LXD is not installed.") + } + + output, err := command.Cmd("lxc", []string{"-v"}).Output() + if err != nil { + return fmt.Errorf("Could get determine the version of LXD.") + } + + re := regexp.MustCompile(`.*([0-9]+\.[0-9]+\.[0-9]+(-alpha|beta)?)`) + v := re.FindStringSubmatch(string(output)) + constraint := version.NewConstrainGroupFromString(VersionRequired) + matched := constraint.Match(v[1]) + + if !matched { + return fmt.Errorf("LXD version %s does not satisfy required version (%s).", v[1], VersionRequired) + } + + return nil +} diff --git a/pkg/lxd/manager.go b/pkg/lxd/manager.go new file mode 100644 index 00000000..74174006 --- /dev/null +++ b/pkg/lxd/manager.go @@ -0,0 +1,368 @@ +package lxd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/user" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/fatih/color" + "github.com/hashicorp/cli" + "github.com/mitchellh/go-homedir" + "github.com/roots/trellis-cli/command" + "github.com/roots/trellis-cli/pkg/vm" + "github.com/roots/trellis-cli/trellis" +) + +const ( + configDir = "lxd" +) + +var ( + ErrConfigPath = errors.New("could not create config directory") + defaultSshKeys = []string{"~/.ssh/id_ed25519.pub", "~/.ssh/id_rsa.pub"} +) + +type Manager struct { + ConfigPath string + Sites map[string]*trellis.Site + HostsResolver vm.HostsResolver + ui cli.Ui + trellis *trellis.Trellis +} + +func NewManager(trellis *trellis.Trellis, ui cli.Ui) (manager *Manager, err error) { + configPath := filepath.Join(trellis.ConfigPath(), configDir) + + hostNames := trellis.Environments["development"].AllHosts() + hostsResolver, err := vm.NewHostsResolver(trellis.CliConfig.Vm.HostsResolver, hostNames) + + if err != nil { + return nil, err + } + + manager = &Manager{ + ConfigPath: configPath, + Sites: trellis.Environments["development"].WordPressSites, + HostsResolver: hostsResolver, + trellis: trellis, + ui: ui, + } + + if err = manager.createConfigPath(); err != nil { + return nil, fmt.Errorf("%w: %v", ErrConfigPath, err) + } + + return manager, nil +} + +func (m *Manager) InventoryPath() string { + return filepath.Join(m.ConfigPath, "inventory") +} + +func (m *Manager) GetInstance(name string) (Instance, bool) { + name = strings.ReplaceAll(name, ".", "-") + instances := m.instances() + instance, ok := instances[name] + + return instance, ok +} + +func (m *Manager) CreateInstance(name string) (err error) { + instance, err := m.newInstance(name) + if err != nil { + return err + } + + if err := instance.CreateConfig(); err != nil { + return err + } + + configFile, err := os.Open(instance.ConfigFile) + if err != nil { + return err + } + defer func() { err = configFile.Close() }() + + version := m.trellis.CliConfig.Vm.Ubuntu + + launchCmd := command.WithOptions( + command.WithTermOutput(), + command.WithLogging(m.ui), + ).Cmd("lxc", []string{"launch", "ubuntu:" + version, instance.Name}) + + launchCmd.Stdin = configFile + err = launchCmd.Run() + + if err != nil { + return err + } + + return postStart(m, &instance) +} + +func (m *Manager) DeleteInstance(name string) error { + instance, ok := m.GetInstance(name) + + if !ok { + m.ui.Info("VM does not exist for this project. Run `trellis vm start` to create it.") + return nil + } + + if instance.Stopped() { + return command.WithOptions( + command.WithTermOutput(), + command.WithLogging(m.ui), + ).Cmd("lxc", []string{"delete", instance.Name}).Run() + } else { + return fmt.Errorf("Error: VM is running. Run `trellis vm stop` to stop it.") + } +} + +func (m *Manager) OpenShell(name string, dir string, commandArgs []string) error { + instance, ok := m.GetInstance(name) + + if !ok { + m.ui.Info("Instance does not exist for this project. Run `trellis vm start` to create it.") + return nil + } + + if instance.Stopped() { + m.ui.Info("Instance is not running. Run `trellis vm start` to start it.") + return nil + } + + if dir != "" { + commandArgs = append([]string{"cd", dir, "&&"}, commandArgs...) + } + + // TODO: use SSH to IP + args := []string{"exec", instance.Name, "--", "sh", "-c", strings.Join(commandArgs, " ")} + + return command.WithOptions( + command.WithTermOutput(), + command.WithLogging(m.ui), + ).Cmd("lxc", args).Run() +} + +func (m *Manager) StartInstance(name string) error { + instance, ok := m.GetInstance(name) + + if !ok { + return vm.ErrVmNotFound + } + + if instance.Running() { + m.ui.Info(fmt.Sprintf("%s VM already running", color.GreenString("[✓]"))) + return nil + } + + err := command.WithOptions( + command.WithTermOutput(), + command.WithLogging(m.ui), + ).Cmd("lxc", []string{"start", instance.Name}).Run() + + if err != nil { + return err + } + + return postStart(m, &instance) +} + +func (m *Manager) StopInstance(name string) error { + instance, ok := m.GetInstance(name) + + if !ok { + m.ui.Info("Instance does not exist for this project. Run `trellis vm start` to create it.") + return nil + } + + if instance.Stopped() { + m.ui.Info(fmt.Sprintf("%s Instance already stopped", color.GreenString("[✓]"))) + return nil + } + + err := command.WithOptions( + command.WithTermOutput(), + command.WithLogging(m.ui), + ).Cmd("lxc", []string{"stop", instance.Name}).Run() + + if err != nil { + return fmt.Errorf("Error stopping Instance\n%v", err) + } + + if err = m.removeHosts(instance); err != nil { + return err + } + + return nil +} + +func (m *Manager) hydrateInstance(instance *Instance) error { + i, _ := m.GetInstance(instance.Name) + *instance = i + + return nil +} + +// TODO: user necessary with privileged containers? +func (m *Manager) initInstance(instance *Instance) { + user, _ := user.Current() + instance.Username = user.Username + + info, _ := os.Stat(m.trellis.ConfigPath()) + + if stat, ok := info.Sys().(*syscall.Stat_t); ok { + instance.Uid = int(stat.Uid) + instance.Gid = int(stat.Gid) + } + + instance.ConfigFile = filepath.Join(m.ConfigPath, instance.Name+".yml") + instance.InventoryFile = m.InventoryPath() + instance.Sites = m.Sites +} + +func (m *Manager) newInstance(name string) (Instance, error) { + name = strings.ReplaceAll(name, ".", "-") + homeDir, err := os.UserHomeDir() + if err != nil { + return Instance{}, err + } + + devices := map[string]Device{ + "home": { + Source: homeDir, + Dest: homeDir, + }, + } + + for name, site := range m.Sites { + devices[name] = Device{ + Source: site.AbsLocalPath, + Dest: "/srv/www/" + name + "/current", + } + } + + sshPublicKey := []byte{} + + for _, path := range defaultSshKeys { + sshPublicKey, err = loadPublicKey(path) + + if err == nil { + break + } + } + + if sshPublicKey == nil { + return Instance{}, fmt.Errorf("No valid SSH public key found. Attempted paths: %s", strings.Join(defaultSshKeys, ", ")) + } + + instance := Instance{ + Name: name, + Devices: devices, + SshPublicKey: string(sshPublicKey), + } + + m.initInstance(&instance) + + return instance, nil +} + +func (m *Manager) createConfigPath() error { + return os.MkdirAll(m.ConfigPath, 0755) +} + +func (m *Manager) addHosts(instance Instance) error { + if err := instance.CreateInventoryFile(); err != nil { + return err + } + + ip, err := instance.IP() + if err != nil { + return err + } + + if err := m.HostsResolver.AddHosts(instance.Name, ip); err != nil { + return err + } + + return nil +} + +func (m *Manager) instances() (instancesByName map[string]Instance) { + instances := []Instance{} + instancesByName = make(map[string]Instance) + + output, _ := command.Cmd("lxc", []string{"list", "--format=json"}).Output() + _ = json.Unmarshal(output, &instances) + + for _, instance := range instances { + m.initInstance(&instance) + instancesByName[instance.Name] = instance + } + + return instancesByName +} + +func (m *Manager) removeHosts(instance Instance) error { + return m.HostsResolver.RemoveHosts(instance.Name) +} + +func (m *Manager) retryHydrateInstance(instance *Instance, ctx context.Context) error { + interval := 1 * time.Second + + for { + err := m.hydrateInstance(instance) + if err != nil { + return err + } + + _, err = instance.IP() + if err == nil { + return nil + } + + select { + case <-ctx.Done(): + return fmt.Errorf("timeout hydrating VM: %v", err) + case <-time.After(interval): + } + } +} + +func postStart(manager *Manager, instance *Instance) error { + // Hydrate instance with data from lxc that is only available after starting (mainly the IP) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + err := manager.retryHydrateInstance(instance, ctx) + if err != nil { + return err + } + + if err = manager.addHosts(*instance); err != nil { + return err + } + + return nil +} + +func loadPublicKey(path string) ([]byte, error) { + path, err := homedir.Expand(path) + if err != nil { + return nil, err + } + + contents, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + return contents, nil +} diff --git a/pkg/lxd/manager_test.go b/pkg/lxd/manager_test.go new file mode 100644 index 00000000..5c248ec8 --- /dev/null +++ b/pkg/lxd/manager_test.go @@ -0,0 +1,96 @@ +package lxd + +import ( + "fmt" + "testing" + + "github.com/hashicorp/cli" + "github.com/roots/trellis-cli/command" + "github.com/roots/trellis-cli/trellis" +) + +type MockHostsResolver struct { + Hosts map[string]string +} + +func TestNewManager(t *testing.T) { + defer trellis.LoadFixtureProject(t)() + trellis := trellis.NewTrellis() + if err := trellis.LoadProject(); err != nil { + t.Fatal(err) + } + + _, err := NewManager(trellis, cli.NewMockUi()) + if err != nil { + t.Fatal(err) + } +} + +func TestInitInstance(t *testing.T) { + defer trellis.LoadFixtureProject(t)() + trellis := trellis.NewTrellis() + if err := trellis.LoadProject(); err != nil { + t.Fatal(err) + } + + manager, err := NewManager(trellis, cli.NewMockUi()) + if err != nil { + t.Fatal(err) + } + + instance := Instance{Name: "test"} + manager.initInstance(&instance) + + if instance.Name != "test" { + t.Errorf("expected instance name to be %q, got %q", "test", instance.Name) + } +} + +func TestInstances(t *testing.T) { + defer trellis.LoadFixtureProject(t)() + trellis := trellis.NewTrellis() + if err := trellis.LoadProject(); err != nil { + t.Fatal(err) + } + + instanceName := "test" + instancesJson := fmt.Sprintf(`[{"architecture":"aarch64","config":{"image.architecture":"arm64","image.description":"ubuntu 22.04 LTS arm64 (release) (20230107)","image.label":"release","image.os":"ubuntu","image.release":"jammy","image.serial":"20230107","image.type":"squashfs","image.version":"22.04","volatile.base_image":"851d46fc056a4a1891de29b32dad2a1fdecebf4961481e2cc0a5c2ee453e49ba","volatile.eth0.host_name":"vethb90b4747","volatile.eth0.hwaddr":"00:16:3e:8c:d3:1d","volatile.idmap.base":"0","volatile.last_state.power":"RUNNING","volatile.uuid":"8f977dc6-09e9-4216-b043-90e4db59b13a"},"devices":{},"ephemeral":false,"profiles":["default","trellis"],"stateful":false,"description":"","created_at":"2023-01-08T17:43:44.088124852Z","name":"%s","status":"Running","status_code":103,"last_used_at":"2023-01-08T17:43:45.681646105Z","location":"none","type":"instance","project":"default","backups":null,"state":{"status":"Running","status_code":103,"disk":{"root":{"usage":8689664}},"memory":{"usage":219332608,"usage_peak":260878336,"swap_usage":0,"swap_usage_peak":0},"network":{"eth0":{"addresses":[{"family":"inet","address":"10.99.30.5","netmask":"24","scope":"global"},{"family":"inet6","address":"fd42:8b4f:7529:43f2:216:3eff:fe8c:d31d","netmask":"64","scope":"global"},{"family":"inet6","address":"fe80::216:3eff:fe8c:d31d","netmask":"64","scope":"link"}],"counters":{"bytes_received":117689,"bytes_sent":16441,"packets_received":97,"packets_sent":114,"errors_received":0,"errors_sent":0,"packets_dropped_outbound":0,"packets_dropped_inbound":0},"hwaddr":"00:16:3e:8c:d3:1d","host_name":"vethb90b4747","mtu":1500,"state":"up","type":"broadcast"},"lo":{"addresses":[{"family":"inet","address":"127.0.0.1","netmask":"8","scope":"local"},{"family":"inet6","address":"::1","netmask":"128","scope":"local"}],"counters":{"bytes_received":1712,"bytes_sent":1712,"packets_received":20,"packets_sent":20,"errors_received":0,"errors_sent":0,"packets_dropped_outbound":0,"packets_dropped_inbound":0},"hwaddr":"","host_name":"","mtu":65536,"state":"up","type":"loopback"}},"pid":158889,"processes":38,"cpu":{"usage":6848930922}},"snapshots":null}]`, instanceName) + + commands := []command.MockCommand{ + { + Command: "lxc", + Args: []string{"list", "--format=json"}, + Output: instancesJson, + }, + } + + defer command.MockExecCommands(t, commands)() + + manager, err := NewManager(trellis, cli.NewMockUi()) + if err != nil { + t.Fatal(err) + } + + instances := manager.instances() + + if len(instances) != 1 { + t.Errorf("expected 1 instance, got %d", len(instances)) + } + + instance, ok := instances[instanceName] + + if !ok { + t.Errorf("expected instance with name %s to be present", instanceName) + } + + if instance.Name != instanceName { + t.Errorf("expected instance name to be %q, got %q", instanceName, instance.Name) + } + + expectedIP := "10.99.30.5" + actualIP, _ := instance.IP() + + if actualIP != expectedIP { + t.Errorf("expected instance IP to be %q, got %q", expectedIP, actualIP) + } +}