diff --git a/app/app.go b/app/app.go index bbbb134b..64a96fdc 100644 --- a/app/app.go +++ b/app/app.go @@ -21,6 +21,7 @@ import ( "github.com/abiosoft/colima/environment/host" "github.com/abiosoft/colima/environment/vm/lima" "github.com/abiosoft/colima/environment/vm/lima/limautil" + "github.com/abiosoft/colima/environment/vm/native" "github.com/abiosoft/colima/store" "github.com/abiosoft/colima/util" "github.com/docker/go-units" @@ -42,11 +43,32 @@ type App interface { var _ App = (*colimaApp)(nil) -// New creates a new app. +// New creates a new app using the saved instance's VM type. func New() (App, error) { - guest := lima.New(host.New()) - if err := host.IsInstalled(guest); err != nil { - return nil, fmt.Errorf("dependency check failed for VM: %w", err) + return NewWithVMType("") +} + +// NewWithVMType creates a new app with the specified VM type. +// If vmType is empty, it loads from saved instance state or uses the default. +func NewWithVMType(vmType string) (App, error) { + h := host.New() + + if vmType == "" { + if conf, err := configmanager.LoadInstance(); err == nil && conf.VMType != "" { + vmType = conf.VMType + } else { + vmType = environment.DefaultVMType() + } + } + + var guest environment.VM + if vmType == "native" && util.Linux() { + guest = native.New(h) + } else { + guest = lima.New(h) + if err := host.IsInstalled(guest); err != nil { + return nil, fmt.Errorf("dependency check failed for VM: %w", err) + } } return &colimaApp{ @@ -281,9 +303,12 @@ func (c colimaApp) Delete(data, force bool) error { // delete runtime disk if disk in use and data deletion is requested if diskInUse && data { - log.Println("deleting container data") - if err := limautil.DeleteDisk(); err != nil { - return fmt.Errorf("error deleting container data: %w", err) + conf, _ := configmanager.LoadInstance() + if conf.VMType != "native" { + log.Println("deleting container data") + if err := limautil.DeleteDisk(); err != nil { + return fmt.Errorf("error deleting container data: %w", err) + } } if err := store.Reset(); err != nil { @@ -342,8 +367,7 @@ func (c colimaApp) SSH(args ...string) error { workDir = "" } - guest := lima.New(host.New()) - return guest.SSH(workDir, args...) + return c.guest.SSH(workDir, args...) } type statusInfo struct { @@ -383,29 +407,49 @@ func (c colimaApp) getStatus() (status statusInfo, err error) { status.Arch = string(c.guest.Arch()) status.Runtime = currentRuntime status.MountType = conf.MountType - ipAddress := limautil.IPAddress(config.CurrentProfile().ID) - if ipAddress != "127.0.0.1" { - status.IPAddress = ipAddress + if conf.VMType == "native" { + status.IPAddress = native.HostIPAddress() + cpu, mem, disk := native.HostResources() + status.CPU = cpu + status.Memory = mem + status.Disk = disk + } else { + ipAddress := limautil.IPAddress(config.CurrentProfile().ID) + if ipAddress != "127.0.0.1" { + status.IPAddress = ipAddress + } + if inst, err := limautil.Instance(); err == nil { + status.CPU = inst.CPU + status.Memory = inst.Memory + status.Disk = inst.Disk + } } if currentRuntime == docker.Name { - status.DockerSocket = "unix://" + docker.HostSocketFile() - status.ContainerdSocket = "unix://" + containerd.HostSocketFiles().Containerd + if conf.VMType == "native" { + status.DockerSocket = "unix:///var/run/docker.sock" + } else { + status.DockerSocket = "unix://" + docker.HostSocketFile() + status.ContainerdSocket = "unix://" + containerd.HostSocketFiles().Containerd + } } if currentRuntime == containerd.Name { - status.ContainerdSocket = "unix://" + containerd.HostSocketFiles().Containerd - status.BuildkitdSocket = "unix://" + containerd.HostSocketFiles().Buildkitd + if conf.VMType == "native" { + status.ContainerdSocket = "unix:///run/containerd/containerd.sock" + } else { + status.ContainerdSocket = "unix://" + containerd.HostSocketFiles().Containerd + status.BuildkitdSocket = "unix://" + containerd.HostSocketFiles().Buildkitd + } } if currentRuntime == incus.Name { - status.IncusSocket = "unix://" + incus.HostSocketFile() + if conf.VMType == "native" { + status.IncusSocket = "unix:///var/lib/incus/unix.socket" + } else { + status.IncusSocket = "unix://" + incus.HostSocketFile() + } } if k, err := c.Kubernetes(); err == nil && k.Running(ctx) { status.Kubernetes = true } - if inst, err := limautil.Instance(); err == nil { - status.CPU = inst.CPU - status.Memory = inst.Memory - status.Disk = inst.Disk - } return status, nil } @@ -507,6 +551,12 @@ func (c colimaApp) currentRuntime(ctx context.Context) (string, error) { r := c.guest.Get(environment.ContainerRuntimeKey) if r == "" { + // Fallback: read runtime from persisted config (needed for native mode + // where the Lima store mechanism is not available) + conf, err := configmanager.LoadInstance() + if err == nil && conf.Runtime != "" { + return conf.Runtime, nil + } return "", fmt.Errorf("error retrieving current runtime: empty value") } @@ -625,6 +675,11 @@ func (c *colimaApp) Update() error { } func generateSSHConfig(modifySSHConfig bool) error { + // Skip SSH config generation for native mode (no VM to SSH into) + if conf, err := configmanager.LoadInstance(); err == nil && conf.VMType == "native" { + return nil + } + instances, err := limautil.Instances() if err != nil { return fmt.Errorf("error retrieving instances: %w", err) diff --git a/cmd/clone.go b/cmd/clone.go index f12cbcde..a32f31f3 100644 --- a/cmd/clone.go +++ b/cmd/clone.go @@ -8,6 +8,7 @@ import ( "github.com/abiosoft/colima/cli" "github.com/abiosoft/colima/cmd/root" "github.com/abiosoft/colima/config" + "github.com/abiosoft/colima/config/configmanager" "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) @@ -23,15 +24,22 @@ var cloneCmd = &cobra.Command{ to := config.ProfileFromName(args[1]) logrus.Infof("preparing to clone %s...", from.DisplayName) - { - // verify source profile exists + + // Check if source is a native instance + isNative := false + if conf, err := configmanager.LoadFrom(from.StateFile()); err == nil && conf.VMType == "native" { + isNative = true + } + + if !isNative { + // VM mode: verify source profile exists and clone VM files if stat, err := os.Stat(from.LimaInstanceDir()); err != nil || !stat.IsDir() { return fmt.Errorf("colima profile '%s' does not exist", from.ShortName) } - // verify destination profile does not exists + // verify destination profile does not exist if stat, err := os.Stat(to.LimaInstanceDir()); err == nil && stat.IsDir() { - return fmt.Errorf("colima profile '%s' already exists, delete with `colima delete %s` and try again", to.ShortName, to.ShortName) + return fmt.Errorf("colima profile '%s' already exists, delete with 'colima delete %s' and try again", to.ShortName, to.ShortName) } // copy source to destination @@ -49,6 +57,11 @@ var cloneCmd = &cobra.Command{ ).Run(); err != nil { return fmt.Errorf("error copying VM: %w", err) } + } else { + // Native mode: verify source config exists + if _, err := os.Stat(from.ConfigDir()); err != nil { + return fmt.Errorf("colima profile '%s' does not exist", from.ShortName) + } } { diff --git a/cmd/prune.go b/cmd/prune.go index d8b9a1d0..bd200f30 100644 --- a/cmd/prune.go +++ b/cmd/prune.go @@ -43,9 +43,10 @@ var pruneCmd = &cobra.Command{ } if pruneCmdArgs.all { + // Lima prune is not applicable in native-only mode cmd := limautil.Limactl("prune") if err := cmd.Run(); err != nil { - return fmt.Errorf("error during Lima prune: %w", err) + logrus.Warnf("Lima prune skipped or failed: %v", err) } } diff --git a/cmd/restart.go b/cmd/restart.go index 0c3de3fd..aa475bb3 100644 --- a/cmd/restart.go +++ b/cmd/restart.go @@ -23,9 +23,12 @@ The state of the VM is persisted at stop. A start afterwards should return it back to its previous state.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - // validate if the instance was previously created - if _, err := limautil.Instance(); err != nil { - return err + // validate if the instance was previously created (skip for native mode) + conf, _ := configmanager.LoadInstance() + if conf.VMType != "native" { + if _, err := limautil.Instance(); err != nil { + return err + } } app := newApp() diff --git a/cmd/ssh-config.go b/cmd/ssh-config.go index 3f568cb0..520d5e0b 100644 --- a/cmd/ssh-config.go +++ b/cmd/ssh-config.go @@ -5,6 +5,7 @@ import ( "github.com/abiosoft/colima/cmd/root" "github.com/abiosoft/colima/config" + "github.com/abiosoft/colima/config/configmanager" "github.com/abiosoft/colima/environment/vm/lima/limautil" "github.com/spf13/cobra" ) @@ -16,6 +17,12 @@ var sshConfigCmd = &cobra.Command{ Long: `Show configuration of the SSH connection to the VM.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + // SSH config is not applicable for native mode + if conf, err := configmanager.LoadInstance(); err == nil && conf.VMType == "native" { + fmt.Println("# SSH config not applicable for native mode (no VM)") + return nil + } + resp, err := limautil.ShowSSH(config.CurrentProfile().ID) if err == nil { fmt.Println(resp.Output) diff --git a/cmd/start.go b/cmd/start.go index 5497e931..1c560910 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -51,7 +51,7 @@ Run 'colima template' to set the default configurations or 'colima start --edit' " colima start --kubernetes --k3s-arg='\"--disable=coredns,servicelb,traefik,local-storage,metrics-server\"'", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - app := newApp() + app := newAppWithVMType(startCmdArgs.VMType) conf := startCmdArgs.Config if !startCmdArgs.Flags.Edit { @@ -87,9 +87,11 @@ Run 'colima template' to set the default configurations or 'colima start --edit' return start(app, conf) }, PreRunE: func(cmd *cobra.Command, args []string) error { - // validate Lima version - if err := core.LimaVersionSupported(); err != nil { - return fmt.Errorf("lima compatibility error: %w", err) + // validate Lima version (not needed for native mode) + if startCmdArgs.VMType != "native" { + if err := core.LimaVersionSupported(); err != nil { + return fmt.Errorf("lima compatibility error: %w", err) + } } // combine args and current config file(if any) @@ -173,6 +175,9 @@ func init() { if util.MacOS13OrNewerOnArm() { vmTypes = append(vmTypes, "krunkit") } + if util.Linux() { + vmTypes = append(vmTypes, "native") + } types := strings.Join(vmTypes, ", ") saveConfigDefault := true @@ -229,6 +234,11 @@ func init() { } } + // vm type on Linux + if util.Linux() { + startCmd.Flags().StringVarP(&startCmdArgs.VMType, "vm-type", "t", defaultVMType, "virtual machine type ("+types+")") + } + // Gateway Address startCmd.Flags().IPVar(&startCmdArgs.Network.GatewayAddress, "gateway-address", net.ParseIP("192.168.5.2"), "gateway address") diff --git a/cmd/util.go b/cmd/util.go index f009c73a..473356ca 100644 --- a/cmd/util.go +++ b/cmd/util.go @@ -22,6 +22,14 @@ func newApp() app.App { return colimaApp } +func newAppWithVMType(vmType string) app.App { + colimaApp, err := app.NewWithVMType(vmType) + if err != nil { + logrus.Fatal("Error: ", err) + } + return colimaApp +} + // waitForUserEdit launches a temporary file with content using editor, // and waits for the user to close the editor. // It returns the filename (if saved), empty file name (if aborted), and an error (if any). diff --git a/config/config.go b/config/config.go index fa1e98ac..b8044c0a 100644 --- a/config/config.go +++ b/config/config.go @@ -142,6 +142,9 @@ func (c Config) AutoActivate() bool { func (c Config) Empty() bool { return c.Runtime == "" } // this may be better but not really needed. func (c Config) DriverLabel() string { + if c.VMType == "native" { + return "Native" + } if util.MacOS13OrNewer() && c.VMType == "vz" { return "macOS Virtualization.Framework" } else if util.MacOS13OrNewerOnArm() && c.VMType == "krunkit" { diff --git a/config/configmanager/configmanager.go b/config/configmanager/configmanager.go index 20490c0f..6efc0938 100644 --- a/config/configmanager/configmanager.go +++ b/config/configmanager/configmanager.go @@ -49,6 +49,15 @@ func LoadFrom(file string) (config.Config, error) { // ValidateConfig validates config before we use it func ValidateConfig(c config.Config) error { + // native mode: skip VM-specific validations entirely + if c.VMType == "native" { + if !util.Linux() { + return fmt.Errorf("vmType 'native' is only available on Linux") + } + // Native mode doesn't use mount types, disk images, or port forwarders + return nil + } + validMountTypes := map[string]bool{"9p": true, "sshfs": true} validPortForwarders := map[string]bool{"grpc": true, "ssh": true, "none": true} @@ -65,6 +74,9 @@ func ValidateConfig(c config.Config) error { if util.MacOS13OrNewerOnArm() { validVMTypes["krunkit"] = true } + if util.Linux() { + validVMTypes["native"] = true + } if c.VMType == "krunkit" && !util.MacOS13OrNewerOnArm() { return fmt.Errorf("vmType 'krunkit' is only available on macOS with Apple Silicon") } diff --git a/environment/container/docker/context.go b/environment/container/docker/context.go index 0cae4713..b40d00ff 100644 --- a/environment/container/docker/context.go +++ b/environment/container/docker/context.go @@ -4,6 +4,7 @@ import ( "path/filepath" "github.com/abiosoft/colima/config" + "github.com/abiosoft/colima/config/configmanager" ) var configDir = func() string { return config.CurrentProfile().ConfigDir() } @@ -25,9 +26,15 @@ func (d dockerRuntime) setupContext() error { profile := config.CurrentProfile() + // In native mode, use the system Docker socket directly + socketPath := HostSocketFile() + if conf, err := configmanager.LoadInstance(); err == nil && conf.VMType == "native" { + socketPath = "/var/run/docker.sock" + } + return d.host.Run("docker", "context", "create", profile.ID, "--description", profile.DisplayName, - "--docker", "host=unix://"+HostSocketFile(), + "--docker", "host=unix://"+socketPath, ) } diff --git a/environment/container/docker/daemon.go b/environment/container/docker/daemon.go index 0b65b05b..f331e555 100644 --- a/environment/container/docker/daemon.go +++ b/environment/container/docker/daemon.go @@ -12,7 +12,11 @@ const hostGatewayIPKey = "host-gateway-ip" func getHostGatewayIp(d dockerRuntime, conf map[string]any) (string, error) { // get host-gateway ip from the guest - ip, err := d.guest.RunOutput("sh", "-c", "grep 'host.lima.internal' /etc/hosts | awk -F' ' '{print $1}'") + // Try Lima host entry first, fall back to default gateway for native mode + ip, err := d.guest.RunOutput("sh", "-c", + "grep 'host.lima.internal' /etc/hosts 2>/dev/null | awk -F' ' '{print $1}' || "+ + "ip route show default 2>/dev/null | awk '{print $3}' || "+ + "echo 127.0.0.1") if err != nil { return "", fmt.Errorf("error retrieving host gateway IP address: %w", err) } diff --git a/environment/container/incus/incus.go b/environment/container/incus/incus.go index 238e3bfd..388a8b79 100644 --- a/environment/container/incus/incus.go +++ b/environment/container/incus/incus.go @@ -11,6 +11,7 @@ import ( "github.com/abiosoft/colima/cli" "github.com/abiosoft/colima/config" + "github.com/abiosoft/colima/config/configmanager" "github.com/abiosoft/colima/environment" "github.com/abiosoft/colima/environment/guest/systemctl" "github.com/abiosoft/colima/environment/vm/lima/limautil" @@ -81,7 +82,8 @@ func (c *incusRuntime) Provision(ctx context.Context) error { emptyDisk := true recoverStorage := false - if limautil.DiskProvisioned(Name) { + instConf, _ := configmanager.LoadInstance() + if instConf.VMType != "native" && limautil.DiskProvisioned(Name) { emptyDisk = false // previous disk exists // ignore storage, recovery would be attempted later @@ -432,7 +434,12 @@ func (c *incusRuntime) wipeDisk(size int) error { // (separate incus-disks and incus-backups subdirectories) to the new layout // (full /var/lib/incus directory). func migrationScript() string { - mountPoint := limautil.MountPoint() + var mountPoint string + if conf, err := configmanager.LoadInstance(); err == nil && conf.VMType == "native" { + mountPoint = "/var/lib/colima" + } else { + mountPoint = limautil.MountPoint() + } return `MOUNT_POINT="` + mountPoint + `" if [ -d "$MOUNT_POINT/incus-disks" ] && [ ! -d "$MOUNT_POINT/incus" ]; then mkdir -p "$MOUNT_POINT/incus" diff --git a/environment/container/kubernetes/k3s.go b/environment/container/kubernetes/k3s.go index c13dd1a0..30d2e815 100644 --- a/environment/container/kubernetes/k3s.go +++ b/environment/container/kubernetes/k3s.go @@ -8,10 +8,12 @@ import ( "github.com/abiosoft/colima/cli" "github.com/abiosoft/colima/config" + "github.com/abiosoft/colima/config/configmanager" "github.com/abiosoft/colima/environment" "github.com/abiosoft/colima/environment/container/containerd" "github.com/abiosoft/colima/environment/container/docker" "github.com/abiosoft/colima/environment/vm/lima/limautil" + "github.com/abiosoft/colima/environment/vm/native" "github.com/abiosoft/colima/util" "github.com/abiosoft/colima/util/downloader" "github.com/sirupsen/logrus" @@ -160,7 +162,15 @@ func installK3sCluster( }, k3sArgs...) a.Retry("waiting for VM IP address", time.Second*5, 4, func(retryCount int) error { - ipAddress := limautil.IPAddress(config.CurrentProfile().ID) + var ipAddress string + var netInterface string + if conf, err := configmanager.LoadInstance(); err == nil && conf.VMType == "native" { + ipAddress = native.HostIPAddress() + netInterface = native.HostPrimaryInterface() + } else { + ipAddress = limautil.IPAddress(config.CurrentProfile().ID) + netInterface = limautil.NetInterface + } if ipAddress == "" { return fmt.Errorf("no IP address assigned to network interface") } @@ -172,7 +182,7 @@ func installK3sCluster( args = append(args, "--advertise-address", ipAddress) } if !hasK3sArg(k3sArgs, "--flannel-iface") { - args = append(args, "--flannel-iface", limautil.NetInterface) + args = append(args, "--flannel-iface", netInterface) } } return nil diff --git a/environment/container/kubernetes/kubeconfig.go b/environment/container/kubernetes/kubeconfig.go index 02972e59..0b52f08a 100644 --- a/environment/container/kubernetes/kubeconfig.go +++ b/environment/container/kubernetes/kubeconfig.go @@ -9,13 +9,20 @@ import ( "github.com/abiosoft/colima/cli" "github.com/abiosoft/colima/config" + "github.com/abiosoft/colima/config/configmanager" "github.com/abiosoft/colima/environment/vm/lima/limautil" + "github.com/abiosoft/colima/environment/vm/native" ) const masterAddressKey = "master_address" func (c kubernetesRuntime) provisionKubeconfig(ctx context.Context) error { - ip := limautil.IPAddress(config.CurrentProfile().ID) + var ip string + if conf, err := configmanager.LoadInstance(); err == nil && conf.VMType == "native" { + ip = native.HostIPAddress() + } else { + ip = limautil.IPAddress(config.CurrentProfile().ID) + } if ip == c.guest.Get(masterAddressKey) { return nil } diff --git a/environment/vm.go b/environment/vm.go index c7586383..417ac31c 100644 --- a/environment/vm.go +++ b/environment/vm.go @@ -64,6 +64,9 @@ func (a Arch) Value() Arch { // DefaultVMType returns the default virtual machine type based on the operation // system and availability of Qemu. func DefaultVMType() string { + if util.Linux() { + return "native" + } if util.MacOS13OrNewer() { return "vz" } diff --git a/environment/vm/lima/limautil/instance.go b/environment/vm/lima/limautil/instance.go index a1bc8bec..ce8d08c1 100644 --- a/environment/vm/lima/limautil/instance.go +++ b/environment/vm/lima/limautil/instance.go @@ -4,7 +4,11 @@ import ( "bufio" "bytes" "encoding/json" + "errors" "fmt" + "os" + "os/exec" + "path/filepath" "strings" "github.com/abiosoft/colima/config" @@ -73,8 +77,9 @@ func getInstance(profileID string) (InstanceInfo, error) { return i, nil } -// Instances returns Lima instances created by colima. -func Instances(ids ...string) ([]InstanceInfo, error) { +// limaInstances returns instances retrieved via limactl. +// Returns nil slice (not error) if limactl is unavailable. +func limaInstances(ids ...string) ([]InstanceInfo, error) { limaIDs := make([]string, len(ids)) for i := range ids { limaIDs[i] = config.ProfileFromName(ids[i]).ID @@ -87,6 +92,12 @@ func Instances(ids ...string) ([]InstanceInfo, error) { cmd.Stdout = &buf if err := cmd.Run(); err != nil { + // Only suppress "executable not found" errors. + // Real limactl errors (e.g. daemon issues) should still propagate + // so users running VM-mode on Linux don't get silent failures. + if errors.Is(err, exec.ErrNotFound) { + return nil, nil + } return nil, fmt.Errorf("error retrieving instances: %w", err) } @@ -131,6 +142,155 @@ func Instances(ids ...string) ([]InstanceInfo, error) { return instances, nil } +// NativeInstances scans the lima directory for native-mode colima profiles +// by reading state files directly, without requiring limactl. +func NativeInstances(ids ...string) []InstanceInfo { + limaDir := config.LimaDir() + + entries, err := os.ReadDir(limaDir) + if err != nil { + return nil + } + + // Build filter set from requested IDs + filterSet := make(map[string]bool) + for _, id := range ids { + p := config.ProfileFromName(id) + filterSet[p.ID] = true + } + + var instances []InstanceInfo + for _, entry := range entries { + if !entry.IsDir() { + continue + } + name := entry.Name() + + // Only colima profiles + if !strings.HasPrefix(name, "colima") { + continue + } + + // Apply filter if specified + if len(filterSet) > 0 && !filterSet[name] { + continue + } + + // Read state file + stateFile := filepath.Join(limaDir, name, "colima.yaml") + conf, err := configmanager.LoadFrom(stateFile) + if err != nil { + continue + } + + // Only native profiles + if conf.VMType != "native" { + continue + } + + profile := config.ProfileFromName(name) + + inst := InstanceInfo{ + Name: profile.ShortName, + Arch: conf.Arch, + CPU: conf.CPU, + Memory: int64(conf.Memory * 1024 * 1024 * 1024), // GiB to bytes + Runtime: getRuntime(conf), + } + + if conf.Disk > 0 { + inst.Disk = config.Disk(conf.Disk).Int() + } + + // Check if native instance is running by checking systemd service + switch conf.Runtime { + case "docker": + if isSystemdActive("docker.service") { + inst.Status = limaStatusRunning + inst.IPAddress = getNativeIPAddress() + } else { + inst.Status = "Stopped" + } + case "containerd": + if isSystemdActive("containerd.service") { + inst.Status = limaStatusRunning + inst.IPAddress = getNativeIPAddress() + } else { + inst.Status = "Stopped" + } + case "incus": + if isSystemdActive("incus.service") { + inst.Status = limaStatusRunning + inst.IPAddress = getNativeIPAddress() + } else { + inst.Status = "Stopped" + } + default: + inst.Status = "Stopped" + } + + instances = append(instances, inst) + } + + return instances +} + +// isSystemdActive checks if a systemd service is active. +func isSystemdActive(service string) bool { + var buf bytes.Buffer + cmd := exec.Command("systemctl", "is-active", service) + cmd.Stdout = &buf + cmd.Stderr = nil + if err := cmd.Run(); err != nil { + return false + } + return strings.TrimSpace(buf.String()) == "active" +} + +// getNativeIPAddress returns the host's primary IP address for native mode. +func getNativeIPAddress() string { + var buf bytes.Buffer + cmd := exec.Command("hostname", "-I") + cmd.Stdout = &buf + cmd.Stderr = nil + if err := cmd.Run(); err != nil { + return "" + } + fields := strings.Fields(buf.String()) + if len(fields) > 0 { + return fields[0] + } + return "" +} + +// Instances returns all Colima instances (Lima-managed + native). +func Instances(ids ...string) ([]InstanceInfo, error) { + // Get Lima-managed instances (gracefully handles missing limactl) + limaInsts, err := limaInstances(ids...) + if err != nil { + return nil, err + } + + // Get native instances from state files + nativeInsts := NativeInstances(ids...) + + // Merge, deduplicating by name + seen := make(map[string]bool) + var all []InstanceInfo + + for _, inst := range limaInsts { + seen[inst.Name] = true + all = append(all, inst) + } + for _, inst := range nativeInsts { + if !seen[inst.Name] { + all = append(all, inst) + } + } + + return all, nil +} + // RunningInstances return Lima instances that are has a running status. func RunningInstances() ([]InstanceInfo, error) { allInstances, err := Instances() @@ -165,3 +325,4 @@ func getRuntime(conf config.Config) string { } return runtime } + diff --git a/environment/vm/native/config.go b/environment/vm/native/config.go new file mode 100644 index 00000000..9983a687 --- /dev/null +++ b/environment/vm/native/config.go @@ -0,0 +1,62 @@ +package native + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/abiosoft/colima/config" + log "github.com/sirupsen/logrus" +) + +const configFileName = "native.json" + +// configFilePath returns the path to the native KV config store. +// Lima stores this inside the VM at /etc/colima/colima.json. +// Native mode stores it in the profile directory on the host. +func (n nativeVM) configFilePath() string { + return filepath.Join(config.CurrentProfile().ConfigDir(), configFileName) +} + +func (n nativeVM) loadConfig() map[string]string { + obj := map[string]string{} + b, err := os.ReadFile(n.configFilePath()) + if err != nil { + log.Tracef("error reading native config file: %v", err) + return obj + } + + _ = json.Unmarshal(b, &obj) + return obj +} + +// Get retrieves a configuration value. +func (n nativeVM) Get(key string) string { + if val, ok := n.loadConfig()[key]; ok { + return val + } + return "" +} + +// Set stores a configuration value. +func (n nativeVM) Set(key, value string) error { + obj := n.loadConfig() + obj[key] = value + + b, err := json.Marshal(obj) + if err != nil { + return fmt.Errorf("error marshalling settings to json: %w", err) + } + + dir := filepath.Dir(n.configFilePath()) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("error creating config directory: %w", err) + } + + if err := os.WriteFile(n.configFilePath(), b, 0644); err != nil { + return fmt.Errorf("error saving settings: %w", err) + } + + return nil +} diff --git a/environment/vm/native/file.go b/environment/vm/native/file.go new file mode 100644 index 00000000..e27f6091 --- /dev/null +++ b/environment/vm/native/file.go @@ -0,0 +1,32 @@ +package native + +import ( + "fmt" + "os" + "path/filepath" +) + +// Read reads a file directly from the host filesystem. +// Lima's file.go uses SSH cat to read files inside the VM. +func (n nativeVM) Read(fileName string) (string, error) { + b, err := os.ReadFile(fileName) + if err != nil { + return "", fmt.Errorf("cannot read file '%s': %w", fileName, err) + } + return string(b), nil +} + +// Write writes a file directly to the host filesystem. +// Lima's file.go uses SSH to pipe content into the VM. +func (n nativeVM) Write(fileName string, body []byte) error { + dir := filepath.Dir(fileName) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("error creating directory '%s': %w", dir, err) + } + return os.WriteFile(fileName, body, 0644) +} + +// Stat returns file info directly from the host filesystem. +func (n nativeVM) Stat(fileName string) (os.FileInfo, error) { + return os.Stat(fileName) +} diff --git a/environment/vm/native/hostinfo.go b/environment/vm/native/hostinfo.go new file mode 100644 index 00000000..392e36f1 --- /dev/null +++ b/environment/vm/native/hostinfo.go @@ -0,0 +1,87 @@ +package native + +import ( + "net" + "os" + "runtime" + "strconv" + "strings" + "syscall" +) + +// HostResources returns the host's CPU count, total memory (bytes), and root disk size (bytes). +// This replaces limautil.Instance() which returns VM resource info. +func HostResources() (cpu int, memory int64, disk int64) { + cpu = runtime.NumCPU() + + // Parse /proc/meminfo for total memory + if data, err := os.ReadFile("/proc/meminfo"); err == nil { + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(line, "MemTotal:") { + fields := strings.Fields(line) + if len(fields) >= 2 { + if kb, err := strconv.ParseInt(fields[1], 10, 64); err == nil { + memory = kb * 1024 // convert kB to bytes + } + } + break + } + } + } + + // Get root filesystem size + var stat syscall.Statfs_t + if err := syscall.Statfs("/", &stat); err == nil { + disk = int64(stat.Blocks) * int64(stat.Bsize) + } + + return +} + +// HostIPAddress returns the primary non-loopback IP address of the host. +// Falls back to "127.0.0.1" if no suitable address is found. +func HostIPAddress() string { + addrs, err := net.InterfaceAddrs() + if err != nil { + return "127.0.0.1" + } + + for _, addr := range addrs { + if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { + if ipnet.IP.To4() != nil { + return ipnet.IP.String() + } + } + } + + return "127.0.0.1" +} + +// HostPrimaryInterface returns the name of the primary network interface. +func HostPrimaryInterface() string { + ifaces, err := net.Interfaces() + if err != nil { + return "eth0" + } + + for _, iface := range ifaces { + // Skip loopback and down interfaces + if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 { + continue + } + + addrs, err := iface.Addrs() + if err != nil || len(addrs) == 0 { + continue + } + + // Return first interface with a non-loopback address + for _, addr := range addrs { + if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil { + return iface.Name + } + } + } + + return "eth0" +} diff --git a/environment/vm/native/native.go b/environment/vm/native/native.go new file mode 100644 index 00000000..4631b8ea --- /dev/null +++ b/environment/vm/native/native.go @@ -0,0 +1,197 @@ +package native + +import ( + "context" + "fmt" + "os" + "os/user" + "path/filepath" + + "github.com/abiosoft/colima/cli" + "github.com/abiosoft/colima/config" + "github.com/abiosoft/colima/config/configmanager" + "github.com/abiosoft/colima/environment" + "github.com/abiosoft/colima/environment/container/containerd" + "github.com/abiosoft/colima/environment/container/docker" + "github.com/abiosoft/colima/environment/container/incus" +) + +// New creates a new native VM that runs directly on the host without +// any virtualization. This is only intended for Linux hosts where container +// runtimes can run natively without a VM. +func New(host environment.HostActions) environment.VM { + return &nativeVM{ + host: host, + CommandChain: cli.New("vm"), + } +} + +var _ environment.VM = (*nativeVM)(nil) + +type nativeVM struct { + host environment.HostActions + cli.CommandChain + + + // keep config in case of restart + conf config.Config +} + +func (n nativeVM) Dependencies() []string { + // No external dependencies needed (no Lima/QEMU) + return nil +} + +func (n *nativeVM) Start(ctx context.Context, conf config.Config) error { + a := n.Init(ctx) + log := n.Logger(ctx) + + n.conf = conf + + if n.Running(ctx) { + log.Println("already running") + return nil + } + + a.Stage("starting (native mode)") + + // Save state first so other commands can detect this is a native instance + a.Add(func() error { + stateFile := config.CurrentProfile().StateFile() + stateDir := filepath.Dir(stateFile) + if err := os.MkdirAll(stateDir, 0755); err != nil { + return fmt.Errorf("error creating state directory: %w", err) + } + if err := configmanager.SaveToFile(conf, stateFile); err != nil { + return fmt.Errorf("error persisting Colima state: %w", err) + } + return nil + }) + + // Verify the container runtime is available on the host + a.Add(func() error { + return n.verifyRuntime(conf.Runtime) + }) + + return a.Exec() +} + +func (n nativeVM) Stop(ctx context.Context, force bool) error { + // In native mode, we don't stop a VM. The container runtime + // stop is handled by the container layer (app.go). + return nil +} + +func (n nativeVM) Teardown(ctx context.Context) error { + // Clean up the native config file + configFile := n.configFilePath() + _ = n.host.RunQuiet("rm", "-f", configFile) + + // Clean up the state directory (contains colima.yaml) + // This ensures 'colima list' won't show deleted native instances. + stateDir := config.CurrentProfile().LimaInstanceDir() + _ = n.host.RunQuiet("rm", "-rf", stateDir) + + return nil +} + +func (n nativeVM) Running(_ context.Context) bool { + // First check if this profile has been started by Colima + // (state file must exist) + if !n.Created() { + return false + } + + // Check if the primary container runtime service is active. + conf, err := configmanager.LoadInstance() + if err != nil { + // State file exists but can't be loaded — check common runtimes + for _, svc := range []string{"docker.service", "containerd.service", "incus.service"} { + if n.host.RunQuiet("systemctl", "is-active", svc) == nil { + return true + } + } + return false + } + + switch conf.Runtime { + case docker.Name: + return n.host.RunQuiet("systemctl", "is-active", "docker.service") == nil + case containerd.Name: + return n.host.RunQuiet("systemctl", "is-active", "containerd.service") == nil + case incus.Name: + return n.host.RunQuiet("systemctl", "is-active", "incus.service") == nil + } + + return false +} + +func (n *nativeVM) Restart(ctx context.Context) error { + if n.conf.Empty() { + return fmt.Errorf("cannot restart, instance not previously started") + } + + // In native mode, restart means re-running Start which re-verifies + // the runtime and re-saves state. There is no VM to restart. + return n.Start(ctx, n.conf) +} + +func (n nativeVM) Host() environment.HostActions { + return n.host +} + +func (n nativeVM) Env(s string) (string, error) { + return n.host.Env(s), nil +} + +func (n nativeVM) Created() bool { + _, err := n.host.Read(config.CurrentProfile().StateFile()) + return err == nil +} + +func (n nativeVM) User() (string, error) { + u, err := user.Current() + if err != nil { + return "", fmt.Errorf("error getting current user: %w", err) + } + return u.Username, nil +} + +func (n nativeVM) Arch() environment.Arch { + return environment.HostArch() +} + +// verifyRuntime checks that the specified container runtime is available on the host. +func (n nativeVM) verifyRuntime(runtime string) error { + if environment.IsNoneRuntime(runtime) { + return nil + } + + switch runtime { + case docker.Name: + // Check systemctl first, then fallback to docker binary existence + if n.host.RunQuiet("systemctl", "is-active", "docker.service") != nil { + // Not active via systemd, check if docker binary exists + if n.host.RunQuiet("which", "docker") != nil { + return fmt.Errorf("docker is not available on this host\n" + + "Install with: curl -fsSL https://get.docker.com | sh") + } + return fmt.Errorf("docker is installed but not running\n" + + "Start with: sudo systemctl start docker") + } + case containerd.Name: + if n.host.RunQuiet("systemctl", "is-active", "containerd.service") != nil { + return fmt.Errorf("containerd is not available on this host\n" + + "Install containerd and ensure it is running") + } + case incus.Name: + if n.host.RunQuiet("which", "incus") != nil { + return fmt.Errorf("incus is not available on this host\n" + + "Install incus and ensure it is running") + } + default: + return fmt.Errorf("unsupported runtime for native mode: %s", runtime) + } + + return nil +} diff --git a/environment/vm/native/shell.go b/environment/vm/native/shell.go new file mode 100644 index 00000000..bbe7e5ef --- /dev/null +++ b/environment/vm/native/shell.go @@ -0,0 +1,60 @@ +package native + +import ( + "fmt" + "io" + "os" +) + +// Run runs a command directly on the host. +// Unlike Lima's shell.go which prepends "lima" (SSH into VM), +// native mode executes commands directly. +func (n nativeVM) Run(args ...string) error { + return n.host.Run(args...) +} + +// RunQuiet runs a command on the host whilst suppressing output. +func (n nativeVM) RunQuiet(args ...string) error { + return n.host.RunQuiet(args...) +} + +// RunOutput runs a command on the host and returns its output. +func (n nativeVM) RunOutput(args ...string) (string, error) { + return n.host.RunOutput(args...) +} + +// RunInteractive runs a command on the host interactively. +func (n nativeVM) RunInteractive(args ...string) error { + return n.host.RunInteractive(args...) +} + +// RunWith runs a command on the host with custom stdin/stdout. +func (n nativeVM) RunWith(stdin io.Reader, stdout io.Writer, args ...string) error { + return n.host.RunWith(stdin, stdout, args...) +} + +// SSH opens an interactive session on the host. +// In native mode there is no VM to SSH into, so we either execute the +// provided command directly or open a shell. +func (n nativeVM) SSH(workingDir string, args ...string) error { + if len(args) > 0 { + // Execute the command directly on the host + if workingDir != "" { + return n.host.WithDir(workingDir).RunInteractive(args...) + } + return n.host.RunInteractive(args...) + } + + // No args: open an interactive shell + shell := os.Getenv("SHELL") + if shell == "" { + shell = "/bin/bash" + } + + if workingDir != "" { + return n.host.RunInteractive("sh", "-c", + fmt.Sprintf("cd %s && exec %s", workingDir, shell)) + } + + return n.host.RunInteractive(shell) +} diff --git a/model/docker.go b/model/docker.go index 0ab88210..7a4be7a6 100644 --- a/model/docker.go +++ b/model/docker.go @@ -7,8 +7,6 @@ import ( "time" "github.com/abiosoft/colima/environment" - "github.com/abiosoft/colima/environment/host" - "github.com/abiosoft/colima/environment/vm/lima" "github.com/abiosoft/colima/util/terminal" log "github.com/sirupsen/logrus" ) @@ -96,7 +94,7 @@ func findGGUFPath(guest environment.VM, modelHash string) (string, error) { // InspectDockerModel returns information about a Docker model. func InspectDockerModel(modelName string) (*DockerModelInfo, error) { - guest := lima.New(host.New()) + guest := newGuest() output, err := guest.RunOutput("docker", "model", "inspect", modelName) if err != nil { return nil, fmt.Errorf("error inspecting model %q: %w", modelName, err) @@ -112,7 +110,7 @@ func InspectDockerModel(modelName string) (*DockerModelInfo, error) { // SetupOrUpdateDocker reinstalls Docker Model Runner in the VM. func SetupOrUpdateDocker() error { - guest := lima.New(host.New()) + guest := newGuest() log.Println("reinstalling Docker Model Runner...") @@ -135,7 +133,7 @@ func SetupOrUpdateDocker() error { // GetDockerModelVersion returns the Docker Model Runner version in the VM. // Returns empty string if version cannot be determined. func GetDockerModelVersion() string { - guest := lima.New(host.New()) + guest := newGuest() output, err := guest.RunOutput("docker", "model", "version") if err != nil { return "" @@ -146,7 +144,7 @@ func GetDockerModelVersion() string { // EnsureDockerModel ensures a Docker model is available, pulling if necessary. // Returns the normalized model name (resolving aliases like hf.co → huggingface.co). func EnsureDockerModel(modelName string) (string, error) { - guest := lima.New(host.New()) + guest := newGuest() // Try to inspect the model first modelInfo, err := InspectDockerModel(modelName) @@ -182,7 +180,7 @@ type DockerModelServeConfig struct { // The function blocks until interrupted (Ctrl-C) or llama-server exits. // Note: Call EnsureDockerModel first to ensure the model is available. func ServeDockerModel(cfg DockerModelServeConfig) error { - guest := lima.New(host.New()) + guest := newGuest() // Set defaults if cfg.Threads <= 0 { @@ -309,7 +307,7 @@ func stopSocat(guest environment.VM, port int) { // StopDockerModelServe stops a Docker model serve instance. func StopDockerModelServe(port int) error { - guest := lima.New(host.New()) + guest := newGuest() // Stop the socat proxy on the VM stopCmd := fmt.Sprintf("pkill -f 'socat.*TCP-LISTEN:%d' 2>/dev/null || true", port) @@ -326,7 +324,7 @@ func StopDockerModelServe(port int) error { // IsDockerModelServeRunning checks if a serve instance is running on the given port. func IsDockerModelServeRunning(port int) bool { - guest := lima.New(host.New()) + guest := newGuest() // Check if socat is running for this port checkCmd := fmt.Sprintf("pgrep -f 'socat.*TCP-LISTEN:%d' > /dev/null 2>&1", port) diff --git a/model/guest.go b/model/guest.go new file mode 100644 index 00000000..327ba3cd --- /dev/null +++ b/model/guest.go @@ -0,0 +1,21 @@ +package model + +import ( + "github.com/abiosoft/colima/config/configmanager" + "github.com/abiosoft/colima/environment" + "github.com/abiosoft/colima/environment/host" + "github.com/abiosoft/colima/environment/vm/lima" + "github.com/abiosoft/colima/environment/vm/native" + "github.com/abiosoft/colima/util" +) + +// newGuest creates the appropriate VM based on the current instance config. +// This centralizes VM creation that was previously hardcoded as lima.New() +// in 10+ places across the model package. +func newGuest() environment.VM { + conf, _ := configmanager.LoadInstance() + if conf.VMType == "native" && util.Linux() { + return native.New(host.New()) + } + return lima.New(host.New()) +} diff --git a/model/ramalama.go b/model/ramalama.go index 7d6172b4..ad0f9fc4 100644 --- a/model/ramalama.go +++ b/model/ramalama.go @@ -7,8 +7,6 @@ import ( "strings" "time" - "github.com/abiosoft/colima/environment/host" - "github.com/abiosoft/colima/environment/vm/lima" "github.com/abiosoft/colima/store" log "github.com/sirupsen/logrus" ) @@ -24,7 +22,7 @@ func SetupOrUpdateRamalama() error { // GetRamalamaVersion returns the currently installed ramalama version in the VM. // Returns empty string if ramalama is not installed or version cannot be determined. func GetRamalamaVersion() string { - guest := lima.New(host.New()) + guest := newGuest() output, err := guest.RunOutput("sh", "-c", `export PATH="$HOME/.local/bin:$PATH"; ramalama version 2>/dev/null`) if err != nil { return "" @@ -71,7 +69,7 @@ type ramalamaModel struct { // listRamalamaModels returns all locally available ramalama models. func listRamalamaModels() ([]ramalamaModel, error) { - guest := lima.New(host.New()) + guest := newGuest() output, err := guest.RunOutput("sh", "-c", `export PATH="$HOME/.local/bin:$PATH"; ramalama ls --json 2>/dev/null`) if err != nil { return nil, fmt.Errorf("error listing models: %w", err) @@ -131,7 +129,7 @@ func EnsureRamalamaModel(modelName string) error { } // Model not found locally, pull it - guest := lima.New(host.New()) + guest := newGuest() shellCmd := fmt.Sprintf( `export RAMALAMA_CONTAINER_ENGINE=docker PATH="$HOME/.local/bin:$PATH"; ramalama pull %s`, modelName, @@ -146,7 +144,7 @@ func EnsureRamalamaModel(modelName string) error { // ProvisionRamalama installs ramalama and its dependencies in the VM. func ProvisionRamalama() error { - guest := lima.New(host.New()) + guest := newGuest() script := `set -e export PATH="$HOME/.local/bin:$PATH" diff --git a/model/runner.go b/model/runner.go index b0e6dec9..f9d7da9e 100644 --- a/model/runner.go +++ b/model/runner.go @@ -10,8 +10,6 @@ import ( "github.com/abiosoft/colima/config" "github.com/abiosoft/colima/config/configmanager" "github.com/abiosoft/colima/environment/container/docker" - "github.com/abiosoft/colima/environment/host" - "github.com/abiosoft/colima/environment/vm/lima" "github.com/abiosoft/colima/environment/vm/lima/limaconfig" "github.com/abiosoft/colima/store" "github.com/abiosoft/colima/util" @@ -97,11 +95,19 @@ func validateCommonPrerequisites(a app.App) error { "Start colima with: colima start --runtime docker --vm-type krunkit", r) } - // check VM type is krunkit (required for GPU access) + // check VM type requirements conf, err := configmanager.LoadInstance() if err != nil { return fmt.Errorf("error loading instance config: %w", err) } + + // native mode: colima model is not yet supported + if conf.VMType == "native" { + return fmt.Errorf("'colima model' is not yet supported in native mode\n" + + "Use Docker directly with NVIDIA Container Toolkit for GPU workloads") + } + + // check VM type is krunkit (required for GPU access) if conf.VMType != limaconfig.Krunkit { return fmt.Errorf("'colima model' requires krunkit VM type for GPU access, current VM type is %s\n"+ "Start colima with: colima start --runtime docker --vm-type krunkit", conf.VMType) @@ -179,7 +185,7 @@ func GetFirstModel() (string, error) { // listDockerModels returns all available models from docker model list. func listDockerModels() ([]dockerModel, error) { - guest := lima.New(host.New()) + guest := newGuest() output, err := guest.RunOutput("docker", "model", "list", "--json") if err != nil { return nil, fmt.Errorf("error listing models: %w", err) @@ -351,7 +357,7 @@ func (r *ramalamaRunner) EnsureModel(modelName string) (string, error) { // Serve starts serving a model using ramalama. func (r *ramalamaRunner) Serve(modelName string, port int) error { - guest := lima.New(host.New()) + guest := newGuest() // ramalama serve with GPU support and custom port shellCmd := fmt.Sprintf( diff --git a/util/linux.go b/util/linux.go new file mode 100644 index 00000000..40e96022 --- /dev/null +++ b/util/linux.go @@ -0,0 +1,6 @@ +package util + +import "runtime" + +// Linux returns if the current OS is Linux. +func Linux() bool { return runtime.GOOS == "linux" }