Skip to content

Commit 107ca7b

Browse files
committed
Add LXD integration
Adds support for LXD for VM management on Linux.
1 parent b02b6c1 commit 107ca7b

File tree

9 files changed

+721
-1
lines changed

9 files changed

+721
-1
lines changed

cli_config/cli_config.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ func (c *Config) LoadFile(path string) error {
5757
return fmt.Errorf("%w: %s", InvalidConfigErr, err)
5858
}
5959

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

cmd/vm.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77

88
"github.com/hashicorp/cli"
99
"github.com/roots/trellis-cli/pkg/lima"
10+
"github.com/roots/trellis-cli/pkg/lxd"
1011
"github.com/roots/trellis-cli/pkg/vm"
1112
"github.com/roots/trellis-cli/trellis"
1213
)
@@ -17,11 +18,15 @@ func newVmManager(trellis *trellis.Trellis, ui cli.Ui) (manager vm.Manager, err
1718
switch runtime.GOOS {
1819
case "darwin":
1920
return lima.NewManager(trellis, ui)
21+
case "linux":
22+
return lxd.NewManager(trellis, ui)
2023
default:
2124
return nil, fmt.Errorf("No VM managers are supported on %s yet.", runtime.GOOS)
2225
}
2326
case "lima":
2427
return lima.NewManager(trellis, ui)
28+
case "lxd":
29+
return lxd.NewManager(trellis, ui)
2530
case "mock":
2631
return vm.NewMockManager(trellis, ui)
2732
}

pkg/lxd/files/config.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
config:
2+
raw.idmap: |
3+
both {{ .Uid }} {{ .Uid }}
4+
gid {{ .Gid }} {{ .Gid }}
5+
user.vendor-data: |
6+
#cloud-config
7+
manage_etc_hosts: localhost
8+
users:
9+
- name: {{ .Username }}
10+
groups: sudo
11+
sudo: ['ALL=(ALL) NOPASSWD:ALL']
12+
ssh_authorized_keys:
13+
- {{ .SshPublicKey }}
14+
devices:
15+
{{ range $name, $device := .Devices }}
16+
{{ $name }}:
17+
type: disk
18+
source: {{ $device.Source }}
19+
path: {{ $device.Dest }}
20+
shift: "true"
21+
{{ end }}

pkg/lxd/files/inventory.txt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
default ansible_host={{ .IP }} ansible_user={{ .Username }} ansible_ssh_common_args='-o StrictHostKeyChecking=no'
2+
3+
[development]
4+
default
5+
6+
[web]
7+
default

pkg/lxd/instance.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package lxd
2+
3+
import (
4+
_ "embed"
5+
"errors"
6+
"fmt"
7+
"os"
8+
"text/template"
9+
10+
"github.com/roots/trellis-cli/trellis"
11+
)
12+
13+
//go:embed files/config.yml
14+
var ConfigTemplate string
15+
16+
//go:embed files/inventory.txt
17+
var inventoryTemplate string
18+
19+
var (
20+
ConfigErr = errors.New("Could not write LXD config file")
21+
IpErr = errors.New("Could not determine IP address for VM")
22+
)
23+
24+
type Device struct {
25+
Source string
26+
Dest string
27+
}
28+
29+
type NetworkAddress struct {
30+
Family string `json:"family"`
31+
Address string `json:"address"`
32+
}
33+
34+
type Network struct {
35+
Addresses []NetworkAddress `json:"addresses"`
36+
}
37+
38+
type State struct {
39+
Status string `json:"status"`
40+
Network map[string]Network `json:"network"`
41+
}
42+
43+
type Instance struct {
44+
ConfigFile string
45+
InventoryFile string
46+
Sites map[string]*trellis.Site
47+
Name string `json:"name"`
48+
State State `json:"state"`
49+
Username string `json:"username,omitempty"`
50+
Uid int
51+
Gid int
52+
SshPublicKey string
53+
Devices map[string]Device
54+
}
55+
56+
func (i *Instance) CreateConfig() error {
57+
tpl := template.Must(template.New("lxc").Parse(ConfigTemplate))
58+
59+
file, err := os.Create(i.ConfigFile)
60+
if err != nil {
61+
return fmt.Errorf("%v: %w", ConfigErr, err)
62+
}
63+
64+
err = tpl.Execute(file, i)
65+
if err != nil {
66+
return fmt.Errorf("%v: %w", ConfigErr, err)
67+
}
68+
69+
return nil
70+
}
71+
72+
func (i *Instance) CreateInventoryFile() error {
73+
tpl := template.Must(template.New("lxd").Parse(inventoryTemplate))
74+
75+
file, err := os.Create(i.InventoryFile)
76+
if err != nil {
77+
return fmt.Errorf("Could not create Ansible inventory file: %v", err)
78+
}
79+
80+
err = tpl.Execute(file, i)
81+
if err != nil {
82+
return fmt.Errorf("Could not template Ansible inventory file: %v", err)
83+
}
84+
85+
return nil
86+
}
87+
88+
func (i *Instance) IP() (ip string, err error) {
89+
network, ok := i.State.Network["eth0"]
90+
if !ok {
91+
return "", fmt.Errorf("%v: eth0 network not found", IpErr)
92+
}
93+
94+
for _, address := range network.Addresses {
95+
if address.Family == "inet" && address.Address != "" {
96+
return address.Address, nil
97+
}
98+
}
99+
100+
return "", fmt.Errorf("%v: inet address family not found", IpErr)
101+
}
102+
103+
func (i *Instance) Running() bool {
104+
return i.State.Status == "Running"
105+
}
106+
107+
func (i *Instance) Stopped() bool {
108+
return i.State.Status == "Stopped"
109+
}

pkg/lxd/instance_test.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package lxd
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
9+
"github.com/roots/trellis-cli/command"
10+
)
11+
12+
func TestCreateInventoryFile(t *testing.T) {
13+
dir := t.TempDir()
14+
15+
expectedIP := "1.2.3.4"
16+
17+
instance := &Instance{
18+
InventoryFile: filepath.Join(dir, "inventory"),
19+
Username: "dev",
20+
State: State{
21+
Network: map[string]Network{
22+
"eth0": {
23+
Addresses: []NetworkAddress{{Address: expectedIP, Family: "inet"}}},
24+
},
25+
},
26+
}
27+
28+
err := instance.CreateInventoryFile()
29+
if err != nil {
30+
t.Fatal(err)
31+
}
32+
33+
content, err := os.ReadFile(instance.InventoryFile)
34+
35+
if err != nil {
36+
t.Fatal(err)
37+
}
38+
39+
expected := fmt.Sprintf(`default ansible_host=%s ansible_user=dev ansible_ssh_common_args='-o StrictHostKeyChecking=no'
40+
41+
[development]
42+
default
43+
44+
[web]
45+
default
46+
`, expectedIP)
47+
48+
if string(content) != expected {
49+
t.Errorf("expected %s\ngot %s", expected, string(content))
50+
}
51+
}
52+
53+
func TestIP(t *testing.T) {
54+
expectedIP := "10.99.30.5"
55+
56+
instance := &Instance{
57+
Name: "test",
58+
State: State{
59+
Network: map[string]Network{
60+
"eth0": {
61+
Addresses: []NetworkAddress{{Address: expectedIP, Family: "inet"}}},
62+
},
63+
},
64+
}
65+
66+
ip, err := instance.IP()
67+
if err != nil {
68+
t.Fatal(err)
69+
}
70+
71+
if ip != expectedIP {
72+
t.Errorf("expected %s\ngot %s", expectedIP, ip)
73+
}
74+
}
75+
76+
func TestCommandHelperProcess(t *testing.T) {
77+
command.CommandHelperProcess(t)
78+
}

pkg/lxd/lxd.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package lxd
2+
3+
import (
4+
"fmt"
5+
"os/exec"
6+
"regexp"
7+
8+
"github.com/mcuadros/go-version"
9+
"github.com/roots/trellis-cli/command"
10+
)
11+
12+
const (
13+
VersionRequired = ">= 0.14.0"
14+
)
15+
16+
func Installed() error {
17+
if _, err := exec.LookPath("lxc"); err != nil {
18+
return fmt.Errorf("LXD is not installed.")
19+
}
20+
21+
output, err := command.Cmd("lxc", []string{"-v"}).Output()
22+
if err != nil {
23+
return fmt.Errorf("Could get determine the version of LXD.")
24+
}
25+
26+
re := regexp.MustCompile(`.*([0-9]+\.[0-9]+\.[0-9]+(-alpha|beta)?)`)
27+
v := re.FindStringSubmatch(string(output))
28+
constraint := version.NewConstrainGroupFromString(VersionRequired)
29+
matched := constraint.Match(v[1])
30+
31+
if !matched {
32+
return fmt.Errorf("LXD version %s does not satisfy required version (%s).", v[1], VersionRequired)
33+
}
34+
35+
return nil
36+
}

0 commit comments

Comments
 (0)