diff --git a/pkg/lima/files/config.yml b/pkg/lima/files/config.yml index fb91e759..ff088f23 100644 --- a/pkg/lima/files/config.yml +++ b/pkg/lima/files/config.yml @@ -1,4 +1,4 @@ -vmType: "vz" +vmType: "{{ .VmType }}" rosetta: enabled: false images: @@ -17,7 +17,11 @@ ssh: forwardAgent: true loadDotSSHPubKeys: true networks: +{{- if eq .VmType "qemu" }} +- lima: user-v2 +{{- else }} - vzNAT: true +{{- end }} {{ if .Config.PortForwards }} portForwards: {{ range $port := .Config.PortForwards -}} @@ -32,3 +36,15 @@ provision: script: | #!/bin/bash echo "127.0.0.1 $(hostname)" >> /etc/hosts + {{- if eq .VmType "qemu" }} + # Additional network setup for QEMU on Linux + # 1. Find interface by MAC (matches the one set in your Go wrapper) + TAP_IFACE=$(ip -o link show | grep "52:54:00:12:34:56" | awk -F': ' '{print $2}' || echo "") + + # 2. If found, configure it manually + if [ -n "$TAP_IFACE" ]; then + ip link set "$TAP_IFACE" up + # "|| true" prevents errors if the IP is already assigned from a previous run + ip addr add 192.168.56.5/24 dev "$TAP_IFACE" || true + fi + {{- end }} \ No newline at end of file diff --git a/pkg/lima/instance.go b/pkg/lima/instance.go index f84eacc2..1a2f89d6 100644 --- a/pkg/lima/instance.go +++ b/pkg/lima/instance.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "regexp" + "runtime" "text/template" "github.com/roots/trellis-cli/command" @@ -54,6 +55,8 @@ type Instance struct { SshLocalPort int `json:"sshLocalPort,omitempty"` Config Config `json:"config"` Username string `json:"username,omitempty"` + GOOS string `json:"goos,omitempty"` + VmType string `json:"vmType,omitempty"` } func (i *Instance) ConfigFile() string { @@ -62,6 +65,12 @@ func (i *Instance) ConfigFile() string { func (i *Instance) GenerateConfig() (*bytes.Buffer, error) { var contents bytes.Buffer + + i.VmType = "vz" + + if runtime.GOOS == "linux" { + i.VmType = "qemu" + } tpl := template.Must(template.New("lima").Parse(ConfigTemplate)) @@ -113,15 +122,28 @@ Gets the IP address of the instance using the output of `ip route`: 192.168.64.1 proto dhcp scope link src 192.168.64.2 metric 100 */ func (i *Instance) IP() (ip string, err error) { - output, err := command.Cmd( - "limactl", - []string{"shell", "--workdir", "/", i.Name, "ip", "route", "show", "dev", "lima0"}, - ).CombinedOutput() + args := []string{"shell", "--workdir", "/", i.Name, "ip", "route", "show"} + + // Keep existing macOS logic + if runtime.GOOS == "darwin" { + args = append(args, "dev", "lima0") + } + output, err := command.Cmd("limactl", args).CombinedOutput() if err != nil { return "", fmt.Errorf("%w: %v\n%s", IpErr, err, string(output)) } + // Prioritize the custom TAP interface IP (192.168.56.x) + if runtime.GOOS == "linux" { + reCustom := regexp.MustCompile(`src (192\.168\.56\.[0-9]+)`) + matchesCustom := reCustom.FindStringSubmatch(string(output)) + + if len(matchesCustom) >= 2 { + return matchesCustom[1], nil + } + } + re := regexp.MustCompile(`default via .* src ([0-9\.]+)`) matches := re.FindStringSubmatch(string(output)) if len(matches) < 2 { diff --git a/pkg/lima/manager.go b/pkg/lima/manager.go index e457090f..2ffb9ce9 100644 --- a/pkg/lima/manager.go +++ b/pkg/lima/manager.go @@ -1,6 +1,7 @@ package lima import ( + "bufio" "bytes" "encoding/json" "errors" @@ -9,6 +10,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "github.com/fatih/color" @@ -22,6 +24,7 @@ import ( const ( configDir = "lima" RequiredMacOSVersion = "13.0.0" + RequiredLinuxVersion = "20.04" ) var ( @@ -220,6 +223,61 @@ func (m *Manager) StartInstance(name string) error { return err } + if runtime.GOOS == "linux" { + // 1. Ensure tap0 exists (unchanged) + if _, err := exec.Command("ip", "link", "show", "tap0").Output(); err != nil { + m.ui.Info(color.YellowString("🔧 tap0 missing. Creating it (requires sudo)...")) + // Added 'set tap0 up' to ensure the link is active on host + cmd := exec.Command("sudo", "sh", "-c", "ip tuntap add dev tap0 mode tap user $(whoami) && ip addr add 192.168.56.1/24 dev tap0 && ip link set tap0 up") + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("Failed to create tap0: %v", err) + } + } + + m.ui.Info(color.YellowString("🛠️ Configuring QEMU Network Wrapper...")) + + // 2. Create a dedicated directory for the wrapper + wrapperDir := filepath.Join(os.TempDir(), "lima-qemu-wrapper") + if err := os.MkdirAll(wrapperDir, 0755); err != nil { + return fmt.Errorf("failed to create wrapper dir: %v", err) + } + + // 3. Name the script exactly 'qemu-system-x86_64' so Lima picks it up + wrapperPath := filepath.Join(wrapperDir, "qemu-system-x86_64") + + wrapperScript := `#!/bin/bash +# Pass-through for help/version commands to prevent crashing during capability checks +if [[ "$@" == *"-netdev help"* ]] || \ +[[ "$@" == *"-version"* ]] || \ +[[ "$@" == *"-accel help"* ]] || \ +[[ "$@" == *"-machine help"* ]] || \ +[[ "$@" == *"-cpu help"* ]]; then + exec /usr/bin/qemu-system-x86_64 "$@" +fi + +# Inject the TAP interface args BEFORE other args ($@) to ensure precedence +exec /usr/bin/qemu-system-x86_64 \ +-netdev tap,id=mynet0,ifname=tap0,script=no,downscript=no \ +-device virtio-net-pci,netdev=mynet0,mac=52:54:00:12:34:56 \ +"$@" +` + if err := os.WriteFile(wrapperPath, []byte(wrapperScript), 0755); err != nil { + m.ui.Warn(fmt.Sprintf("Failed to create QEMU wrapper: %v", err)) + } + + // 4. CRITICAL: Prepend the wrapper directory to PATH + // This forces Lima to find your script before the real QEMU + currentPath := os.Getenv("PATH") + newPath := wrapperDir + string(os.PathListSeparator) + currentPath + os.Setenv("PATH", newPath) + } + + + + err := command.WithOptions( command.WithTermOutput(), command.WithLogging(m.ui), @@ -389,14 +447,59 @@ func getMacOSVersion() (string, error) { return version, nil } -func ensureRequirements() error { - macOSVersion, err := getMacOSVersion() - if err != nil { - return ErrUnsupportedOS - } +func getLinuxVersion() (string, error) { + // /etc/os-release is the standard on almost all modern Linux distros + f, err := os.Open("/etc/os-release") + if err != nil { + return "", err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + // Look for the line starting with VERSION_ID= + if strings.HasPrefix(line, "VERSION_ID=") { + // Remove the prefix + val := strings.TrimPrefix(line, "VERSION_ID=") + // Remove double or single quotes if present (e.g. "22.04" -> 22.04) + val = strings.Trim(val, `"'`) + + // Normalize using your existing version package + verNormalized := version.Normalize(val) + return verNormalized, nil + } + } + + if err := scanner.Err(); err != nil { + return "", err + } + + return "", fmt.Errorf("VERSION_ID not found in /etc/os-release") +} - if version.Compare(macOSVersion, RequiredMacOSVersion, "<") { - return fmt.Errorf("%w", ErrUnsupportedOS) +func ensureRequirements() error { + var currentVersion string + var requiredVersion string + var err error + + switch runtime.GOOS { + case "darwin": + currentVersion, err = getMacOSVersion() + requiredVersion = RequiredMacOSVersion + case "linux": + currentVersion, err = getLinuxVersion() + requiredVersion = RequiredLinuxVersion + default: + return fmt.Errorf("unsupported operating system: %s", runtime.GOOS) + } + if err != nil { + return fmt.Errorf("failed to detect OS version: %w", err) + } + + // Now compare the current version against the specific requirement for that OS + if version.Compare(currentVersion, requiredVersion, "<") { + return fmt.Errorf("OS version %s is too old; %s or newer is required", currentVersion, requiredVersion) } if err = Installed(); err != nil { diff --git a/trellis/trellis.go b/trellis/trellis.go index 3591a46b..7e4c7c1a 100644 --- a/trellis/trellis.go +++ b/trellis/trellis.go @@ -387,7 +387,7 @@ func (t *Trellis) WriteYamlFile(s interface{}, path string, header string) error func (t *Trellis) VmManagerType() string { switch t.CliConfig.Vm.Manager { case "auto": - if runtime.GOOS == "darwin" { + if runtime.GOOS == "darwin" || runtime.GOOS == "linux" { return "lima" } return ""