diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ee2a6fc..5a9c303 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -9,7 +9,7 @@ permissions: contents: read env: - DAGGER_VERSION: "0.20.1" + DAGGER_VERSION: "0.20.6" concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/.github/workflows/cut-release.yaml b/.github/workflows/cut-release.yaml index 2c677a2..33a1eba 100644 --- a/.github/workflows/cut-release.yaml +++ b/.github/workflows/cut-release.yaml @@ -7,7 +7,7 @@ permissions: contents: write env: - DAGGER_VERSION: "0.20.1" + DAGGER_VERSION: "0.20.6" jobs: create-release: diff --git a/.github/workflows/nightly.yaml b/.github/workflows/nightly.yaml index a1ef6ce..b6dd050 100644 --- a/.github/workflows/nightly.yaml +++ b/.github/workflows/nightly.yaml @@ -10,7 +10,7 @@ permissions: contents: write env: - DAGGER_VERSION: "0.20.1" + DAGGER_VERSION: "0.20.6" jobs: check: diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index e892ef9..fee2a02 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -9,7 +9,7 @@ permissions: pull-requests: read env: - DAGGER_VERSION: "0.20.1" + DAGGER_VERSION: "0.20.6" concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index f887dbf..0bb26b0 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -5,7 +5,7 @@ on: types: [published] env: - DAGGER_VERSION: "0.20.1" + DAGGER_VERSION: "0.20.6" jobs: build-linux: diff --git a/cmd/vmhost/platform_darwin_arm64.go b/cmd/vmhost/platform_darwin_arm64.go index 8c5c375..9866d87 100644 --- a/cmd/vmhost/platform_darwin_arm64.go +++ b/cmd/vmhost/platform_darwin_arm64.go @@ -63,7 +63,7 @@ func getPlatformConfig(_ string) *vm.QEMUPlatformConfig { return &vm.QEMUPlatformConfig{ Accelerator: "hvf", Binary: "qemu-system-aarch64", - MachineType: "virt", + MachineType: "virt,highmem=on", EFISearchPaths: []string{ "{qemu_prefix}/share/qemu/edk2-aarch64-code.fd", "/opt/homebrew/share/qemu/edk2-aarch64-code.fd", diff --git a/cmd/vmhost/platform_linux.go b/cmd/vmhost/platform_linux.go deleted file mode 100644 index cd22aa8..0000000 --- a/cmd/vmhost/platform_linux.go +++ /dev/null @@ -1,36 +0,0 @@ -//go:build linux - -package vmhostcmder - -import ( - "context" - "fmt" - "log" - - "github.com/papercomputeco/masterblaster/pkg/vm" - "github.com/papercomputeco/masterblaster/pkg/vmhost" -) - -func bootAppleVirt(_ context.Context, _ string, _ *vm.Instance, _ *log.Logger) (vmhost.VMController, error) { - return nil, fmt.Errorf("apple Virtualization.framework is only available on macOS/Apple Silicon") -} - -// getPlatformConfig returns the QEMU platform configuration for Linux. -func getPlatformConfig(_ string) *vm.QEMUPlatformConfig { - return &vm.QEMUPlatformConfig{ - Accelerator: "kvm", - Binary: "qemu-system-aarch64", - MachineType: "virt", - EFISearchPaths: []string{ - "{qemu_prefix}/share/qemu/edk2-aarch64-code.fd", - "/usr/share/qemu/edk2-aarch64-code.fd", - "/usr/share/AAVMF/AAVMF_CODE.fd", - "/usr/share/edk2/aarch64/QEMU_CODE.fd", - }, - ControlPlaneMode: "vsock", - VsockDevice: "vhost-vsock-pci", - DirectKernelBoot: true, - DiskAIO: "io_uring", - DiskCache: "none", - } -} diff --git a/cmd/vmhost/platform_linux_amd64.go b/cmd/vmhost/platform_linux_amd64.go new file mode 100644 index 0000000..6df8161 --- /dev/null +++ b/cmd/vmhost/platform_linux_amd64.go @@ -0,0 +1,22 @@ +//go:build linux && amd64 + +package vmhostcmder + +import ( + "context" + "fmt" + "log" + + "github.com/papercomputeco/masterblaster/pkg/vm" + "github.com/papercomputeco/masterblaster/pkg/vmhost" +) + +func bootAppleVirt(_ context.Context, _ string, _ *vm.Instance, _ *log.Logger) (vmhost.VMController, error) { + return nil, fmt.Errorf("apple Virtualization.framework is only available on macOS/Apple Silicon") +} + +// getPlatformConfig returns the QEMU platform configuration for linux/amd64. +// Delegates to pkg/vm so the config lives in exactly one place. +func getPlatformConfig(_ string) *vm.QEMUPlatformConfig { + return vm.LinuxAMD64PlatformConfig() +} diff --git a/cmd/vmhost/platform_linux_arm64.go b/cmd/vmhost/platform_linux_arm64.go new file mode 100644 index 0000000..16dc3eb --- /dev/null +++ b/cmd/vmhost/platform_linux_arm64.go @@ -0,0 +1,22 @@ +//go:build linux && arm64 + +package vmhostcmder + +import ( + "context" + "fmt" + "log" + + "github.com/papercomputeco/masterblaster/pkg/vm" + "github.com/papercomputeco/masterblaster/pkg/vmhost" +) + +func bootAppleVirt(_ context.Context, _ string, _ *vm.Instance, _ *log.Logger) (vmhost.VMController, error) { + return nil, fmt.Errorf("apple Virtualization.framework is only available on macOS/Apple Silicon") +} + +// getPlatformConfig returns the QEMU platform configuration for linux/arm64. +// Delegates to pkg/vm so the config lives in exactly one place. +func getPlatformConfig(_ string) *vm.QEMUPlatformConfig { + return vm.LinuxARM64PlatformConfig() +} diff --git a/dagger.json b/dagger.json index ce17dd5..b78dd4b 100644 --- a/dagger.json +++ b/dagger.json @@ -1,6 +1,6 @@ { "name": "masterblaster", - "engineVersion": "v0.20.1", + "engineVersion": "v0.20.6", "sdk": { "source": "go" }, diff --git a/flake.lock b/flake.lock index c26d507..5cb56de 100644 --- a/flake.lock +++ b/flake.lock @@ -7,11 +7,11 @@ ] }, "locked": { - "lastModified": 1772742297, - "narHash": "sha256-uMoLVE37LnQHt0KFcZWXixBIIIT5HLQlK2XuZFQvui8=", + "lastModified": 1776312396, + "narHash": "sha256-+kIlfrvisYdTgdix932MnQEKjq0yJsJL+vnG/I6QJ68=", "owner": "dagger", "repo": "nix", - "rev": "55a422614715144f73b5090b6dbe0864d47416ef", + "rev": "851584fd8093ab942b4aa833cdef53050b27279b", "type": "github" }, "original": { @@ -40,11 +40,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1770107345, - "narHash": "sha256-tbS0Ebx2PiA1FRW8mt8oejR0qMXmziJmPaU1d4kYY9g=", + "lastModified": 1776949667, + "narHash": "sha256-GMSVw35Q+294GlrTUKlx087E31z7KurReQ1YHSKp5iw=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "4533d9293756b63904b7238acb84ac8fe4c8c2c4", + "rev": "01fbdeef22b76df85ea168fbfe1bfd9e63681b30", "type": "github" }, "original": { diff --git a/go.mod b/go.mod index 9332059..67db433 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/google/go-containerregistry v0.21.0 github.com/google/uuid v1.6.0 github.com/klauspost/compress v1.18.4 + github.com/mdlayher/vsock v1.2.1 github.com/onsi/ginkgo/v2 v2.28.1 github.com/onsi/gomega v1.39.1 github.com/posthog/posthog-go v1.10.0 @@ -51,6 +52,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mdlayher/socket v0.4.1 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect diff --git a/go.sum b/go.sum index 9c8e4e8..383ddb7 100644 --- a/go.sum +++ b/go.sum @@ -112,6 +112,10 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U= +github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA= +github.com/mdlayher/vsock v1.2.1 h1:pC1mTJTvjo1r9n9fbm7S1j04rCgCzhCOS5DY0zqHlnQ= +github.com/mdlayher/vsock v1.2.1/go.mod h1:NRfCibel++DgeMD8z/hP+PPTjlNJsdPOmxcnENvE+SE= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= diff --git a/pkg/mixtapes/pull.go b/pkg/mixtapes/pull.go index 01e3ebc..524bbeb 100644 --- a/pkg/mixtapes/pull.go +++ b/pkg/mixtapes/pull.go @@ -20,6 +20,7 @@ import ( "io" "os" "path/filepath" + "runtime" "strings" "github.com/klauspost/compress/zstd" @@ -134,8 +135,14 @@ func resolveImage(desc *remote.Descriptor) (v1.Image, error) { } } -// resolveFromIndex parses an OCI index and selects the raw-format manifest. -// Falls back to the first manifest if no raw format is found. +// resolveFromIndex parses an OCI index and selects the best manifest for the +// running host, preferring raw disk format. +// +// Selection priority (see selectIndexManifest for the pure policy): +// 1. Platform matches host GOOS/GOARCH AND has a raw disk layer +// 2. Platform matches host GOOS/GOARCH (any format) +// 3. Any manifest with a raw disk layer (index didn't set Platform) +// 4. First manifest (last-resort fallback) func resolveFromIndex(desc *remote.Descriptor) (v1.Image, error) { idx, err := desc.ImageIndex() if err != nil { @@ -147,27 +154,97 @@ func resolveFromIndex(desc *remote.Descriptor) (v1.Image, error) { return nil, fmt.Errorf("reading index manifest: %w", err) } - if len(indexManifest.Manifests) == 0 { - return nil, fmt.Errorf("index manifest contains no entries") + hasRaw := func(h v1.Hash) (bool, error) { + img, err := idx.Image(h) + if err != nil { + return false, err + } + return hasRawDiskLayer(img), nil } - // Try each manifest to find the raw format. - // The raw format is listed first by convention, but we check explicitly. - for _, m := range indexManifest.Manifests { - img, err := idx.Image(m.Digest) - if err != nil { - continue + digest, err := selectIndexManifest(indexManifest.Manifests, runtime.GOOS, runtime.GOARCH, hasRaw) + if err != nil { + return nil, err + } + return idx.Image(digest) +} + +// hasRawDiskLayerFunc reports whether the manifest addressed by a given digest +// contains a raw disk layer. Parameterised so selectIndexManifest stays +// testable without a real OCI client. +type hasRawDiskLayerFunc func(v1.Hash) (bool, error) + +// selectIndexManifest picks the best manifest digest from an OCI index's +// manifest descriptors for the given host GOOS/GOARCH. See resolveFromIndex +// for priority order. +func selectIndexManifest( + manifests []v1.Descriptor, + goos, goarch string, + hasRaw hasRawDiskLayerFunc, +) (v1.Hash, error) { + if len(manifests) == 0 { + return v1.Hash{}, fmt.Errorf("index manifest contains no entries") + } + + platformMatch := func(p *v1.Platform) bool { + return p != nil && p.OS == goos && p.Architecture == goarch + } + + var ( + platformRaw, platformAny, anyRaw v1.Hash + hasT1, hasT2, hasT3 bool + ) + + for _, m := range manifests { + // Cache per-manifest raw-probe so we only load each manifest once. + var ( + rawChecked, rawOK bool + ) + checkRaw := func() bool { + if rawChecked { + return rawOK + } + rawChecked = true + ok, err := hasRaw(m.Digest) + rawOK = err == nil && ok + return rawOK + } + + if platformMatch(m.Platform) { + if !hasT2 { + platformAny = m.Digest + hasT2 = true + } + if !hasT1 && checkRaw() { + platformRaw = m.Digest + hasT1 = true + } } - if hasRawDiskLayer(img) { - ui.Info("Selected raw disk format from index") - return img, nil + if !hasT3 && checkRaw() { + anyRaw = m.Digest + hasT3 = true + } + + // Early exit: we have the top-priority match. + if hasT1 { + break } } - // Fallback: use the first manifest. - ui.Warn("No raw disk format found in index, using first manifest") - firstDigest := indexManifest.Manifests[0].Digest - return idx.Image(firstDigest) + switch { + case hasT1: + ui.Info("Selected raw disk manifest for %s/%s from index", goos, goarch) + return platformRaw, nil + case hasT2: + ui.Warn("No raw format for %s/%s in index; using platform-matched manifest", goos, goarch) + return platformAny, nil + case hasT3: + ui.Warn("No %s/%s manifest in index; using any raw-format manifest (may not run on this host)", goos, goarch) + return anyRaw, nil + default: + ui.Warn("No raw format in index, using first manifest") + return manifests[0].Digest, nil + } } // hasRawDiskLayer checks whether an image contains a raw disk layer. diff --git a/pkg/mixtapes/pull_test.go b/pkg/mixtapes/pull_test.go new file mode 100644 index 0000000..1a413f9 --- /dev/null +++ b/pkg/mixtapes/pull_test.go @@ -0,0 +1,134 @@ +package mixtapes + +import ( + "testing" + + v1 "github.com/google/go-containerregistry/pkg/v1" +) + +// digest produces a v1.Hash from a short identifier so tests stay readable. +func digest(hex string) v1.Hash { + // Pad to 64 hex chars so v1.Hash accepts it. + const zeros = "0000000000000000000000000000000000000000000000000000000000000000" + return v1.Hash{Algorithm: "sha256", Hex: (hex + zeros)[:64]} +} + +// manifest constructs an OCI descriptor with the given arch (or no platform +// if arch is ""). +func manifest(d v1.Hash, goos, arch string) v1.Descriptor { + m := v1.Descriptor{Digest: d} + if arch != "" { + m.Platform = &v1.Platform{OS: goos, Architecture: arch} + } + return m +} + +func TestSelectIndexManifest(t *testing.T) { + armRaw := digest("aa1") + amdRaw := digest("bb2") + amdQcow := digest("cc3") + legacyRaw := digest("dd4") // no platform set + legacyQcow := digest("ee5") + + // `raws` encodes which digests our fake `hasRaw` says are raw disks. + type args struct { + manifests []v1.Descriptor + goos string + goarch string + raws map[v1.Hash]bool + } + + cases := []struct { + name string + args args + want v1.Hash + }{ + { + name: "multi-arch index: host amd64 picks amd64 raw", + args: args{ + manifests: []v1.Descriptor{ + manifest(armRaw, "linux", "arm64"), + manifest(amdRaw, "linux", "amd64"), + manifest(amdQcow, "linux", "amd64"), + }, + goos: "linux", + goarch: "amd64", + raws: map[v1.Hash]bool{armRaw: true, amdRaw: true}, + }, + want: amdRaw, + }, + { + name: "multi-arch index: host arm64 picks arm64 raw", + args: args{ + manifests: []v1.Descriptor{ + manifest(armRaw, "linux", "arm64"), + manifest(amdRaw, "linux", "amd64"), + }, + goos: "linux", + goarch: "arm64", + raws: map[v1.Hash]bool{armRaw: true, amdRaw: true}, + }, + want: armRaw, + }, + { + name: "platform match but no raw for host — uses qcow2", + args: args{ + manifests: []v1.Descriptor{ + manifest(armRaw, "linux", "arm64"), + manifest(amdQcow, "linux", "amd64"), + }, + goos: "linux", + goarch: "amd64", + raws: map[v1.Hash]bool{armRaw: true}, + }, + want: amdQcow, + }, + { + name: "legacy single-arch index without Platform — uses raw fallback", + args: args{ + manifests: []v1.Descriptor{ + manifest(legacyRaw, "", ""), + manifest(legacyQcow, "", ""), + }, + goos: "linux", + goarch: "amd64", + raws: map[v1.Hash]bool{legacyRaw: true}, + }, + want: legacyRaw, + }, + { + name: "no platform match, no raw — uses first manifest", + args: args{ + manifests: []v1.Descriptor{ + manifest(armRaw, "linux", "arm64"), + }, + goos: "linux", + goarch: "amd64", + raws: map[v1.Hash]bool{}, + }, + want: armRaw, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + hasRaw := func(h v1.Hash) (bool, error) { + return tc.args.raws[h], nil + } + got, err := selectIndexManifest(tc.args.manifests, tc.args.goos, tc.args.goarch, hasRaw) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if got != tc.want { + t.Errorf("got %s, want %s", got, tc.want) + } + }) + } +} + +func TestSelectIndexManifestEmpty(t *testing.T) { + _, err := selectIndexManifest(nil, "linux", "amd64", func(v1.Hash) (bool, error) { return false, nil }) + if err == nil { + t.Error("expected error for empty manifest list") + } +} diff --git a/pkg/vm/backend_darwin_arm64.go b/pkg/vm/backend_darwin_arm64.go index fad106e..9cce559 100644 --- a/pkg/vm/backend_darwin_arm64.go +++ b/pkg/vm/backend_darwin_arm64.go @@ -36,7 +36,7 @@ func NewPlatformBackend(baseDir string) (Backend, error) { platform := &QEMUPlatformConfig{ Accelerator: "hvf", Binary: "qemu-system-aarch64", - MachineType: "virt", + MachineType: "virt,highmem=on", EFISearchPaths: []string{ "{qemu_prefix}/share/qemu/edk2-aarch64-code.fd", "/opt/homebrew/share/qemu/edk2-aarch64-code.fd", diff --git a/pkg/vm/backend_linux.go b/pkg/vm/backend_linux.go deleted file mode 100644 index 6272472..0000000 --- a/pkg/vm/backend_linux.go +++ /dev/null @@ -1,29 +0,0 @@ -//go:build linux - -package vm - -// NewPlatformBackend returns the appropriate VM backend for Linux. -// This returns the QEMU backend configured for KVM acceleration with -// native vsock support via vhost-vsock-pci. -func NewPlatformBackend(baseDir string) (Backend, error) { - // TODO: Implement KVM/libvirt backend for better performance. - - platform := &QEMUPlatformConfig{ - Accelerator: "kvm", - Binary: "qemu-system-aarch64", - MachineType: "virt", - EFISearchPaths: []string{ - "{qemu_prefix}/share/qemu/edk2-aarch64-code.fd", - "/usr/share/qemu/edk2-aarch64-code.fd", - "/usr/share/AAVMF/AAVMF_CODE.fd", - "/usr/share/edk2/aarch64/QEMU_CODE.fd", - }, - ControlPlaneMode: "vsock", - VsockDevice: "vhost-vsock-pci", - DirectKernelBoot: true, - DiskAIO: "io_uring", - DiskCache: "none", - } - - return NewQEMUBackend(baseDir, platform), nil -} diff --git a/pkg/vm/backend_linux_amd64.go b/pkg/vm/backend_linux_amd64.go new file mode 100644 index 0000000..7d8b883 --- /dev/null +++ b/pkg/vm/backend_linux_amd64.go @@ -0,0 +1,42 @@ +//go:build linux && amd64 + +package vm + +// LinuxAMD64PlatformConfig returns the QEMU platform config for +// linux/amd64: KVM acceleration, q35 chipset, AF_VSOCK control plane +// via vhost-vsock-pci. boot() falls back to TCP hostfwd at runtime when +// the host lacks vhost_vsock (WSL2, kernels without the module loaded). +// +// EFI firmware paths cover the common distros: +// - NixOS / generic: edk2-x86_64-code.fd next to the QEMU prefix +// - Debian/Ubuntu: /usr/share/OVMF/OVMF_CODE.fd +// - Fedora/RHEL: /usr/share/edk2/ovmf/OVMF_CODE.fd +// +// Exported so cmd/vmhost can share the same config without duplicating +// the struct literal. +func LinuxAMD64PlatformConfig() *QEMUPlatformConfig { + return &QEMUPlatformConfig{ + Accelerator: "kvm", + Binary: "qemu-system-x86_64", + // q35 is the modern x86 chipset; i440fx ("pc") is legacy. + MachineType: "q35", + EFISearchPaths: []string{ + "{qemu_prefix}/share/qemu/edk2-x86_64-code.fd", + "/usr/share/qemu/edk2-x86_64-code.fd", + "/usr/share/OVMF/OVMF_CODE.fd", + "/usr/share/OVMF/OVMF_CODE_4M.fd", + "/usr/share/edk2/ovmf/OVMF_CODE.fd", + "/usr/share/edk2/x64/OVMF_CODE.fd", + }, + ControlPlaneMode: "vsock", + VsockDevice: "vhost-vsock-pci", + DirectKernelBoot: true, + DiskAIO: "io_uring", + DiskCache: "none", + } +} + +// NewPlatformBackend returns the QEMU backend for linux/amd64. +func NewPlatformBackend(baseDir string) (Backend, error) { + return NewQEMUBackend(baseDir, LinuxAMD64PlatformConfig()), nil +} diff --git a/pkg/vm/backend_linux_arm64.go b/pkg/vm/backend_linux_arm64.go new file mode 100644 index 0000000..1087f45 --- /dev/null +++ b/pkg/vm/backend_linux_arm64.go @@ -0,0 +1,37 @@ +//go:build linux && arm64 + +package vm + +// LinuxARM64PlatformConfig returns the QEMU platform config for +// linux/arm64: KVM acceleration on the virt machine with native vsock +// via vhost-vsock-pci. +// +// Exported so cmd/vmhost can share the same config without duplicating +// the struct literal. +func LinuxARM64PlatformConfig() *QEMUPlatformConfig { + return &QEMUPlatformConfig{ + Accelerator: "kvm", + Binary: "qemu-system-aarch64", + // `highmem=on` extends the aarch64 virt machine's 40-bit IPA space + // so guests with >3GiB RAM map correctly. q35 doesn't accept this + // option — don't copy it into the amd64 backend. + MachineType: "virt,highmem=on", + EFISearchPaths: []string{ + "{qemu_prefix}/share/qemu/edk2-aarch64-code.fd", + "/usr/share/qemu/edk2-aarch64-code.fd", + "/usr/share/AAVMF/AAVMF_CODE.fd", + "/usr/share/edk2/aarch64/QEMU_CODE.fd", + }, + ControlPlaneMode: "vsock", + VsockDevice: "vhost-vsock-pci", + DirectKernelBoot: true, + DiskAIO: "io_uring", + DiskCache: "none", + } +} + +// NewPlatformBackend returns the QEMU backend for linux/arm64. +func NewPlatformBackend(baseDir string) (Backend, error) { + // TODO: Implement KVM/libvirt backend for better performance. + return NewQEMUBackend(baseDir, LinuxARM64PlatformConfig()), nil +} diff --git a/pkg/vm/prepare.go b/pkg/vm/prepare.go index 4d1b836..a92a815 100644 --- a/pkg/vm/prepare.go +++ b/pkg/vm/prepare.go @@ -140,6 +140,7 @@ func LoadInstanceFromDisk(baseDir, name string) (*Instance, error) { QMPSocket: filepath.Join(vmDir, "qmp.sock"), SSHPort: state.SSHPort, VsockPort: state.VsockPort, + VsockCID: state.VsockCID, } // Try to read QEMU PID (if applicable) diff --git a/pkg/vm/qemu.go b/pkg/vm/qemu.go index 90f1c1c..37cfba8 100644 --- a/pkg/vm/qemu.go +++ b/pkg/vm/qemu.go @@ -3,6 +3,7 @@ package vm import ( "context" "fmt" + "log" "os" "os/exec" "path/filepath" @@ -169,16 +170,32 @@ func (q *QEMUBackend) boot(ctx context.Context, inst *Instance, cfg *config.Jcar } inst.SSHPort = sshPort - // Allocate a TCP port for the stereosd control plane. - // TODO(@jpmcb): Once native AF_VSOCK transport is implemented for - // Linux/KVM, this can be made conditional on ControlPlaneMode == "tcp". - // For now, all platforms use TCP through QEMU user-mode networking - // and stereosd listens on TCP via --listen-mode auto. - vsockTCPPort, err := allocatePort() - if err != nil { - return fmt.Errorf("allocating control plane port: %w", err) + // Resolve effective control plane mode. The platform may request vsock, + // but if this host doesn't actually have vhost-vsock (WSL2, or a kernel + // without the module loaded) we fall back to TCP hostfwd rather than + // fail the boot. + effectiveMode := q.platform.ControlPlaneMode + if effectiveMode == "vsock" && !HostHasVsock() { + log.Printf("vsock: host lacks /dev/vhost-vsock (or is running under WSL2); falling back to TCP control plane") + effectiveMode = "tcp" + } + + if effectiveMode == "vsock" { + cid, err := allocateVsockCID(q.baseDir) + if err != nil { + return fmt.Errorf("allocating vsock CID: %w", err) + } + inst.VsockCID = cid + inst.VsockPort = 0 // no TCP hostfwd needed + } else { + // TCP control plane: allocate a host port and forward it to the + // guest's stereosd listener through SLIRP. + vsockTCPPort, err := allocatePort() + if err != nil { + return fmt.Errorf("allocating control plane port: %w", err) + } + inst.VsockPort = vsockTCPPort } - inst.VsockPort = vsockTCPPort inst.QMPSocket = filepath.Join(inst.Dir, "qmp.sock") @@ -203,6 +220,7 @@ func (q *QEMUBackend) boot(ctx context.Context, inst *Instance, cfg *config.Jcar NetworkMode: cfg.Network.Mode, SSHPort: inst.SSHPort, VsockPort: inst.VsockPort, + VsockCID: inst.VsockCID, SSHKeyPath: sshKeyPath, Backend: "qemu", } @@ -265,10 +283,30 @@ func (q *QEMUBackend) WaitQEMU() error { return q.qemuCmd.Wait() } +// clearVsockCIDOnStop persists VsockCID=0 to state.json once the VM is +// confirmed stopped, so a subsequent `mb start` can reuse the CID via +// allocateVsockCID instead of marking it permanently consumed. Idempotent +// and best-effort: a missing state file or already-zero CID is a no-op, +// and a write failure is swallowed (the worst case is that the next +// allocation skips this CID, which is harmless given the 2^31 space). +func clearVsockCIDOnStop(inst *Instance) { + if inst.VMState != StateStopped { + return + } + state, err := loadState(inst.Dir) + if err != nil || state.VsockCID == 0 { + return + } + state.VsockCID = 0 + _ = saveState(inst.Dir, state) + inst.VsockCID = 0 +} + // Down gracefully stops the VM. func (q *QEMUBackend) Down(ctx context.Context, inst *Instance, timeout time.Duration) error { + defer clearVsockCIDOnStop(inst) // First try stereosd shutdown (preferred: allows stereosd to unmount, sync, etc.) - if inst.VsockPort > 0 || q.platform.ControlPlaneMode == "vsock" { + if inst.VsockPort > 0 || inst.VsockCID > 0 { if err := q.controlPlaneShutdown(ctx, inst); err == nil { // Wait for process to exit if q.waitForExit(ctx, inst, timeout) { @@ -299,6 +337,7 @@ func (q *QEMUBackend) Down(ctx context.Context, inst *Instance, timeout time.Dur // ForceDown immediately terminates the VM process. func (q *QEMUBackend) ForceDown(_ context.Context, inst *Instance) error { + defer clearVsockCIDOnStop(inst) if inst.PID == 0 { pidData, err := os.ReadFile(inst.PIDFilePath()) if err != nil { @@ -449,6 +488,7 @@ func (q *QEMUBackend) LoadInstance(name string) (*Instance, error) { QMPSocket: filepath.Join(vmDir, "qmp.sock"), SSHPort: state.SSHPort, VsockPort: state.VsockPort, + VsockCID: state.VsockCID, SSHKeyPath: state.SSHKeyPath, } @@ -477,13 +517,32 @@ func (q *QEMUBackend) startQEMU(_ context.Context, inst *Instance, cfg *config.J return fmt.Errorf("building QEMU args: %w", err) } + // Capture QEMU stderr/stdout to a per-VM file. Without this, a QEMU + // launch failure (bad machine type option, missing firmware path, etc.) + // leaves no trace: vmhost.log only records the "booting" intent, and + // the serial log never gets populated because QEMU exited before + // opening it. + // + // Append rather than truncate so a retry after a failed boot does not + // overwrite the prior crash log — the whole point of capturing this + // is forensic, and the most common time to look at it is right after + // re-running `mb start`. + qemuLogPath := filepath.Join(inst.Dir, "qemu.log") + qemuLog, err := os.OpenFile(qemuLogPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + return fmt.Errorf("opening qemu log %s: %w", qemuLogPath, err) + } + cmd := exec.Command(qemuBin, args...) - cmd.Stderr = os.Stderr - cmd.Stdout = os.Stdout + cmd.Stderr = qemuLog + cmd.Stdout = qemuLog if err := cmd.Start(); err != nil { - return fmt.Errorf("starting QEMU: %w\nCheck serial log: %s", err, inst.SerialLogPath()) + _ = qemuLog.Close() + return fmt.Errorf("starting QEMU: %w\nCheck qemu log: %s (serial log: %s)", err, qemuLogPath, inst.SerialLogPath()) } + // Child has its own FD; safe to close the parent's copy. + _ = qemuLog.Close() q.qemuCmd = cmd inst.PID = cmd.Process.Pid @@ -519,8 +578,11 @@ func (q *QEMUBackend) buildArgs(inst *Instance, cfg *config.JcardConfig, kernelA // Parse memory for QEMU (convert GiB -> G, MiB -> M, etc.) memory := convertSizeForQEMU(cfg.Resources.Memory) - // Machine type and acceleration from platform config - machineArg := fmt.Sprintf("%s,accel=%s,highmem=on", + // Machine type and acceleration from platform config. The platform's + // MachineType field is the full machine spec including any per-platform + // options (e.g. "virt,highmem=on" for aarch64, "q35" for x86_64 — the + // `highmem` option is aarch64-virt-specific and QEMU rejects it on q35). + machineArg := fmt.Sprintf("%s,accel=%s", q.platform.DefaultMachineType(), q.platform.Accelerator) args := []string{ @@ -576,11 +638,12 @@ func (q *QEMUBackend) buildArgs(inst *Instance, cfg *config.JcardConfig, kernelA // Networking args = append(args, q.buildNetworkArgs(inst, cfg)...) - // Control plane device: native vsock when available, otherwise the - // stereosd port is forwarded via TCP hostfwd in buildNetworkArgs. - if q.platform.ControlPlaneMode == "vsock" { + // Control plane device: native vsock when the effective mode resolved + // to vsock (boot() assigns a non-zero VsockCID in that case). Otherwise + // the stereosd port is forwarded via TCP hostfwd in buildNetworkArgs. + if inst.VsockCID > 0 { args = append(args, - "-device", fmt.Sprintf("%s,guest-cid=%d", q.platform.VsockDevice, vsock.VsockGuestCID), + "-device", fmt.Sprintf("%s,guest-cid=%d", q.platform.VsockDevice, inst.VsockCID), ) } @@ -619,10 +682,12 @@ func (q *QEMUBackend) buildNetworkArgs(inst *Instance, cfg *config.JcardConfig) // Always forward SSH hostfwds := fmt.Sprintf("hostfwd=tcp::%d-:22", inst.SSHPort) - // Forward stereosd control plane via TCP through SLIRP. - // TODO(@jpmcb): Once native AF_VSOCK transport is implemented for - // Linux/KVM, this can be skipped when ControlPlaneMode == "vsock". - hostfwds += fmt.Sprintf(",hostfwd=tcp::%d-:%d", inst.VsockPort, vsock.VsockPort) + // Forward stereosd control plane via TCP only when vsock isn't in + // use. With a native vsock CID allocated, mb dials AF_VSOCK + // directly and the guest needs no port forward. + if inst.VsockCID == 0 && inst.VsockPort > 0 { + hostfwds += fmt.Sprintf(",hostfwd=tcp::%d-:%d", inst.VsockPort, vsock.VsockPort) + } // Add configured port forwards for _, fwd := range cfg.Network.Forwards { @@ -724,16 +789,14 @@ func (q *QEMUBackend) controlPlaneShutdown(ctx context.Context, inst *Instance) return client.Shutdown(ctx, "mb down") } -// controlPlaneTransport returns the appropriate Transport for connecting -// to stereosd based on the platform's control plane mode. -// -// TODO(@jpmcb): Implement native AF_VSOCK transport for Linux/KVM. -// When ControlPlaneMode == "vsock", this should return a VsockTransport -// that dials AF_VSOCK CID:3 port 1024 directly, bypassing TCP/SLIRP. -// See the VsockTransport sketch in pkg/vsock/transport.go. +// controlPlaneTransport returns the appropriate Transport for connecting to +// stereosd. Selection is per-instance: boot() has already resolved the +// effective mode (vsock vs tcp) and populated either VsockCID or VsockPort. +// A non-zero VsockCID means AF_VSOCK; otherwise fall back to TCP hostfwd. func (q *QEMUBackend) controlPlaneTransport(inst *Instance) vsock.Transport { - // All platforms currently use TCP through QEMU user-mode networking. - // stereosd inside the guest listens on TCP via --listen-mode auto. + if inst.VsockCID > 0 { + return &vsock.VsockTransport{CID: inst.VsockCID, Port: vsock.VsockPort} + } return &vsock.TCPTransport{Host: "127.0.0.1", Port: inst.VsockPort} } diff --git a/pkg/vm/state.go b/pkg/vm/state.go index a1cb5fc..5535356 100644 --- a/pkg/vm/state.go +++ b/pkg/vm/state.go @@ -19,7 +19,12 @@ type StateFile struct { NetworkMode string `json:"network_mode"` SSHPort int `json:"ssh_port"` VsockPort int `json:"vsock_port"` - ConfigPath string `json:"config_path,omitempty"` + // VsockCID is the guest context ID for native AF_VSOCK control plane. + // Set when ControlPlaneMode resolves to "vsock" at boot; zero when the + // VM uses TCP hostfwd. Persisted so `mb down` after a daemon restart + // can still address the guest. + VsockCID uint32 `json:"vsock_cid,omitempty"` + ConfigPath string `json:"config_path,omitempty"` // SSHKeyPath is the path to the ephemeral SSH private key, relative // to the VM directory or absolute. Persisted so the key survives diff --git a/pkg/vm/types.go b/pkg/vm/types.go index 944ddcc..e4ee730 100644 --- a/pkg/vm/types.go +++ b/pkg/vm/types.go @@ -36,6 +36,11 @@ type Instance struct { // For QEMU with TCP fallback, this is a TCP port on localhost. VsockPort int `json:"vsock_port"` + // VsockCID is the guest context ID for native AF_VSOCK control plane. + // Non-zero when this VM uses vsock (Linux backend); zero for TCP-only + // backends (macOS, WSL2, hosts without vhost_vsock). + VsockCID uint32 `json:"vsock_cid,omitempty"` + // SSHPort is the host port forwarded to guest port 22. SSHPort int `json:"ssh_port"` diff --git a/pkg/vm/util.go b/pkg/vm/util.go index 2022f7d..3e51409 100644 --- a/pkg/vm/util.go +++ b/pkg/vm/util.go @@ -4,6 +4,7 @@ import ( "fmt" "net" "os" + "path/filepath" "syscall" ) @@ -21,6 +22,44 @@ func allocatePort() (int, error) { return port, nil } +// allocateVsockCID picks a guest CID for vhost-vsock by scanning existing VM +// state files under baseDir and returning the lowest unused CID >= 3. +// +// CIDs 0-2 are reserved (VMADDR_CID_HYPERVISOR, _LOCAL, _HOST). vhost-vsock +// CIDs are host-global: two concurrent VMs must not share one, or QEMU will +// refuse to start the second. The scan is best-effort (races with a +// concurrent bootVM are possible); QEMU's own collision detection is the +// authoritative enforcement. +func allocateVsockCID(baseDir string) (uint32, error) { + used := map[uint32]bool{} + + entries, err := os.ReadDir(VMsDir(baseDir)) + if err != nil && !os.IsNotExist(err) { + return 0, fmt.Errorf("reading VMs directory: %w", err) + } + for _, e := range entries { + if !e.IsDir() { + continue + } + state, err := loadState(filepath.Join(VMsDir(baseDir), e.Name())) + if err != nil { + continue + } + if state.VsockCID > 0 { + used[state.VsockCID] = true + } + } + + // Pick the lowest free CID. We start at 3 (first non-reserved) and cap + // at 2^31 defensively — in practice we run out of RAM long before CIDs. + for cid := uint32(3); cid < 1<<31; cid++ { + if !used[cid] { + return cid, nil + } + } + return 0, fmt.Errorf("no vsock CID available (all in use under %s)", baseDir) +} + // processAlive reports whether a process with the given PID is still running. // It uses signal 0, which checks process existence without sending a signal. func processAlive(pid int) bool { diff --git a/pkg/vm/util_test.go b/pkg/vm/util_test.go new file mode 100644 index 0000000..6d20e2f --- /dev/null +++ b/pkg/vm/util_test.go @@ -0,0 +1,133 @@ +package vm + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestAllocateVsockCID(t *testing.T) { + // Build a fake VMs directory tree and verify the allocator skips + // already-assigned CIDs and returns the next free one. + baseDir := t.TempDir() + vmsDir := filepath.Join(baseDir, "vms") + if err := os.MkdirAll(vmsDir, 0755); err != nil { + t.Fatalf("mkdir vmsDir: %v", err) + } + + writeState := func(name string, cid uint32) { + dir := filepath.Join(vmsDir, name) + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + s := StateFile{Name: name, VsockCID: cid, Backend: "qemu"} + data, _ := json.Marshal(s) + if err := os.WriteFile(filepath.Join(dir, "state.json"), data, 0644); err != nil { + t.Fatalf("write state: %v", err) + } + } + + // No VMs yet — should pick the first free CID (3). + cid, err := allocateVsockCID(baseDir) + if err != nil { + t.Fatalf("allocate empty: %v", err) + } + if cid != 3 { + t.Errorf("empty dir: got CID %d, want 3", cid) + } + + // Pre-seed some VMs taking CIDs 3, 4, 6. + writeState("alpha", 3) + writeState("beta", 4) + writeState("gamma", 6) + + cid, err = allocateVsockCID(baseDir) + if err != nil { + t.Fatalf("allocate with holes: %v", err) + } + // First free CID is 5 (since 3, 4 are used; 5 is free; 6 is used). + if cid != 5 { + t.Errorf("with holes: got CID %d, want 5", cid) + } + + // VMs without CID set (VsockCID == 0) should be ignored. + writeState("no-cid", 0) + cid2, err := allocateVsockCID(baseDir) + if err != nil { + t.Fatalf("allocate with no-cid entry: %v", err) + } + if cid2 != 5 { + t.Errorf("no-cid entry must be ignored: got %d, want 5", cid2) + } +} + +func TestAllocateVsockCIDMissingDir(t *testing.T) { + // baseDir without a vms/ subdir should still return the first CID. + baseDir := t.TempDir() + cid, err := allocateVsockCID(baseDir) + if err != nil { + t.Fatalf("missing vms dir: %v", err) + } + if cid != 3 { + t.Errorf("missing dir: got CID %d, want 3", cid) + } +} + +func TestClearVsockCIDOnStop(t *testing.T) { + writeStateWithCID := func(t *testing.T, dir string, cid uint32) { + t.Helper() + s := StateFile{Name: filepath.Base(dir), VsockCID: cid, Backend: "qemu"} + data, _ := json.Marshal(s) + if err := os.WriteFile(filepath.Join(dir, "state.json"), data, 0644); err != nil { + t.Fatalf("write state: %v", err) + } + } + + t.Run("clears CID when stopped", func(t *testing.T) { + dir := t.TempDir() + writeStateWithCID(t, dir, 7) + inst := &Instance{Dir: dir, VsockCID: 7, VMState: StateStopped} + + clearVsockCIDOnStop(inst) + + if inst.VsockCID != 0 { + t.Errorf("inst.VsockCID = %d, want 0", inst.VsockCID) + } + state, err := loadState(dir) + if err != nil { + t.Fatalf("loadState: %v", err) + } + if state.VsockCID != 0 { + t.Errorf("state.VsockCID = %d, want 0", state.VsockCID) + } + }) + + t.Run("noop when not stopped", func(t *testing.T) { + dir := t.TempDir() + writeStateWithCID(t, dir, 7) + inst := &Instance{Dir: dir, VsockCID: 7, VMState: StateRunning} + + clearVsockCIDOnStop(inst) + + state, _ := loadState(dir) + if state.VsockCID != 7 || inst.VsockCID != 7 { + t.Errorf("CID changed while running: state=%d inst=%d", state.VsockCID, inst.VsockCID) + } + }) + + t.Run("noop when CID already zero", func(t *testing.T) { + dir := t.TempDir() + writeStateWithCID(t, dir, 0) + inst := &Instance{Dir: dir, VsockCID: 0, VMState: StateStopped} + + clearVsockCIDOnStop(inst) // must not error or panic on missing/zero CID + }) + + t.Run("noop when state file missing", func(t *testing.T) { + dir := t.TempDir() + inst := &Instance{Dir: dir, VsockCID: 5, VMState: StateStopped} + + clearVsockCIDOnStop(inst) // tolerated — best-effort cleanup + }) +} diff --git a/pkg/vm/vsock_host_linux.go b/pkg/vm/vsock_host_linux.go new file mode 100644 index 0000000..dbb7579 --- /dev/null +++ b/pkg/vm/vsock_host_linux.go @@ -0,0 +1,44 @@ +//go:build linux + +package vm + +import ( + "os" + "strings" +) + +// HostHasVsock reports whether this host can participate as the vsock +// endpoint for a QEMU guest using vhost-vsock-pci. Requires: +// +// 1. /dev/vhost-vsock exists and is a character device (vhost_vsock module +// loaded + udev has populated /dev). +// 2. The process is not running under WSL2 (Microsoft's WSL kernel doesn't +// expose nested vsock cleanly; TCP hostfwd is the safer path there). +// +// When this returns false, callers should fall back to TCP transport even +// when the platform config requests vsock. +func HostHasVsock() bool { + if runningUnderWSL() { + return false + } + info, err := os.Stat("/dev/vhost-vsock") + if err != nil { + return false + } + return info.Mode()&os.ModeCharDevice != 0 +} + +// runningUnderWSL reports whether /proc/version carries the Microsoft WSL +// marker. Kernel strings on WSL2 look like: +// +// Linux version 5.15.167.4-microsoft-standard-WSL2 ... +// +// so both "microsoft" and "wsl" substrings are reliable signals. +func runningUnderWSL() bool { + data, err := os.ReadFile("/proc/version") + if err != nil { + return false + } + v := strings.ToLower(string(data)) + return strings.Contains(v, "microsoft") || strings.Contains(v, "wsl") +} diff --git a/pkg/vm/vsock_host_other.go b/pkg/vm/vsock_host_other.go new file mode 100644 index 0000000..0118637 --- /dev/null +++ b/pkg/vm/vsock_host_other.go @@ -0,0 +1,8 @@ +//go:build !linux + +package vm + +// HostHasVsock is always false off Linux — AF_VSOCK sockets are Linux-only +// on the host side, and macOS reaches stereosd via the applevirt backend's +// vz.VirtioSocketDevice instead. +func HostHasVsock() bool { return false } diff --git a/pkg/vsock/transport.go b/pkg/vsock/transport.go index 395d908..7d07f30 100644 --- a/pkg/vsock/transport.go +++ b/pkg/vsock/transport.go @@ -10,9 +10,16 @@ import ( // a StereOS guest VM. Different backends use different transports: // // - TCPTransport: connects via TCP through QEMU user-mode networking -// (hostfwd). Used on macOS/HVF where native vsock is unavailable. -// - VsockTransport (future): connects via AF_VSOCK on Linux/KVM. -// Requires golang.org/x/sys/unix for socket creation. +// (hostfwd). Fallback for hosts without vhost-vsock (macOS/HVF, WSL2, +// and Linux kernels without vhost_vsock loaded). +// - VsockTransport: connects via AF_VSOCK on Linux, against a QEMU +// vhost-vsock-pci device (transport_linux.go). Preferred when +// available — no SLIRP overhead and control plane survives +// `network.mode = "none"`. +// - FuncTransport: wraps a caller-supplied dial function. Used by the +// Apple Virtualization.framework backend to reach stereosd through +// vz.VirtioSocketDevice without importing the darwin-only vz package +// into pkg/vsock. // - VirtioSerialTransport (future): connects via a chardev unix socket // backed by virtio-serial. Works on macOS/HVF without guest networking. type Transport interface { @@ -64,20 +71,3 @@ func (f *FuncTransport) Dial(timeout time.Duration) (net.Conn, error) { // String returns the human-readable label. func (f *FuncTransport) String() string { return f.Label } - -// NOTE: VsockTransport for Linux/KVM (AF_VSOCK CID:3 port 1024) will be -// implemented when Linux backend support is built. It requires: -// -// import "golang.org/x/sys/unix" -// -// type VsockTransport struct { -// CID uint32 -// Port uint32 -// } -// -// func (t *VsockTransport) Dial(timeout time.Duration) (net.Conn, error) { -// fd, _ := unix.Socket(unix.AF_VSOCK, unix.SOCK_STREAM, 0) -// addr := &unix.SockaddrVM{CID: t.CID, Port: t.Port} -// unix.Connect(fd, addr) -// return net.FileConn(os.NewFile(uintptr(fd), "vsock")) -// } diff --git a/pkg/vsock/transport_linux.go b/pkg/vsock/transport_linux.go new file mode 100644 index 0000000..8de040c --- /dev/null +++ b/pkg/vsock/transport_linux.go @@ -0,0 +1,69 @@ +//go:build linux + +package vsock + +import ( + "fmt" + "net" + "time" + + mdvsock "github.com/mdlayher/vsock" +) + +// VsockTransport dials stereosd via AF_VSOCK directly on Linux. Requires the +// host kernel's vhost_vsock module to be loaded and a corresponding +// vhost-vsock-pci device in the guest (QEMU `-device +// vhost-vsock-pci,guest-cid=`). The (CID, Port) pair uniquely +// identifies the guest endpoint on the host. +// +// This is the preferred transport on Linux: no SLIRP/TCP hostfwd in the +// data path, and the control plane stays reachable even when the guest's +// user network is turned off (jcard `network.mode = "none"`). +// +// Implementation note: Go's `net.FileConn` rejects AF_VSOCK with "protocol +// not supported" because the stdlib's socket-family detection doesn't know +// about AF_VSOCK. We delegate to github.com/mdlayher/vsock, which wraps +// the AF_VSOCK fd in a proper net.Conn with Go's runtime poller. +type VsockTransport struct { + CID uint32 // guest CID; must match QEMU's -device ...,guest-cid= + Port uint32 // guest vsock port (stereosd listens on vsock.VsockPort = 1024) +} + +// Dial connects to stereosd on (CID, Port). mdlayher/vsock.Dial is blocking +// with no timeout parameter; in practice the kernel either completes the +// connect or returns ECONNREFUSED within microseconds (there's no network +// round-trip on vsock). We race against time.After as a safety net for +// pathological cases. +func (t *VsockTransport) Dial(timeout time.Duration) (net.Conn, error) { + type result struct { + conn net.Conn + err error + } + ch := make(chan result, 1) + go func() { + c, err := mdvsock.Dial(t.CID, t.Port, nil) + ch <- result{c, err} + }() + + select { + case r := <-ch: + if r.err != nil { + return nil, fmt.Errorf("vsock: dial CID:%d port:%d: %w", t.CID, t.Port, r.err) + } + return r.conn, nil + case <-time.After(timeout): + // Drain the goroutine so we don't leak if Dial completes later. + go func() { + r := <-ch + if r.err == nil && r.conn != nil { + _ = r.conn.Close() + } + }() + return nil, fmt.Errorf("vsock: connect timeout after %v to CID:%d port:%d", timeout, t.CID, t.Port) + } +} + +// String returns a human-readable address for logs and error messages. +func (t *VsockTransport) String() string { + return fmt.Sprintf("vsock:CID=%d,port=%d", t.CID, t.Port) +} diff --git a/pkg/vsock/transport_other.go b/pkg/vsock/transport_other.go new file mode 100644 index 0000000..cb450cd --- /dev/null +++ b/pkg/vsock/transport_other.go @@ -0,0 +1,27 @@ +//go:build !linux + +package vsock + +import ( + "fmt" + "net" + "time" +) + +// VsockTransport is a compile-time stub on non-Linux platforms. AF_VSOCK is +// Linux-only; macOS code paths use FuncTransport wrapping a +// vz.VirtioSocketDevice instead (see pkg/vm/applevirt.go). +type VsockTransport struct { + CID uint32 + Port uint32 +} + +// Dial always fails on non-Linux targets. +func (t *VsockTransport) Dial(_ time.Duration) (net.Conn, error) { + return nil, fmt.Errorf("vsock: AF_VSOCK transport is Linux-only") +} + +// String returns a human-readable address for logs and error messages. +func (t *VsockTransport) String() string { + return fmt.Sprintf("vsock:CID=%d,port=%d (unsupported on this OS)", t.CID, t.Port) +}