diff --git a/cmd/api/api/capabilities.go b/cmd/api/api/capabilities.go new file mode 100644 index 00000000..e97d030a --- /dev/null +++ b/cmd/api/api/capabilities.go @@ -0,0 +1,224 @@ +package api + +import ( + "context" + "runtime" + "sync" + + "github.com/kernel/hypeman/lib/hypervisor" + "github.com/kernel/hypeman/lib/images" + "github.com/kernel/hypeman/lib/logger" + "github.com/kernel/hypeman/lib/network" + "github.com/kernel/hypeman/lib/oapi" +) + +// Server-level feature IDs: API surfaces this server exposes regardless of +// which runtime backs an instance. Per-runtime feature IDs are owned by +// lib/hypervisor (hypervisor.Capabilities.FeatureIDs) so adding a runtime +// capability never requires touching this handler. +const ( + featureInstances = "instances" + featureImages = "images" + featureBuilds = "builds" + featureVolumes = "volumes" + featureIngress = "ingress" + featureExec = "exec" + featureLogs = "logs" + featureDevices = "devices" + featureRosettaEmulation = "rosetta-emulation" +) + +// apiVersion is the API contract version from the embedded OpenAPI document. +// The decoded spec is cached: decoding it per request is needlessly expensive. +var apiVersion = sync.OnceValue(func() string { + spec, err := oapi.GetSwagger() + if err != nil || spec.Info == nil { + return "unknown" + } + return spec.Info.Version +}) + +// defaultHypervisorProvider is the narrow accessor this handler needs from +// the instance manager. The concrete instances manager implements it; it is +// type-asserted rather than added to instances.Manager so alternate Manager +// implementations (mocks, wrappers) compiled against the public module keep +// building without a new method. +type defaultHypervisorProvider interface { + DefaultHypervisor() hypervisor.Type +} + +// GetCapabilities reports host, runtime, network, and image capabilities. +func (s *ApiService) GetCapabilities(ctx context.Context, _ oapi.GetCapabilitiesRequestObject) (oapi.GetCapabilitiesResponseObject, error) { + log := logger.FromContext(ctx) + + // Resolve the default runtime the way launches do. Prefer the manager's + // own effective default via the optional accessor; when the manager does + // not expose it (a wrapper embedding instances.Manager hides the concrete + // manager's extra method), fall back to the configured default the manager + // was constructed from — launches still route through the wrapped manager, + // so a hardcoded fallback would misreport a Firecracker/QEMU default as + // cloud-hypervisor. Only an empty (unconfigured) value normalizes to the + // compile-time default, mirroring lib/instances.NewManagerWithConfigE. + defaultRuntime := hypervisor.Type(s.Config.Hypervisor.Default) + if defaultRuntime == "" { + defaultRuntime = hypervisor.TypeCloudHypervisor + } + if p, ok := s.InstanceManager.(defaultHypervisorProvider); ok { + defaultRuntime = p.DefaultHypervisor() + } + + // The capability registry is platform-gated at registration time, so its + // contents are exactly the runtimes this build supports on this host — + // including ones added after this handler was written. Capabilities and + // launch prerequisites are resolved per request, so configuration applied + // after init (e.g. a pinned cloud-hypervisor version) and host state + // (e.g. an installed QEMU binary) are reflected without a restart. + registered := hypervisor.RegisteredRuntimes() + runtimes := make([]oapi.CapabilitiesRuntime, 0, len(registered)) + defaultAvailable := false + for _, rt := range registered { + available := rt.Available() + if rt.Type == defaultRuntime { + defaultAvailable = available + } + if !available { + log.WarnContext(ctx, "runtime is registered but missing launch prerequisites", + "runtime", string(rt.Type), "error", rt.LaunchErr) + } + runtimes = append(runtimes, oapi.CapabilitiesRuntime{ + Name: string(rt.Type), + Available: available, + Features: rt.Capabilities.FeatureIDs(), + }) + } + if !defaultAvailable { + // Ordinary launches use the default runtime and will fail on this + // host; surface that in logs as well as in the response. + log.WarnContext(ctx, "configured default runtime is not available on this host", + "runtime", string(defaultRuntime), "host_os", runtime.GOOS, "host_arch", runtime.GOARCH) + } + + networkCaps, err := s.networkCapabilities(ctx) + if err != nil { + log.ErrorContext(ctx, "failed to resolve network capabilities", "error", err) + return oapi.GetCapabilities500JSONResponse{ + Code: "internal_error", + Message: "failed to resolve network capabilities", + }, nil + } + + emulation := emulationAvailable(runtime.GOOS, runtime.GOARCH, rosettaInstalled()) + + resp := oapi.Capabilities{ + Server: oapi.CapabilitiesServer{ + Version: s.Config.Version, + ApiVersion: apiVersion(), + }, + Host: oapi.CapabilitiesHost{ + Os: runtime.GOOS, + Arch: runtime.GOARCH, + }, + DefaultRuntime: oapi.CapabilitiesDefaultRuntime{ + Name: string(defaultRuntime), + Available: defaultAvailable, + }, + Runtimes: runtimes, + Network: *networkCaps, + Images: oapi.CapabilitiesImages{ + Platforms: imagePlatforms(runtime.GOARCH, emulation), + DefaultPlatform: images.HostPlatformString(), + }, + Features: serverFeatures(runtime.GOOS, emulation), + } + + return oapi.GetCapabilities200JSONResponse(resp), nil +} + +// networkCapabilities resolves the guest networking model and the +// guest-visible host gateway from the network manager's effective default +// network. Gateway and subnet are omitted (not serialized as empty strings) +// when no default network has been resolved. +func (s *ApiService) networkCapabilities(ctx context.Context) (*oapi.CapabilitiesNetwork, error) { + caps := &oapi.CapabilitiesNetwork{ + Model: oapiNetworkModel(network.NetworkModel()), + GuestToGuest: false, + } + if s.NetworkManager == nil { + return caps, nil + } + nw, err := s.NetworkManager.EffectiveDefaultNetwork() + if err != nil { + return nil, err + } + if nw == nil { + return caps, nil + } + if nw.Gateway != "" { + gateway := nw.Gateway + caps.Gateway = &gateway + } + if nw.Subnet != "" { + subnet := nw.Subnet + caps.Subnet = &subnet + } + caps.GuestToGuest = network.GuestToGuestEnabled(nw) + return caps, nil +} + +// oapiNetworkModel maps the network package's typed model onto the API enum, +// keeping lib/network free of oapi dependencies. +func oapiNetworkModel(m network.Model) oapi.CapabilitiesNetworkModel { + switch m { + case network.ModelBridge: + return oapi.Bridge + case network.ModelNAT: + return oapi.Nat + } + // A new network.Model must also be added to the OpenAPI enum; surface it + // verbatim rather than misreporting it as a known model. + return oapi.CapabilitiesNetworkModel(m) +} + +// emulationAvailable reports whether the host can boot images built for the +// other CPU architecture right now. Only Apple Silicon macOS hosts qualify +// (vz with Rosetta), and only when the Rosetta availability probe — the same +// Virtualization.framework check the vz-shim enforces at launch — reports it +// installed. Platform eligibility alone (darwin/arm64) is deliberately not +// enough: a macOS 11/12 host or one without Rosetta installed would advertise +// launches that lib/hypervisor/vz rejects. +func emulationAvailable(goos, goarch string, rosettaInstalled bool) bool { + return goos == "darwin" && goarch == "arm64" && rosettaInstalled +} + +// imagePlatforms returns the image platforms (os/arch) the host can run: the +// host-native Linux guest platform, plus Rosetta-emulated linux/amd64 on +// Apple Silicon with Rosetta installed. +func imagePlatforms(goarch string, emulation bool) []string { + platforms := []string{"linux/" + goarch} + if emulation { + platforms = append(platforms, "linux/amd64") + } + return platforms +} + +// serverFeatures builds the server-level feature ID list: API surfaces that +// are always present plus host-platform conditionals. +func serverFeatures(goos string, emulation bool) []string { + features := []string{ + featureInstances, + featureImages, + featureBuilds, + featureVolumes, + featureIngress, + featureExec, + featureLogs, + } + // Device (GPU/PCI) passthrough management is only available on Linux hosts. + if goos == "linux" { + features = append(features, featureDevices) + } + if emulation { + features = append(features, featureRosettaEmulation) + } + return features +} diff --git a/cmd/api/api/capabilities_darwin_test.go b/cmd/api/api/capabilities_darwin_test.go new file mode 100644 index 00000000..79326ea0 --- /dev/null +++ b/cmd/api/api/capabilities_darwin_test.go @@ -0,0 +1,57 @@ +//go:build darwin + +package api + +import ( + "runtime" + "slices" + "testing" + + "github.com/kernel/hypeman/lib/hypervisor" + "github.com/stretchr/testify/require" +) + +// TestGetCapabilitiesRosettaTracksProbe pins that Rosetta emulation reporting +// follows the live Virtualization.framework availability probe on this host +// — the same check the vz-shim enforces at launch — rather than treating +// every Apple Silicon host as emulation-capable: a host without Rosetta +// installed (or on macOS < 13) must advertise neither the rosetta-emulation +// feature nor the linux/amd64 image platform. +func TestGetCapabilitiesRosettaTracksProbe(t *testing.T) { + t.Parallel() + if runtime.GOARCH != "arm64" { + t.Skipf("rosetta emulation exists only on Apple Silicon (GOARCH=%s)", runtime.GOARCH) + } + svc := newTestService(t) + svc.NetworkManager = &stubCapabilitiesNetworkManager{} + + caps := getCapabilities(t, svc) + + want := rosettaInstalled() + require.Equal(t, want, slices.Contains(caps.Features, "rosetta-emulation"), + "rosetta-emulation feature must track the launch-path availability probe") + require.Equal(t, want, slices.Contains(caps.Images.Platforms, "linux/amd64"), + "linux/amd64 image platform must track the launch-path availability probe") +} + +// TestRegisteredRuntimesDarwin pins the macOS registration boundary: only vz +// can genuinely launch VMs on macOS (the Linux backends require KVM and +// kernel AF_VSOCK), so the capability registry contains exactly vz even +// though the cloud-hypervisor and qemu packages are linked into the binary. +func TestRegisteredRuntimesDarwin(t *testing.T) { + t.Parallel() + + registered := hypervisor.RegisteredRuntimes() + require.Len(t, registered, 1) + require.Equal(t, hypervisor.TypeVZ, registered[0].Type) + + for _, linuxOnly := range []hypervisor.Type{ + hypervisor.TypeCloudHypervisor, + hypervisor.TypeFirecracker, + hypervisor.TypeQEMU, + hypervisor.TypeQEMUMicroVM, + } { + _, ok := hypervisor.CapabilitiesForType(linuxOnly) + require.False(t, ok, "%s must not register capabilities on macOS", linuxOnly) + } +} diff --git a/cmd/api/api/capabilities_linux_test.go b/cmd/api/api/capabilities_linux_test.go new file mode 100644 index 00000000..fd26b243 --- /dev/null +++ b/cmd/api/api/capabilities_linux_test.go @@ -0,0 +1,33 @@ +//go:build linux + +package api + +import ( + "runtime" + "testing" + + "github.com/kernel/hypeman/lib/hypervisor" + "github.com/stretchr/testify/require" +) + +// TestRegisteredRuntimesLinux pins the Linux registration boundary: the +// capability registry contains exactly the runtimes launchable on this host, +// in deterministic sorted order. qemu-microvm appears only where its x86 +// board exists, and vz never registers off macOS. +func TestRegisteredRuntimesLinux(t *testing.T) { + t.Parallel() + + expected := []string{"cloud-hypervisor", "firecracker", "qemu"} + if runtime.GOARCH == "amd64" { + expected = append(expected, "qemu-microvm") + } + + names := make([]string, 0, len(expected)) + for _, rt := range hypervisor.RegisteredRuntimes() { + names = append(names, string(rt.Type)) + } + require.Equal(t, expected, names) + + _, vzRegistered := hypervisor.CapabilitiesForType(hypervisor.TypeVZ) + require.False(t, vzRegistered, "vz must not register capabilities on Linux") +} diff --git a/cmd/api/api/capabilities_rosetta_darwin_arm64.go b/cmd/api/api/capabilities_rosetta_darwin_arm64.go new file mode 100644 index 00000000..a52b3f04 --- /dev/null +++ b/cmd/api/api/capabilities_rosetta_darwin_arm64.go @@ -0,0 +1,18 @@ +//go:build darwin && arm64 + +package api + +import "github.com/Code-Hex/vz/v3" + +// rosettaInstalled reports whether Rosetta translation for Linux guests is +// installed and usable right now, using the same Virtualization.framework +// probe the vz-shim enforces when a launch requests Rosetta +// (cmd/vz-shim/rosetta_arm64.go): NotInstalled (Rosetta missing) and +// NotSupported (macOS < 13) both fail launches, so capability reporting must +// not advertise emulation in either state. Evaluated per request, so +// installing Rosetta (softwareupdate --install-rosetta) is reflected without +// a restart. cmd/api already links Virtualization.framework on macOS +// (checkHypervisorAccess), so this adds no build or runtime requirement. +var rosettaInstalled = func() bool { + return vz.LinuxRosettaDirectoryShareAvailability() == vz.LinuxRosettaAvailabilityInstalled +} diff --git a/cmd/api/api/capabilities_rosetta_other.go b/cmd/api/api/capabilities_rosetta_other.go new file mode 100644 index 00000000..af40c2ef --- /dev/null +++ b/cmd/api/api/capabilities_rosetta_other.go @@ -0,0 +1,8 @@ +//go:build !(darwin && arm64) + +package api + +// rosettaInstalled is never true off Apple Silicon macOS: Rosetta emulation +// exists only under vz. Kept as a var with the same shape as the +// darwin/arm64 probe so the handler code is identical on every platform. +var rosettaInstalled = func() bool { return false } diff --git a/cmd/api/api/capabilities_test.go b/cmd/api/api/capabilities_test.go new file mode 100644 index 00000000..bb3e4684 --- /dev/null +++ b/cmd/api/api/capabilities_test.go @@ -0,0 +1,349 @@ +package api + +import ( + "context" + "runtime" + "testing" + + "github.com/kernel/hypeman/lib/hypervisor" + "github.com/kernel/hypeman/lib/instances" + "github.com/kernel/hypeman/lib/network" + "github.com/kernel/hypeman/lib/oapi" + "github.com/stretchr/testify/require" +) + +// stubCapabilitiesNetworkManager overrides EffectiveDefaultNetwork with a fixed +// network so the handler test is hermetic (no bridge/netlink). +type stubCapabilitiesNetworkManager struct { + network.Manager + nw *network.Network + err error +} + +func (s *stubCapabilitiesNetworkManager) EffectiveDefaultNetwork() (*network.Network, error) { + return s.nw, s.err +} + +// stubDefaultRuntimeInstanceManager overrides the configured default runtime +// via the optional defaultHypervisorProvider accessor. +type stubDefaultRuntimeInstanceManager struct { + instances.Manager + defaultRuntime hypervisor.Type +} + +func (s *stubDefaultRuntimeInstanceManager) DefaultHypervisor() hypervisor.Type { + return s.defaultRuntime +} + +// stubOpaqueInstanceManager implements instances.Manager without the optional +// DefaultHypervisor accessor, standing in for alternate Manager +// implementations compiled against the public module. +type stubOpaqueInstanceManager struct { + instances.Manager +} + +func getCapabilities(t *testing.T, svc *ApiService) oapi.Capabilities { + t.Helper() + resp, err := svc.GetCapabilities(ctx(), oapi.GetCapabilitiesRequestObject{}) + require.NoError(t, err) + okResp, ok := resp.(oapi.GetCapabilities200JSONResponse) + require.True(t, ok, "expected 200 response, got %T", resp) + return oapi.Capabilities(okResp) +} + +func runtimeNames(caps oapi.Capabilities) []string { + names := make([]string, 0, len(caps.Runtimes)) + for _, rt := range caps.Runtimes { + names = append(names, rt.Name) + } + return names +} + +func TestGetCapabilities(t *testing.T) { + t.Parallel() + svc := newTestService(t) + svc.Config.Version = "testsha123" + svc.NetworkManager = &stubCapabilitiesNetworkManager{ + nw: &network.Network{ + Name: "default", + Subnet: "10.100.0.0/16", + Gateway: "10.100.0.1", + Isolated: true, + Default: true, + }, + } + + caps := getCapabilities(t, svc) + + // Server identity and version serialization + require.Equal(t, "testsha123", caps.Server.Version) + require.Equal(t, apiVersion(), caps.Server.ApiVersion) + require.NotEmpty(t, caps.Server.ApiVersion) + require.NotEqual(t, "unknown", caps.Server.ApiVersion) + + // Host identity + require.Equal(t, runtime.GOOS, caps.Host.Os) + require.Equal(t, runtime.GOARCH, caps.Host.Arch) + + // Every host-supported runtime is reported, with availability from its + // launch-prerequisite check and feature IDs derived from the registered + // capability set — no handler-owned mapping. + registered := hypervisor.RegisteredRuntimes() + require.Len(t, caps.Runtimes, len(registered)) + for i, rt := range registered { + require.Equal(t, string(rt.Type), caps.Runtimes[i].Name) + require.Equal(t, rt.Available(), caps.Runtimes[i].Available, + "runtime %s availability must come from the registry's launch check", rt.Type) + require.Equal(t, rt.Capabilities.FeatureIDs(), caps.Runtimes[i].Features) + require.NotNil(t, caps.Runtimes[i].Features, "features must serialize as [], not null") + } + + // The configured default identity is retained. The test-service manager + // defaults to cloud-hypervisor, which is available on Linux only (its + // binaries are embedded, so registration implies launchability). + require.Equal(t, string(hypervisor.TypeCloudHypervisor), caps.DefaultRuntime.Name) + if runtime.GOOS == "linux" { + require.True(t, caps.DefaultRuntime.Available) + require.Contains(t, runtimeNames(caps), caps.DefaultRuntime.Name) + } else { + require.False(t, caps.DefaultRuntime.Available, + "cloud-hypervisor default must not be reported available on non-Linux hosts") + require.NotContains(t, runtimeNames(caps), caps.DefaultRuntime.Name) + } + + // Network model and guest-visible gateway + require.Equal(t, oapiNetworkModel(network.NetworkModel()), caps.Network.Model) + require.NotNil(t, caps.Network.Gateway) + require.Equal(t, "10.100.0.1", *caps.Network.Gateway) + require.NotNil(t, caps.Network.Subnet) + require.Equal(t, "10.100.0.0/16", *caps.Network.Subnet) + require.False(t, caps.Network.GuestToGuest, "isolated default network must report guest_to_guest=false") + + // Image platforms always include the host-native guest platform + require.Contains(t, caps.Images.Platforms, caps.Images.DefaultPlatform) + + // Server-level feature IDs are always present; per-runtime IDs never + // appear at the server level. + for _, f := range []string{"instances", "images", "builds", "volumes", "ingress", "exec", "logs"} { + require.Contains(t, caps.Features, f) + } + require.NotContains(t, caps.Features, hypervisor.FeatureStandby) + require.NotContains(t, caps.Features, hypervisor.FeatureSnapshots) + if runtime.GOOS == "linux" { + require.Contains(t, caps.Features, "devices") + } else { + require.NotContains(t, caps.Features, "devices") + } +} + +// TestGetCapabilitiesEachRuntimeIndependent proves runtimes report their own +// features independently: the runtimes on one host genuinely differ. +func TestGetCapabilitiesEachRuntimeIndependent(t *testing.T) { + t.Parallel() + svc := newTestService(t) + svc.NetworkManager = &stubCapabilitiesNetworkManager{} + + caps := getCapabilities(t, svc) + + features := make(map[string][]string, len(caps.Runtimes)) + for _, rt := range caps.Runtimes { + features[rt.Name] = rt.Features + } + + if runtime.GOOS != "linux" { + t.Skipf("linux-only runtime matrix (GOOS=%s)", runtime.GOOS) + } + + // cloud-hypervisor supports memory hotplug; firecracker and qemu do not. + require.Contains(t, features["cloud-hypervisor"], hypervisor.FeatureHotplugMemory) + require.NotContains(t, features["firecracker"], hypervisor.FeatureHotplugMemory) + require.NotContains(t, features["qemu"], hypervisor.FeatureHotplugMemory) + + // firecracker has no GPU passthrough; qemu (standard board) does. + require.NotContains(t, features["firecracker"], hypervisor.FeatureGPUPassthrough) + require.Contains(t, features["qemu"], hypervisor.FeatureGPUPassthrough) + + // Lifecycle features hold for all Linux runtimes. + for name, ids := range features { + require.Contains(t, ids, hypervisor.FeatureSnapshots, "runtime %s", name) + require.Contains(t, ids, hypervisor.FeatureStandby, "runtime %s", name) + require.Contains(t, ids, hypervisor.FeatureVsock, "runtime %s", name) + } + + // qemu-microvm appears automatically on amd64 and, unlike standard qemu, + // must not advertise PCI passthrough. + if runtime.GOARCH == "amd64" { + require.Contains(t, features, "qemu-microvm") + require.NotContains(t, features["qemu-microvm"], hypervisor.FeatureGPUPassthrough) + } else { + require.NotContains(t, features, "qemu-microvm") + } +} + +// TestGetCapabilitiesDefaultAvailabilityTracksLaunchCheck pins that the +// default runtime's availability is the same launch-prerequisite verdict as +// its runtimes[] entry — a registered default (e.g. qemu without a system +// binary) must not be reported available just because it is registered. +func TestGetCapabilitiesDefaultAvailabilityTracksLaunchCheck(t *testing.T) { + t.Parallel() + if runtime.GOOS != "linux" { + t.Skipf("qemu registers on Linux only (GOOS=%s)", runtime.GOOS) + } + svc := newTestService(t) + svc.NetworkManager = &stubCapabilitiesNetworkManager{} + svc.InstanceManager = &stubDefaultRuntimeInstanceManager{defaultRuntime: hypervisor.TypeQEMU} + + var qemuAvailable, found bool + for _, rt := range hypervisor.RegisteredRuntimes() { + if rt.Type == hypervisor.TypeQEMU { + qemuAvailable = rt.Available() + found = true + } + } + require.True(t, found, "qemu must be registered on Linux") + + caps := getCapabilities(t, svc) + require.Equal(t, string(hypervisor.TypeQEMU), caps.DefaultRuntime.Name) + require.Equal(t, qemuAvailable, caps.DefaultRuntime.Available, + "a qemu default must report the launch-prerequisite verdict, not registration") +} + +// TestGetCapabilitiesDefaultNotAvailable pins the contract when the configured +// default runtime cannot run on this host: the default identity is retained, +// available=false, and the available runtime list is unaffected. +func TestGetCapabilitiesDefaultNotAvailable(t *testing.T) { + t.Parallel() + svc := newTestService(t) + svc.NetworkManager = &stubCapabilitiesNetworkManager{} + unavailable := hypervisor.TypeVZ + if runtime.GOOS == "darwin" { + unavailable = hypervisor.TypeFirecracker + } + svc.InstanceManager = &stubDefaultRuntimeInstanceManager{defaultRuntime: unavailable} + + caps := getCapabilities(t, svc) + + require.Equal(t, string(unavailable), caps.DefaultRuntime.Name) + require.False(t, caps.DefaultRuntime.Available) + require.NotContains(t, runtimeNames(caps), string(unavailable)) + require.Equal(t, len(hypervisor.RegisteredRuntimes()), len(caps.Runtimes), + "an unavailable default must not hide the runtimes that are available") +} + +// TestGetCapabilitiesDefaultWithoutAccessor pins the fallback contract for +// instance managers that do not expose the optional DefaultHypervisor +// accessor (it is deliberately not part of instances.Manager, so a wrapper +// embedding the interface hides the concrete manager's method): the handler +// must report the configured default — the value the wrapped manager was +// constructed from and launches still use — normalizing only an empty +// (unconfigured) value to the same cloud-hypervisor default lib/instances +// applies. +func TestGetCapabilitiesDefaultWithoutAccessor(t *testing.T) { + t.Parallel() + + t.Run("configured non-default runtime is reported", func(t *testing.T) { + t.Parallel() + svc := newTestService(t) + svc.Config.Hypervisor.Default = string(hypervisor.TypeFirecracker) + svc.NetworkManager = &stubCapabilitiesNetworkManager{} + svc.InstanceManager = &stubOpaqueInstanceManager{} + + caps := getCapabilities(t, svc) + require.Equal(t, string(hypervisor.TypeFirecracker), caps.DefaultRuntime.Name, + "a wrapped manager must not misreport the configured default as cloud-hypervisor") + }) + + t.Run("unconfigured default normalizes to cloud-hypervisor", func(t *testing.T) { + t.Parallel() + svc := newTestService(t) + svc.NetworkManager = &stubCapabilitiesNetworkManager{} + svc.InstanceManager = &stubOpaqueInstanceManager{} + + caps := getCapabilities(t, svc) + require.Equal(t, string(hypervisor.TypeCloudHypervisor), caps.DefaultRuntime.Name) + }) + + t.Run("accessor overrides the configured value", func(t *testing.T) { + t.Parallel() + svc := newTestService(t) + svc.Config.Hypervisor.Default = string(hypervisor.TypeFirecracker) + svc.NetworkManager = &stubCapabilitiesNetworkManager{} + svc.InstanceManager = &stubDefaultRuntimeInstanceManager{defaultRuntime: hypervisor.TypeQEMU} + + caps := getCapabilities(t, svc) + require.Equal(t, string(hypervisor.TypeQEMU), caps.DefaultRuntime.Name, + "the manager's effective default is authoritative when exposed") + }) +} + +// TestGetCapabilitiesNoDefaultNetwork pins the gateway-absence contract: when +// no default network resolves, gateway and subnet are omitted rather than +// serialized as empty strings. +func TestGetCapabilitiesNoDefaultNetwork(t *testing.T) { + t.Parallel() + svc := newTestService(t) + svc.NetworkManager = &stubCapabilitiesNetworkManager{nw: nil} + + caps := getCapabilities(t, svc) + + require.Nil(t, caps.Network.Gateway) + require.Nil(t, caps.Network.Subnet) + require.False(t, caps.Network.GuestToGuest) + require.Equal(t, oapiNetworkModel(network.NetworkModel()), caps.Network.Model) +} + +func TestGetCapabilitiesNetworkError(t *testing.T) { + t.Parallel() + svc := newTestService(t) + svc.NetworkManager = &stubCapabilitiesNetworkManager{err: context.DeadlineExceeded} + + resp, err := svc.GetCapabilities(ctx(), oapi.GetCapabilitiesRequestObject{}) + require.NoError(t, err) + _, ok := resp.(oapi.GetCapabilities500JSONResponse) + require.True(t, ok, "expected 500 response when network resolution fails, got %T", resp) +} + +func TestOapiNetworkModel(t *testing.T) { + t.Parallel() + require.Equal(t, oapi.Bridge, oapiNetworkModel(network.ModelBridge)) + require.Equal(t, oapi.Nat, oapiNetworkModel(network.ModelNAT)) +} + +func TestEmulationAvailable(t *testing.T) { + t.Parallel() + require.True(t, emulationAvailable("darwin", "arm64", true)) + require.False(t, emulationAvailable("darwin", "arm64", false), + "an Apple Silicon host without Rosetta installed must not advertise emulation") + require.False(t, emulationAvailable("darwin", "amd64", true)) + require.False(t, emulationAvailable("linux", "arm64", true)) + require.False(t, emulationAvailable("linux", "amd64", true)) +} + +func TestImagePlatforms(t *testing.T) { + t.Parallel() + require.Equal(t, []string{"linux/amd64"}, imagePlatforms("amd64", false)) + require.Equal(t, []string{"linux/arm64"}, imagePlatforms("arm64", false)) + require.Equal(t, + []string{"linux/arm64", "linux/amd64"}, + imagePlatforms("arm64", true), + "Apple Silicon macOS advertises Rosetta-emulated amd64") +} + +func TestServerFeatures(t *testing.T) { + t.Parallel() + + base := []string{"instances", "images", "builds", "volumes", "ingress", "exec", "logs"} + + require.Equal(t, append(base, "devices"), serverFeatures("linux", false)) + require.Equal(t, base, serverFeatures("darwin", false)) + require.Equal(t, append(base, "rosetta-emulation"), serverFeatures("darwin", true)) +} + +// TestAPIVersionMatchesSpec guards the version contract: the endpoint must +// serialize the same version as the embedded OpenAPI document. +func TestAPIVersionMatchesSpec(t *testing.T) { + t.Parallel() + spec, err := oapi.GetSwagger() + require.NoError(t, err) + require.Equal(t, spec.Info.Version, apiVersion()) +} diff --git a/lib/hypervisor/cloudhypervisor/cloudhypervisor.go b/lib/hypervisor/cloudhypervisor/cloudhypervisor.go index 1d3a7f14..6b3b4a78 100644 --- a/lib/hypervisor/cloudhypervisor/cloudhypervisor.go +++ b/lib/hypervisor/cloudhypervisor/cloudhypervisor.go @@ -48,6 +48,9 @@ func (c *CloudHypervisor) Capabilities() hypervisor.Capabilities { return capabilities() } +// capabilities preserves the legacy client behavior for clients that do not +// carry instance-version metadata. The runtime registry separately resolves +// capabilities for the configured default used by new launches. func capabilities() hypervisor.Capabilities { return CapabilitiesForVersion(vmm.DefaultVersion) } @@ -57,7 +60,9 @@ func capabilities() hypervisor.Capabilities { // aren't supported on previous versions. func CapabilitiesForVersion(v vmm.CHVersion) hypervisor.Capabilities { caps := hypervisor.Capabilities{ - SupportsSnapshot: true, + SupportsSnapshot: true, + // PrepareFork rewrites snapshot config for fork identity (fork.go). + SupportsFork: true, SupportsHotplugMemory: true, SupportsBalloonControl: true, SupportsPause: true, diff --git a/lib/hypervisor/cloudhypervisor/process.go b/lib/hypervisor/cloudhypervisor/process.go index 3caa3684..14b306be 100644 --- a/lib/hypervisor/cloudhypervisor/process.go +++ b/lib/hypervisor/cloudhypervisor/process.go @@ -56,7 +56,6 @@ func GetDefaultVersion() vmm.CHVersion { func init() { hypervisor.RegisterSocketName(hypervisor.TypeCloudHypervisor, "ch.sock") - hypervisor.RegisterCapabilities(hypervisor.TypeCloudHypervisor, capabilities()) hypervisor.RegisterClientFactory(hypervisor.TypeCloudHypervisor, func(socketPath string) (hypervisor.Hypervisor, error) { return New(socketPath) }) diff --git a/lib/hypervisor/cloudhypervisor/register_linux.go b/lib/hypervisor/cloudhypervisor/register_linux.go new file mode 100644 index 00000000..fe03fbac --- /dev/null +++ b/lib/hypervisor/cloudhypervisor/register_linux.go @@ -0,0 +1,20 @@ +//go:build linux + +package cloudhypervisor + +import "github.com/kernel/hypeman/lib/hypervisor" + +// Cloud Hypervisor requires KVM, so registration — and therefore its +// presence in hypervisor.RegisteredRuntimes — is Linux-only. Capabilities +// are resolved per registry read from the configured default version (which +// SetDefaultVersion may change after init, e.g. pinning v49.0 without +// disk-resize) rather than frozen from the compile-time default. No +// LaunchCheck: the binaries for every supported version ship embedded in +// hypeman. +func init() { + hypervisor.RegisterRuntime(hypervisor.TypeCloudHypervisor, hypervisor.RuntimeRegistration{ + Capabilities: func() hypervisor.Capabilities { + return CapabilitiesForVersion(GetDefaultVersion()) + }, + }) +} diff --git a/lib/hypervisor/cloudhypervisor/register_linux_test.go b/lib/hypervisor/cloudhypervisor/register_linux_test.go new file mode 100644 index 00000000..6d218958 --- /dev/null +++ b/lib/hypervisor/cloudhypervisor/register_linux_test.go @@ -0,0 +1,53 @@ +//go:build linux + +package cloudhypervisor + +import ( + "testing" + + "github.com/kernel/hypeman/lib/hypervisor" + "github.com/kernel/hypeman/lib/vmm" + "github.com/stretchr/testify/require" +) + +// TestRegisteredCapabilitiesTrackConfiguredDefaultVersion pins the v49/v51 +// boundary: the capability registry must resolve cloud-hypervisor +// capabilities from the configured default version at read time, not from a +// value frozen at package init. v49.0 lacks live disk resize; advertising it +// would misreport what ordinary configured launches can do. +// +// Not parallel: it mutates the process-wide configured default version. +func TestRegisteredCapabilitiesTrackConfiguredDefaultVersion(t *testing.T) { + t.Cleanup(func() { require.NoError(t, SetDefaultVersion("")) }) + + require.NoError(t, SetDefaultVersion(string(vmm.V49_0))) + caps, ok := hypervisor.CapabilitiesForType(hypervisor.TypeCloudHypervisor) + require.True(t, ok, "cloud-hypervisor must be registered on Linux") + require.False(t, caps.SupportsDiskResize, "a v49.0 default must not advertise disk-resize") + require.NotContains(t, caps.FeatureIDs(), hypervisor.FeatureDiskResize) + + require.NoError(t, SetDefaultVersion(string(vmm.V51_1))) + caps, ok = hypervisor.CapabilitiesForType(hypervisor.TypeCloudHypervisor) + require.True(t, ok) + require.True(t, caps.SupportsDiskResize, "a v51.1 default must advertise disk-resize") + require.Contains(t, caps.FeatureIDs(), hypervisor.FeatureDiskResize) + + // The registry enumeration used by the capabilities endpoint must agree + // with the per-type lookup. + for _, rt := range hypervisor.RegisteredRuntimes() { + if rt.Type == hypervisor.TypeCloudHypervisor { + require.True(t, rt.Capabilities.SupportsDiskResize) + require.NoError(t, rt.LaunchErr, "cloud-hypervisor binaries are embedded; no launch prerequisites") + } + } +} + +// TestCapabilitiesAdvertiseForkOnEveryVersion pins that fork support is +// explicit and version-independent for cloud-hypervisor, matching the +// PrepareFork implementation in fork.go. +func TestCapabilitiesAdvertiseForkOnEveryVersion(t *testing.T) { + t.Parallel() + for _, v := range vmm.SupportedVersions { + require.True(t, CapabilitiesForVersion(v).SupportsFork, "version %s", v) + } +} diff --git a/lib/hypervisor/features.go b/lib/hypervisor/features.go new file mode 100644 index 00000000..8bfc9885 --- /dev/null +++ b/lib/hypervisor/features.go @@ -0,0 +1,84 @@ +package hypervisor + +// Stable per-runtime feature IDs derived from Capabilities. These are part of +// the public capabilities API contract: clients gate behavior on these IDs +// rather than on hypervisor names. Adding a client-visible Capabilities field +// means adding its ID here, deriving it in FeatureIDs, and documenting it in +// the OpenAPI CapabilitiesRuntime schema — HTTP handlers never derive feature +// lists themselves. +const ( + // FeatureSnapshots: snapshot/restore of instance state. + FeatureSnapshots = "snapshots" + // FeatureStandby: pause + memory snapshot, with later restore. + FeatureStandby = "standby" + // FeatureFork: cloning an instance from a stopped source. Derived from + // the explicit SupportsFork capability, which each backend keeps aligned + // with its VMStarter.PrepareFork implementation. Fork of a standby or + // running source additionally restores/creates snapshots, so clients + // must gate those operations on FeatureStandby as well — vz on macOS 13 + // advertises fork (stopped-source clones need no machine-state + // save/restore) without snapshots or standby. + FeatureFork = "fork" + // FeaturePause: pause/resume of a running instance. + FeaturePause = "pause" + // FeatureHotplugMemory: live memory resize. + FeatureHotplugMemory = "hotplug-memory" + // FeatureBalloonControl: runtime balloon target changes. + FeatureBalloonControl = "balloon-control" + // FeatureVsock: guest vsock communication. + FeatureVsock = "vsock" + // FeatureGPUPassthrough: GPU/PCI device passthrough. + FeatureGPUPassthrough = "gpu-passthrough" + // FeatureDiskIOLimit: disk I/O rate limiting. + FeatureDiskIOLimit = "disk-io-limit" + // FeatureDiskResize: live disk resize. + FeatureDiskResize = "disk-resize" +) + +// SupportsStandby reports whether standby is genuinely available. Standby +// pauses the VM and then snapshots its memory, so it requires both pause and +// snapshot support — neither alone is sufficient. +func (c Capabilities) SupportsStandby() bool { + return c.SupportsSnapshot && c.SupportsPause +} + +// FeatureIDs returns the stable feature IDs implied by this capability set, +// in a fixed deterministic order. Only client-visible features are included; +// internal lifecycle hints (snapshot base reuse, pager detachability, host +// snapshot version pinning, graceful VMM shutdown) are implementation details +// and deliberately have no IDs. The result is always non-nil so it serializes +// as an empty JSON array rather than null. +func (c Capabilities) FeatureIDs() []string { + ids := make([]string, 0, 10) + if c.SupportsSnapshot { + ids = append(ids, FeatureSnapshots) + } + if c.SupportsStandby() { + ids = append(ids, FeatureStandby) + } + if c.SupportsFork { + ids = append(ids, FeatureFork) + } + if c.SupportsPause { + ids = append(ids, FeaturePause) + } + if c.SupportsHotplugMemory { + ids = append(ids, FeatureHotplugMemory) + } + if c.SupportsBalloonControl { + ids = append(ids, FeatureBalloonControl) + } + if c.SupportsVsock { + ids = append(ids, FeatureVsock) + } + if c.SupportsGPUPassthrough { + ids = append(ids, FeatureGPUPassthrough) + } + if c.SupportsDiskIOLimit { + ids = append(ids, FeatureDiskIOLimit) + } + if c.SupportsDiskResize { + ids = append(ids, FeatureDiskResize) + } + return ids +} diff --git a/lib/hypervisor/features_test.go b/lib/hypervisor/features_test.go new file mode 100644 index 00000000..b3703ff1 --- /dev/null +++ b/lib/hypervisor/features_test.go @@ -0,0 +1,189 @@ +package hypervisor + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +func fullCapabilities() Capabilities { + return Capabilities{ + SupportsSnapshot: true, + SupportsFork: true, + SupportsHotplugMemory: true, + SupportsBalloonControl: true, + SupportsPause: true, + SupportsVsock: true, + SupportsGPUPassthrough: true, + SupportsDiskIOLimit: true, + SupportsDiskResize: true, + } +} + +func TestFeatureIDs(t *testing.T) { + t.Parallel() + + t.Run("zero capabilities yield an empty non-nil list", func(t *testing.T) { + t.Parallel() + ids := Capabilities{}.FeatureIDs() + require.NotNil(t, ids, "must serialize as [], not null") + require.Empty(t, ids) + }) + + t.Run("full capabilities yield every client-visible ID in fixed order", func(t *testing.T) { + t.Parallel() + require.Equal(t, []string{ + FeatureSnapshots, + FeatureStandby, + FeatureFork, + FeaturePause, + FeatureHotplugMemory, + FeatureBalloonControl, + FeatureVsock, + FeatureGPUPassthrough, + FeatureDiskIOLimit, + FeatureDiskResize, + }, fullCapabilities().FeatureIDs()) + }) + + t.Run("internal lifecycle hints have no feature IDs", func(t *testing.T) { + t.Parallel() + ids := Capabilities{ + SupportsGracefulVMMShutdown: true, + SupportsSnapshotBaseReuse: true, + RequiresHostSnapshotVersion: true, + SupportsConcurrentForkPrepare: true, + UsesDetachableSnapshotMemoryPager: true, + }.FeatureIDs() + require.Empty(t, ids) + }) + + t.Run("pause without snapshot yields pause only", func(t *testing.T) { + t.Parallel() + ids := Capabilities{SupportsPause: true}.FeatureIDs() + require.Equal(t, []string{FeaturePause}, ids) + }) + + t.Run("snapshot support alone must not advertise fork", func(t *testing.T) { + t.Parallel() + // A snapshot-capable backend may still reject PrepareFork with + // ErrNotSupported, so fork is an explicit capability — never inferred + // from snapshots. + ids := Capabilities{SupportsSnapshot: true}.FeatureIDs() + require.Equal(t, []string{FeatureSnapshots}, ids) + }) + + t.Run("explicit fork support yields the fork ID", func(t *testing.T) { + t.Parallel() + ids := Capabilities{SupportsSnapshot: true, SupportsFork: true}.FeatureIDs() + require.Equal(t, []string{FeatureSnapshots, FeatureFork}, ids) + }) + + t.Run("fork without snapshots is valid and yields fork alone", func(t *testing.T) { + t.Parallel() + // vz on macOS 13: stopped-source forks clone disks without + // machine-state save/restore, so fork is advertised while snapshots + // and standby are not. Clients gate hot-source forks on standby. + ids := Capabilities{SupportsFork: true}.FeatureIDs() + require.Equal(t, []string{FeatureFork}, ids) + }) +} + +// TestStandbySemantics pins that standby requires both snapshot and pause: a +// standby transition pauses the VM and then snapshots its memory. +func TestStandbySemantics(t *testing.T) { + t.Parallel() + require.False(t, Capabilities{SupportsSnapshot: true}.SupportsStandby()) + require.False(t, Capabilities{SupportsPause: true}.SupportsStandby()) + require.True(t, Capabilities{SupportsSnapshot: true, SupportsPause: true}.SupportsStandby()) +} + +// staticRegistration wraps a fixed capability set for enumeration tests. +func staticRegistration(caps Capabilities) RuntimeRegistration { + return RuntimeRegistration{Capabilities: func() Capabilities { return caps }} +} + +// TestEnumerateRuntimes exercises registry enumeration semantics against a +// local map so the global registry is never mutated by tests. +func TestEnumerateRuntimes(t *testing.T) { + t.Parallel() + + t.Run("empty registry yields an empty non-nil list", func(t *testing.T) { + t.Parallel() + runtimes := enumerateRuntimes(map[Type]RuntimeRegistration{}) + require.NotNil(t, runtimes) + require.Empty(t, runtimes) + }) + + t.Run("entries are sorted by type name for deterministic output", func(t *testing.T) { + t.Parallel() + byType := map[Type]RuntimeRegistration{ + TypeQEMU: staticRegistration(Capabilities{SupportsPause: true}), + TypeCloudHypervisor: staticRegistration(Capabilities{SupportsSnapshot: true}), + TypeQEMUMicroVM: staticRegistration(Capabilities{}), + TypeFirecracker: staticRegistration(Capabilities{SupportsVsock: true}), + } + runtimes := enumerateRuntimes(byType) + require.Equal(t, []Type{TypeCloudHypervisor, TypeFirecracker, TypeQEMU, TypeQEMUMicroVM}, + []Type{runtimes[0].Type, runtimes[1].Type, runtimes[2].Type, runtimes[3].Type}) + require.True(t, runtimes[0].Capabilities.SupportsSnapshot) + require.True(t, runtimes[1].Capabilities.SupportsVsock) + require.True(t, runtimes[2].Capabilities.SupportsPause) + }) + + t.Run("capabilities are resolved on every enumeration, not frozen", func(t *testing.T) { + t.Parallel() + // Mirrors config applied after init, e.g. a pinned cloud-hypervisor + // default version changing the effective capability set. + effective := Capabilities{SupportsDiskResize: true} + byType := map[Type]RuntimeRegistration{ + TypeCloudHypervisor: {Capabilities: func() Capabilities { return effective }}, + } + require.True(t, enumerateRuntimes(byType)[0].Capabilities.SupportsDiskResize) + effective.SupportsDiskResize = false + require.False(t, enumerateRuntimes(byType)[0].Capabilities.SupportsDiskResize, + "enumeration must reflect the provider's current value") + }) + + t.Run("launch checks gate availability", func(t *testing.T) { + t.Parallel() + launchErr := errors.New("qemu-system-x86_64 not found") + byType := map[Type]RuntimeRegistration{ + TypeCloudHypervisor: staticRegistration(Capabilities{}), + TypeQEMU: { + Capabilities: func() Capabilities { return Capabilities{} }, + LaunchCheck: func() error { return launchErr }, + }, + } + runtimes := enumerateRuntimes(byType) + require.True(t, runtimes[0].Available(), "nil LaunchCheck means registration implies launchability") + require.NoError(t, runtimes[0].LaunchErr) + require.False(t, runtimes[1].Available(), "a failing launch check must mark the runtime unavailable") + require.ErrorIs(t, runtimes[1].LaunchErr, launchErr) + }) + + t.Run("launch checks are re-evaluated on every enumeration", func(t *testing.T) { + t.Parallel() + // Installing the missing binary must flip availability without a + // server restart. + var launchErr error = errors.New("binary missing") + byType := map[Type]RuntimeRegistration{ + TypeQEMU: { + Capabilities: func() Capabilities { return Capabilities{} }, + LaunchCheck: func() error { return launchErr }, + }, + } + require.False(t, enumerateRuntimes(byType)[0].Available()) + launchErr = nil + require.True(t, enumerateRuntimes(byType)[0].Available()) + }) + + t.Run("results are value copies of the registry", func(t *testing.T) { + t.Parallel() + byType := map[Type]RuntimeRegistration{TypeQEMU: staticRegistration(Capabilities{SupportsPause: true})} + runtimes := enumerateRuntimes(byType) + runtimes[0].Capabilities.SupportsPause = false + require.True(t, byType[TypeQEMU].Capabilities().SupportsPause, "mutating enumeration output must not affect the registry") + }) +} diff --git a/lib/hypervisor/firecracker/binaries.go b/lib/hypervisor/firecracker/binaries.go index d9e2e104..acb3aafc 100644 --- a/lib/hypervisor/firecracker/binaries.go +++ b/lib/hypervisor/firecracker/binaries.go @@ -51,6 +51,25 @@ func getCustomBinaryPath() string { return customBinaryPath } +// checkLaunchPrerequisites verifies the launch prerequisites that +// platform-gated registration cannot express. The default binaries ship +// embedded in hypeman, but a configured hypervisor.firecracker_binary_path +// override takes precedence over them on every launch (resolveBinaryPath), +// so a missing or non-executable override means every launch fails and the +// runtime must not report available. Used as the capability registry's +// LaunchCheck; it is evaluated per registry read, so an override fixed after +// startup is reflected without a restart. +func checkLaunchPrerequisites() error { + path := getCustomBinaryPath() + if path == "" { + return nil + } + if err := validateExecutable(path); err != nil { + return fmt.Errorf("invalid firecracker custom binary path %q: %w", path, err) + } + return nil +} + func resolveBinaryPath(p *paths.Paths, version string) (string, error) { if path := getCustomBinaryPath(); path != "" { if err := validateExecutable(path); err != nil { diff --git a/lib/hypervisor/firecracker/firecracker.go b/lib/hypervisor/firecracker/firecracker.go index 1ef3edc0..8f068bc1 100644 --- a/lib/hypervisor/firecracker/firecracker.go +++ b/lib/hypervisor/firecracker/firecracker.go @@ -56,7 +56,9 @@ func (f *Firecracker) Capabilities() hypervisor.Capabilities { func capabilities() hypervisor.Capabilities { return hypervisor.Capabilities{ - SupportsSnapshot: true, + SupportsSnapshot: true, + // PrepareFork persists restore metadata overrides for forks (fork.go). + SupportsFork: true, SupportsHotplugMemory: false, SupportsBalloonControl: true, SupportsPause: true, diff --git a/lib/hypervisor/firecracker/process.go b/lib/hypervisor/firecracker/process.go index ac7f6b34..01fd8ce2 100644 --- a/lib/hypervisor/firecracker/process.go +++ b/lib/hypervisor/firecracker/process.go @@ -25,7 +25,6 @@ const ( func init() { hypervisor.RegisterSocketName(hypervisor.TypeFirecracker, "fc.sock") - hypervisor.RegisterCapabilities(hypervisor.TypeFirecracker, capabilities()) hypervisor.RegisterClientFactory(hypervisor.TypeFirecracker, func(socketPath string) (hypervisor.Hypervisor, error) { return New(socketPath) }) diff --git a/lib/hypervisor/firecracker/register_linux.go b/lib/hypervisor/firecracker/register_linux.go new file mode 100644 index 00000000..0a4f7177 --- /dev/null +++ b/lib/hypervisor/firecracker/register_linux.go @@ -0,0 +1,17 @@ +//go:build linux + +package firecracker + +import "github.com/kernel/hypeman/lib/hypervisor" + +// Firecracker requires KVM, so registration — and therefore its presence in +// hypervisor.RegisteredRuntimes — is Linux-only. The default binary ships +// embedded in hypeman, but hypervisor.firecracker_binary_path overrides it +// on every launch, so the LaunchCheck validates the active override (if any) +// with the same executable check the launch path applies. +func init() { + hypervisor.RegisterRuntime(hypervisor.TypeFirecracker, hypervisor.RuntimeRegistration{ + Capabilities: capabilities, + LaunchCheck: checkLaunchPrerequisites, + }) +} diff --git a/lib/hypervisor/firecracker/register_linux_test.go b/lib/hypervisor/firecracker/register_linux_test.go new file mode 100644 index 00000000..a88e0905 --- /dev/null +++ b/lib/hypervisor/firecracker/register_linux_test.go @@ -0,0 +1,67 @@ +//go:build linux + +package firecracker + +import ( + "os" + "path/filepath" + "testing" + + "github.com/kernel/hypeman/lib/hypervisor" + "github.com/stretchr/testify/require" +) + +// registeredFirecracker returns firecracker's capability-registry entry, +// re-resolved on every call so launch checks reflect the current +// configuration. +func registeredFirecracker(t *testing.T) hypervisor.RegisteredRuntime { + t.Helper() + for _, rt := range hypervisor.RegisteredRuntimes() { + if rt.Type == hypervisor.TypeFirecracker { + return rt + } + } + t.Fatal("firecracker must be registered on Linux") + return hypervisor.RegisteredRuntime{} +} + +// TestFirecrackerAvailabilityTracksBinaryOverride pins that firecracker's +// registered availability follows the configured binary override, which +// takes precedence over the embedded binaries on every launch +// (resolveBinaryPath): an invalid hypervisor.firecracker_binary_path means +// every launch fails, so the runtime must not report available; clearing or +// fixing the override must flip availability back without re-registration. +// Not parallel: it mutates the process-wide custom binary path. +func TestFirecrackerAvailabilityTracksBinaryOverride(t *testing.T) { + SetCustomBinaryPath("") + t.Cleanup(func() { SetCustomBinaryPath("") }) + + // No override: the embedded binary implies launchability. + rt := registeredFirecracker(t) + require.NoError(t, rt.LaunchErr) + require.True(t, rt.Available(), "firecracker with embedded binaries must be available") + + // Missing override: launches would fail, so availability must drop. + SetCustomBinaryPath("/does/not/exist/firecracker") + rt = registeredFirecracker(t) + require.Error(t, rt.LaunchErr) + require.False(t, rt.Available(), "a missing binary override must make firecracker unavailable") + require.Contains(t, rt.LaunchErr.Error(), "invalid firecracker custom binary path", + "the launch check must surface the same override error launches report") + + // Non-executable override: same verdict as the launch path's check. + nonExec := filepath.Join(t.TempDir(), "firecracker") + require.NoError(t, os.WriteFile(nonExec, []byte("#!/bin/sh\nexit 0\n"), 0644)) + SetCustomBinaryPath(nonExec) + rt = registeredFirecracker(t) + require.Error(t, rt.LaunchErr) + require.False(t, rt.Available(), "a non-executable binary override must make firecracker unavailable") + + // Valid executable override: available again, per registry read. + valid := filepath.Join(t.TempDir(), "firecracker") + require.NoError(t, os.WriteFile(valid, []byte("#!/bin/sh\nexit 0\n"), 0755)) + SetCustomBinaryPath(valid) + rt = registeredFirecracker(t) + require.NoError(t, rt.LaunchErr) + require.True(t, rt.Available(), "a valid binary override must restore availability without a restart") +} diff --git a/lib/hypervisor/hypervisor.go b/lib/hypervisor/hypervisor.go index 41928613..4b0613ad 100644 --- a/lib/hypervisor/hypervisor.go +++ b/lib/hypervisor/hypervisor.go @@ -4,10 +4,14 @@ package hypervisor import ( + "cmp" "context" "errors" "fmt" + "maps" "net" + "slices" + "sync" "time" "github.com/kernel/hypeman/lib/paths" @@ -47,9 +51,21 @@ var socketNames = make(map[Type]string) // Registered by hypervisor packages when they use socket-based vsock routing. var vsockSocketNames = make(map[Type]string) -// capabilitiesByType maps hypervisor types to their static capabilities. -// Registered by each hypervisor package's init() function. -var capabilitiesByType = make(map[Type]Capabilities) +// runtimeRegistrations maps hypervisor types to their registrations. +// Registered by each hypervisor package's init() function. Registration is +// platform-gated (see RegisterRuntime), so the map's keys double as the set +// of runtimes supported by this build on the current host platform. +// +// RegisterRuntime is public API, so custom backends may register after init +// — including while capability requests are being served. All access +// therefore goes through runtimeRegistrationsMu; readers snapshot the map +// under the lock and resolve registration callbacks only after releasing it, +// so a slow Capabilities resolver or LaunchCheck never blocks registration +// and callbacks can safely re-enter the registry. +var ( + runtimeRegistrationsMu sync.RWMutex + runtimeRegistrations = make(map[Type]RuntimeRegistration) +) // RegisterSocketName registers the socket filename for a hypervisor type. // Called by each hypervisor implementation's init() function. @@ -80,15 +96,119 @@ func VsockSocketNameForType(t Type) string { return "vsock.sock" } -// RegisterCapabilities registers static capabilities for a hypervisor type. +// RuntimeRegistration describes how a hypervisor backend reports itself in +// the capability registry. +type RuntimeRegistration struct { + // Capabilities resolves the runtime's effective capability set. It is + // evaluated on every registry read — never cached at registration time — + // so capabilities that depend on configuration applied after package init + // (for example the configured cloud-hypervisor default version) stay + // truthful. Required. + Capabilities func() Capabilities + + // LaunchCheck verifies host launch prerequisites that platform-gated + // registration cannot express, such as a required system-installed binary + // (QEMU). nil means registration alone implies launchability (backends + // that ship an embedded VMM binary). Like Capabilities, it is evaluated + // on every registry read so prerequisites fixed after startup are + // reflected without a restart. + LaunchCheck func() error +} + +// RegisterRuntime registers a hypervisor backend in the capability registry. +// Backends must register only on platforms where they can genuinely launch +// VMs: the Linux backends (cloud-hypervisor, firecracker, qemu) register from +// linux-only files, and vz registers from its darwin-only package. That keeps +// RegisteredRuntimes truthful without a hand-maintained platform switch. +func RegisterRuntime(t Type, reg RuntimeRegistration) { + if reg.Capabilities == nil { + panic(fmt.Sprintf("hypervisor: RegisterRuntime(%s) requires a Capabilities resolver", t)) + } + runtimeRegistrationsMu.Lock() + defer runtimeRegistrationsMu.Unlock() + runtimeRegistrations[t] = reg +} + +// RegisterCapabilities registers a static capability set for a hypervisor +// type with no launch check, so registration alone implies launchability. +// +// Deprecated: Use RegisterRuntime, which resolves capabilities per registry +// read (so configuration applied after init stays truthful) and supports an +// optional LaunchCheck for host launch prerequisites. RegisterCapabilities +// is kept so custom backends built against earlier versions of this module +// keep compiling; it wraps RegisterRuntime with a resolver that returns the +// given set unchanged. func RegisterCapabilities(t Type, caps Capabilities) { - capabilitiesByType[t] = caps + RegisterRuntime(t, RuntimeRegistration{ + Capabilities: func() Capabilities { return caps }, + }) } -// CapabilitiesForType returns static capabilities for a hypervisor type. +// CapabilitiesForType returns the effective capabilities for a hypervisor +// type, resolved at call time. It reports ok=false for runtimes that cannot +// run on the current host platform, because such runtimes never register. func CapabilitiesForType(t Type) (Capabilities, bool) { - caps, ok := capabilitiesByType[t] - return caps, ok + runtimeRegistrationsMu.RLock() + reg, ok := runtimeRegistrations[t] + runtimeRegistrationsMu.RUnlock() + if !ok { + return Capabilities{}, false + } + // Resolved after releasing the lock: resolvers may be slow or re-enter + // the registry. + return reg.Capabilities(), true +} + +// RegisteredRuntime is one capability-registry entry resolved at read time. +type RegisteredRuntime struct { + Type Type + Capabilities Capabilities + + // LaunchErr is non-nil when the runtime is supported by this build on + // this platform but its launch prerequisites are not currently met (for + // example QEMU registered on Linux without a system QEMU binary + // installed). Ordinary launches of the runtime will fail while it is + // non-nil. + LaunchErr error +} + +// Available reports whether ordinary launches of this runtime can succeed on +// this host: it is registered for the platform and its launch-prerequisite +// check passed. +func (r RegisteredRuntime) Available() bool { return r.LaunchErr == nil } + +// RegisteredRuntimes enumerates every registered runtime, sorted by type name +// so output is deterministic. Because registration is platform-gated, the +// result is the set of runtimes the linked backends support on the current +// host; per-entry Available/LaunchErr additionally reflect whether launch +// prerequisites are met right now. +func RegisteredRuntimes() []RegisteredRuntime { + runtimeRegistrationsMu.RLock() + snapshot := maps.Clone(runtimeRegistrations) + runtimeRegistrationsMu.RUnlock() + // Enumeration resolves capability and launch-check callbacks, so it runs + // on a snapshot taken under the lock rather than on the live map: + // concurrent RegisterRuntime calls must never race the iteration, and + // callbacks must never execute while the registry lock is held. + return enumerateRuntimes(snapshot) +} + +// enumerateRuntimes resolves the map's entries (capabilities and launch +// checks) and sorts them by type name. Split out from RegisteredRuntimes so +// enumeration semantics are testable without mutating the global registry. +func enumerateRuntimes(byType map[Type]RuntimeRegistration) []RegisteredRuntime { + runtimes := make([]RegisteredRuntime, 0, len(byType)) + for t, reg := range byType { + rt := RegisteredRuntime{Type: t, Capabilities: reg.Capabilities()} + if reg.LaunchCheck != nil { + rt.LaunchErr = reg.LaunchCheck() + } + runtimes = append(runtimes, rt) + } + slices.SortFunc(runtimes, func(a, b RegisteredRuntime) int { + return cmp.Compare(a.Type, b.Type) + }) + return runtimes } // VMStarter handles the full VM startup sequence. @@ -234,6 +354,18 @@ type Capabilities struct { // SupportsSnapshot indicates if Snapshot/Restore are available SupportsSnapshot bool + // SupportsFork indicates VMStarter.PrepareFork implements instance + // forking, which every fork source state needs. It is explicit rather + // than derived from SupportsSnapshot because the two are independent in + // both directions: a snapshot-capable backend may still reject + // PrepareFork with ErrNotSupported, and a backend can fork a stopped + // source (a disk clone, no machine-state snapshot involved) without + // snapshot support at all — vz does exactly that on macOS 13. Forking a + // standby or running source additionally restores/creates snapshots, so + // those operations require SupportsStandby() as well; SupportsFork alone + // promises only the stopped-source fork. + SupportsFork bool + // SupportsHotplugMemory indicates if ResizeMemory is available SupportsHotplugMemory bool diff --git a/lib/hypervisor/qemu/launch_check_cache.go b/lib/hypervisor/qemu/launch_check_cache.go new file mode 100644 index 00000000..88b52c8e --- /dev/null +++ b/lib/hypervisor/qemu/launch_check_cache.go @@ -0,0 +1,83 @@ +package qemu + +import ( + "sync" + "time" +) + +// launchCheckCacheTTL is how long a launch-prerequisite result stays valid. +// It is deliberately short: long enough that the standard and microvm +// registrations (and a burst of concurrent capability requests) share a +// single `qemu --version` execution, but short enough that installing QEMU or +// loading vhost_vsock flips availability promptly without a server restart. +const launchCheckCacheTTL = time.Second + +// launchCheckCache coalesces and briefly caches a launch-prerequisite probe. +// The probe executes the QEMU binary, so running it once per registry entry +// per request is wasteful and, with a hung binary, dangerous: both QEMU +// registrations share one cache instance, concurrent callers wait on a +// single in-flight probe instead of spawning duplicates, and results (success +// or failure alike) expire after ttl so repaired host prerequisites become +// visible on the next read. +type launchCheckCache struct { + probe func() error + ttl time.Duration + now func() time.Time // seam for deterministic expiry tests + + mu sync.Mutex + inflight chan struct{} // non-nil while a probe runs; closed on completion + valid bool + result error + expires time.Time +} + +func newLaunchCheckCache(probe func() error, ttl time.Duration) *launchCheckCache { + return &launchCheckCache{probe: probe, ttl: ttl, now: time.Now} +} + +// Check returns the cached probe result, refreshing it when expired. Exactly +// one caller runs the probe at a time; concurrent callers needing a fresh +// result block until the in-flight probe completes and then share it rather +// than spawning duplicates. The probe runs outside the lock, so reads that +// hit a still-valid entry are never blocked by a slow probe. +func (c *launchCheckCache) Check() error { + for { + c.mu.Lock() + if c.valid && c.now().Before(c.expires) { + err := c.result + c.mu.Unlock() + return err + } + if c.inflight != nil { + wait := c.inflight + c.mu.Unlock() + <-wait + // Re-read: the finished probe populated the cache (or another + // refresh is already underway). + continue + } + done := make(chan struct{}) + c.inflight = done + c.mu.Unlock() + + err := c.probe() + + c.mu.Lock() + c.valid = true + c.result = err + c.expires = c.now().Add(c.ttl) + c.inflight = nil + c.mu.Unlock() + close(done) + return err + } +} + +// Invalidate drops any cached result so the next Check re-probes. It is the +// explicit invalidation seam for callers (and tests) that know host state +// changed and should not wait out the TTL. +func (c *launchCheckCache) Invalidate() { + c.mu.Lock() + c.valid = false + c.mu.Unlock() +} diff --git a/lib/hypervisor/qemu/launch_check_cache_test.go b/lib/hypervisor/qemu/launch_check_cache_test.go new file mode 100644 index 00000000..1817ca3f --- /dev/null +++ b/lib/hypervisor/qemu/launch_check_cache_test.go @@ -0,0 +1,116 @@ +package qemu + +import ( + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// newTestCache builds a private cache instance around the given probe with a +// manually advanced clock. Tests never touch the package's shared +// launchPrereqCache, so parallel tests (and the real registrations) are +// unaffected. +func newTestCache(probe func() error, ttl time.Duration) (*launchCheckCache, *time.Time) { + c := newLaunchCheckCache(probe, ttl) + now := time.Unix(1700000000, 0) + c.now = func() time.Time { return now } + return c, &now +} + +// TestLaunchCheckCacheCoalescesConcurrentProbes pins that concurrent Check +// calls on an empty cache share a single in-flight probe: the standard and +// microvm registrations plus a burst of capability requests must not each +// execute `qemu --version`. +func TestLaunchCheckCacheCoalescesConcurrentProbes(t *testing.T) { + t.Parallel() + + const callers = 16 + var probes atomic.Int32 + release := make(chan struct{}) + entered := make(chan struct{}) + cache, _ := newTestCache(func() error { + probes.Add(1) + close(entered) // panics if a duplicate probe ever starts + <-release + return nil + }, time.Minute) + + var wg sync.WaitGroup + results := make([]error, callers) + for i := range callers { + wg.Add(1) + go func() { + defer wg.Done() + results[i] = cache.Check() + }() + } + <-entered + close(release) + wg.Wait() + + require.Equal(t, int32(1), probes.Load(), "concurrent callers must coalesce onto one probe") + for i := range callers { + require.NoError(t, results[i], "every waiter must receive the shared probe result") + } +} + +// TestLaunchCheckCacheExpiry pins TTL semantics: results (successes and +// failures alike) are served from cache within the TTL and re-probed after it +// elapses, so a repaired host prerequisite becomes visible without a restart. +func TestLaunchCheckCacheExpiry(t *testing.T) { + t.Parallel() + + probeErr := errors.New("qemu binary missing") + var result error = probeErr + var probes int + cache, now := newTestCache(func() error { + probes++ + return result + }, time.Second) + + require.ErrorIs(t, cache.Check(), probeErr) + require.Equal(t, 1, probes) + + // Within the TTL the cached failure is served without re-probing. + *now = now.Add(500 * time.Millisecond) + require.ErrorIs(t, cache.Check(), probeErr) + require.Equal(t, 1, probes, "a fresh result must be served from cache") + + // After the TTL the probe runs again and a repaired prerequisite + // (installed binary) is reported. + result = nil + *now = now.Add(time.Second) + require.NoError(t, cache.Check()) + require.Equal(t, 2, probes, "an expired result must be re-probed") + + // A subsequent failure is likewise picked up after expiry. + result = probeErr + *now = now.Add(2 * time.Second) + require.ErrorIs(t, cache.Check(), probeErr) + require.Equal(t, 3, probes) +} + +// TestLaunchCheckCacheInvalidate pins the explicit invalidation seam: callers +// that know host state changed can force the next Check to re-probe without +// waiting out the TTL. +func TestLaunchCheckCacheInvalidate(t *testing.T) { + t.Parallel() + + var probes int + cache, _ := newTestCache(func() error { + probes++ + return nil + }, time.Hour) + + require.NoError(t, cache.Check()) + require.NoError(t, cache.Check()) + require.Equal(t, 1, probes, "an unexpired result must be served from cache") + + cache.Invalidate() + require.NoError(t, cache.Check()) + require.Equal(t, 2, probes, "Invalidate must force the next Check to re-probe") +} diff --git a/lib/hypervisor/qemu/machine_test.go b/lib/hypervisor/qemu/machine_test.go index b086bbc6..9998d928 100644 --- a/lib/hypervisor/qemu/machine_test.go +++ b/lib/hypervisor/qemu/machine_test.go @@ -73,6 +73,14 @@ func TestMicroVMCapabilitiesExcludePCIPassthrough(t *testing.T) { assert.True(t, (MicroVMProfile{}).capabilities().RequiresHostSnapshotVersion) } +// TestQEMUCapabilitiesAdvertiseFork pins that both QEMU boards advertise fork +// explicitly, matching the shared PrepareFork implementation in fork.go. +func TestQEMUCapabilitiesAdvertiseFork(t *testing.T) { + t.Parallel() + assert.True(t, (StandardProfile{}).capabilities().SupportsFork) + assert.True(t, (MicroVMProfile{}).capabilities().SupportsFork) +} + func TestValidateConfigMicroVM(t *testing.T) { t.Parallel() if _, err := microVMMachineType(); err != nil { diff --git a/lib/hypervisor/qemu/process.go b/lib/hypervisor/qemu/process.go index ab6438eb..c02c5bee 100644 --- a/lib/hypervisor/qemu/process.go +++ b/lib/hypervisor/qemu/process.go @@ -4,6 +4,7 @@ package qemu import ( "context" "encoding/json" + "errors" "fmt" "net" "os" @@ -49,7 +50,6 @@ func init() { for _, profile := range []profile{StandardProfile{}, MicroVMProfile{}} { hypervisorType := profile.hypervisorType() hypervisor.RegisterSocketName(hypervisorType, "qemu.sock") - hypervisor.RegisterCapabilities(hypervisorType, profile.capabilities()) t := hypervisorType hypervisor.RegisterClientFactory(t, func(socketPath string) (hypervisor.Hypervisor, error) { return NewForType(socketPath, t) @@ -136,9 +136,53 @@ func (s *Starter) detectVersion(p *paths.Paths) (string, error) { if err != nil { return "", err } - - cmd := exec.Command(binaryPath, "--version") + ctx, cancel := context.WithTimeout(context.Background(), versionProbeTimeout) + defer cancel() + return versionFromBinary(ctx, binaryPath) +} + +// versionProbeTimeout bounds a single `qemu --version` execution. The probe +// runs on capability requests (launch checks) as well as launches, so a hung +// or broken binary must fail the caller promptly instead of leaking +// subprocesses and goroutines indefinitely. +const versionProbeTimeout = 5 * time.Second + +// versionFromBinary runs the resolved QEMU binary's --version and parses the +// installed version from its output. It is the version probe behind +// GetVersion/ResolveVersion — every cold start persists its result — and the +// capability registry's launch check reuses it so "available" means the same +// binary execution that launches require actually succeeds. Execution is +// bounded by ctx (callers pass a versionProbeTimeout-derived context). The +// probe runs in its own process group, and the whole group is SIGKILLed on +// every completion path: the default CommandContext cancel only signals the +// direct child (orphaning a wrapper script's children past the deadline), +// and Cancel never fires at all when the direct child exits before the +// deadline — a wrapper that leaves a background descendant holding the +// output pipe would otherwise leak that descendant on every probe, with +// WaitDelay merely unblocking Output rather than cleaning up. ESRCH from the +// group kill means every group member already exited. +func versionFromBinary(ctx context.Context, binaryPath string) (string, error) { + cmd := exec.CommandContext(ctx, binaryPath, "--version") + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { + // Setpgid makes the child's pgid its own pid; signal the group so + // descendants die (and get reaped by init) with the probe. + err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + if errors.Is(err, syscall.ESRCH) { + return os.ErrProcessDone + } + return err + } + cmd.WaitDelay = time.Second output, err := cmd.Output() + if cmd.Process != nil { + // Kill the group on every completion path, not only via cmd.Cancel: + // Cancel never runs when the direct child exits before the context + // deadline, so descendants it left behind (still pinning the child's + // pid as their pgid) must be reaped here. A no-op ESRCH when the + // group is already empty. + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + } if err != nil { return "", fmt.Errorf("get qemu version: %w", err) } @@ -153,6 +197,25 @@ func (s *Starter) detectVersion(p *paths.Paths) (string, error) { return "", fmt.Errorf("could not parse QEMU version from: %s", string(output)) } +// validateExecutable rejects paths that cannot be executed as the QEMU +// binary: missing files, directories, and files without an execute bit. +// GetBinaryPath's fixed /usr/bin and /usr/local/bin candidates are accepted +// on a bare os.Stat, so launch-prerequisite checks must not assume a +// resolved path is runnable. +func validateExecutable(path string) error { + info, err := os.Stat(path) + if err != nil { + return err + } + if info.IsDir() { + return fmt.Errorf("path is a directory") + } + if info.Mode()&0111 == 0 { + return fmt.Errorf("file is not executable") + } + return nil +} + // buildQMPArgs returns the base QMP socket arguments for QEMU. func buildQMPArgs(socketPath string) []string { return []string{ diff --git a/lib/hypervisor/qemu/process_test.go b/lib/hypervisor/qemu/process_test.go index 2a9a1013..e8be0dda 100644 --- a/lib/hypervisor/qemu/process_test.go +++ b/lib/hypervisor/qemu/process_test.go @@ -1,11 +1,15 @@ package qemu import ( + "context" "errors" "os" "os/exec" "path/filepath" "regexp" + "strconv" + "strings" + "syscall" "testing" "time" @@ -106,6 +110,174 @@ func TestGetVersion_ParsesVersionCorrectly(t *testing.T) { } } +// retryingETXTBSY retries run while it fails with ETXTBSY: tests exec +// just-written scripts, and another parallel test's fork can transiently +// inherit a script's write descriptor between its own fork and exec (the +// well-known fork/exec text-file-busy race), failing exec before the script +// ever runs. Only that pre-execution error is retried, so assertions about +// probe behavior are unaffected. +func retryingETXTBSY(t *testing.T, run func() error) error { + t.Helper() + for attempt := 0; ; attempt++ { + err := run() + if errors.Is(err, syscall.ETXTBSY) && attempt < 100 { + time.Sleep(5 * time.Millisecond) + continue + } + return err + } +} + +// probeRetryingETXTBSY runs versionFromBinary with a fresh caller-supplied +// context per attempt (a retry must not consume the probe deadline the +// caller asserts on), retrying the ETXTBSY exec race. Returns the final +// attempt's start time. +func probeRetryingETXTBSY(t *testing.T, binaryPath string, newCtx func() (context.Context, context.CancelFunc)) (time.Time, error) { + t.Helper() + var start time.Time + err := retryingETXTBSY(t, func() error { + ctx, cancel := newCtx() + defer cancel() + start = time.Now() + _, err := versionFromBinary(ctx, binaryPath) + return err + }) + return start, err +} + +// probeBackstopTimeout bounds the probe in tests that cancel it explicitly +// (or expect it to finish on its own): generous enough that slow CI runners +// — macOS can take hundreds of milliseconds to first-exec a fresh script — +// never hit it, but far below the scripts' 60s sleeps so a regression still +// fails fast instead of running descendants to completion. +const probeBackstopTimeout = 30 * time.Second + +// pidFileReady reports whether a probe script has fully recorded a pid: +// `echo $$ > file` creates the file before writing, so existence alone can +// race an empty read. +func pidFileReady(path string) bool { + data, err := os.ReadFile(path) + return err == nil && strings.TrimSpace(string(data)) != "" +} + +// TestVersionFromBinaryKillsProcessGroupOnTimeout pins that a cancelled +// version probe terminates its entire process tree, not just the direct +// child. A QEMU wrapper script that spawns a descendant (`sleep 60 & wait`) +// must leave neither the wrapper nor the descendant behind after the probe's +// context is cancelled; otherwise every capability request against a wedged +// binary would orphan a subprocess under PID 1. The test cancels the context +// itself once the script has recorded both pids — a fixed short deadline +// races slow script startup and can kill the group before the pid files +// exist. +func TestVersionFromBinaryKillsProcessGroupOnTimeout(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + parentPIDFile := filepath.Join(dir, "parent.pid") + childPIDFile := filepath.Join(dir, "child.pid") + binaryPath := filepath.Join(dir, "qemu-system-fake") + // Write both PIDs before blocking so the test can always observe them, + // then hang in `wait` with a background descendant — the shape that + // leaks when cancellation only signals the direct child. + script := "#!/bin/sh\n" + + "echo $$ > " + parentPIDFile + "\n" + + "sleep 60 &\n" + + "echo $! > " + childPIDFile + "\n" + + "wait\n" + require.NoError(t, os.WriteFile(binaryPath, []byte(script), 0o755)) + + // Cancel the probe as soon as the script has demonstrably hung with its + // descendant running (both pids recorded), never before. + watchCtx, stopWatch := context.WithCancel(context.Background()) + defer stopWatch() + go func() { + for watchCtx.Err() == nil { + if pidFileReady(parentPIDFile) && pidFileReady(childPIDFile) { + stopWatch() + return + } + time.Sleep(10 * time.Millisecond) + } + }() + + start, err := probeRetryingETXTBSY(t, binaryPath, func() (context.Context, context.CancelFunc) { + return context.WithTimeout(watchCtx, probeBackstopTimeout) + }) + require.Error(t, err, "a hung probe must fail when its context is cancelled") + require.Less(t, time.Since(start), probeBackstopTimeout, + "a hung probe must return at cancellation, not run to completion") + + readPID := func(path string) int { + data, readErr := os.ReadFile(path) + require.NoError(t, readErr, "probe script must have recorded %s before hanging", path) + pid, convErr := strconv.Atoi(strings.TrimSpace(string(data))) + require.NoError(t, convErr) + return pid + } + parentPID := readPID(parentPIDFile) + childPID := readPID(childPIDFile) + + processGone := func(pid int) bool { + // Signal 0 probes existence; a killed-but-unreaped descendant still + // answers until init reaps it, hence Eventually below. + return errors.Is(syscall.Kill(pid, 0), syscall.ESRCH) + } + require.Eventually(t, func() bool { + return processGone(parentPID) && processGone(childPID) + }, 5*time.Second, 20*time.Millisecond, + "both the probe wrapper (pid %d) and its descendant (pid %d) must be killed with the process group", + parentPID, childPID) +} + +// TestVersionFromBinaryKillsDescendantsWhenWrapperExitsFirst pins the +// completion-path group kill. cmd.Cancel only fires on context cancellation, +// so when the direct wrapper prints a valid version and exits immediately +// while a background descendant keeps the inherited stdout pipe open, no +// cancellation ever runs: Output merely unblocks after WaitDelay with +// ErrWaitDelay and — before the fix — the descendant survived, so every +// capability-cache refresh against such a binary leaked one process. +func TestVersionFromBinaryKillsDescendantsWhenWrapperExitsFirst(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + childPIDFile := filepath.Join(dir, "child.pid") + binaryPath := filepath.Join(dir, "qemu-system-fake") + // The wrapper answers the probe correctly, spawns a descendant that + // inherits (and holds) the stdout pipe, and exits before the context + // deadline — the shape where cmd.Cancel never runs. + script := "#!/bin/sh\n" + + "echo 'QEMU emulator version 8.2.0'\n" + + "sleep 60 &\n" + + "echo $! > " + childPIDFile + "\n" + + "exit 0\n" + require.NoError(t, os.WriteFile(binaryPath, []byte(script), 0o755)) + + start, err := probeRetryingETXTBSY(t, binaryPath, func() (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), probeBackstopTimeout) + }) + // The descendant holds the output pipe past WaitDelay, so the probe + // reports ErrWaitDelay rather than success — which also proves it did not + // run to the context deadline; what must never happen is the descendant + // outliving the probe. + require.ErrorIs(t, err, exec.ErrWaitDelay, + "a wrapper whose descendant holds the output pipe completes via WaitDelay") + require.Less(t, time.Since(start), probeBackstopTimeout, + "the wrapper exited immediately; the probe must return at WaitDelay, not the context deadline") + + // The wrapper wrote the descendant's pid before exiting, and the probe + // cannot return before the wrapper exits, so the file is complete here. + data, readErr := os.ReadFile(childPIDFile) + require.NoError(t, readErr, "probe wrapper must have recorded its descendant's pid before exiting") + childPID, convErr := strconv.Atoi(strings.TrimSpace(string(data))) + require.NoError(t, convErr) + + require.Eventually(t, func() bool { + return errors.Is(syscall.Kill(childPID, 0), syscall.ESRCH) + }, 5*time.Second, 20*time.Millisecond, + "background descendant (pid %d) must be killed when the probe completes, even though the wrapper exited before the context deadline", + childPID) +} + func TestSaveAndLoadVMConfigPreservesRestoreContract(t *testing.T) { dir := t.TempDir() want := savedVMConfig{ diff --git a/lib/hypervisor/qemu/profile.go b/lib/hypervisor/qemu/profile.go index 08ec4122..2ac3a2cb 100644 --- a/lib/hypervisor/qemu/profile.go +++ b/lib/hypervisor/qemu/profile.go @@ -67,7 +67,10 @@ func (MicroVMProfile) requiresStoredVersion() bool { return true } func qemuCapabilities(supportsPCI bool) hypervisor.Capabilities { return hypervisor.Capabilities{ - SupportsSnapshot: true, + SupportsSnapshot: true, + // PrepareFork rewrites the saved QEMU VM config for forks (fork.go); + // both boards share the implementation. + SupportsFork: true, SupportsHotplugMemory: false, SupportsBalloonControl: true, SupportsPause: true, diff --git a/lib/hypervisor/qemu/register_linux.go b/lib/hypervisor/qemu/register_linux.go new file mode 100644 index 00000000..532c118e --- /dev/null +++ b/lib/hypervisor/qemu/register_linux.go @@ -0,0 +1,107 @@ +//go:build linux + +package qemu + +import ( + "context" + "fmt" + "os" + + "github.com/kernel/hypeman/lib/hypervisor" +) + +// vhostVsockDevicePath is the host device QEMU's vhost-vsock backend opens. +// Every created instance is assigned a nonzero vsock CID (lib/instances), so +// buildArgs always adds a vhost-vsock device and ordinary launches fail +// without it. API startup validates KVM only, so availability must check it. +const vhostVsockDevicePath = "/dev/vhost-vsock" + +// QEMU guests launch with KVM acceleration and kernel AF_VSOCK, so +// registration — and therefore presence in hypervisor.RegisteredRuntimes — is +// Linux-only. The microvm board additionally exists only for x86, so +// qemu-microvm registers only where its machine type resolves (linux/amd64); +// the same resolver rejects it at launch time. +// +// Unlike the other Linux backends, QEMU uses the host-installed system binary +// and hypeman deliberately starts without it (cmd/api/main.go only warns), so +// both QEMU registrations carry a LaunchCheck verifying the launch +// prerequisites. Registration means "supported by this build"; the check +// determines availability. +func init() { + hypervisor.RegisterRuntime(hypervisor.TypeQEMU, hypervisor.RuntimeRegistration{ + Capabilities: StandardProfile{}.capabilities, + LaunchCheck: launchPrereqCache.Check, + }) + if _, err := microVMMachineType(); err == nil { + hypervisor.RegisterRuntime(hypervisor.TypeQEMUMicroVM, hypervisor.RuntimeRegistration{ + Capabilities: MicroVMProfile{}.capabilities, + LaunchCheck: launchPrereqCache.Check, + }) + } +} + +// launchPrereqCache is the single shared launch-prerequisite result for both +// QEMU boards: the standard and microvm registrations verify the same host +// prerequisites (the same system binary and vsock device), so they share one +// briefly-cached probe instead of each executing `qemu --version` on every +// registry read — and concurrent capability requests coalesce onto one +// in-flight probe. The short TTL keeps availability live: installing QEMU or +// loading vhost_vsock flips it within launchCheckCacheTTL, no restart needed. +var launchPrereqCache = newLaunchCheckCache(checkLaunchPrerequisites, launchCheckCacheTTL) + +// checkLaunchPrerequisites is the capability registry's side-effect-free +// launch check for both QEMU boards (via launchPrereqCache): it resolves the +// system binary with the same lookup launches use, then verifies the +// prerequisites every ordinary launch needs. The version probe it runs is +// bounded by versionProbeTimeout so a hung binary fails the check instead of +// wedging capability requests. +func checkLaunchPrerequisites() error { + binaryPath, err := (&Starter{}).GetBinaryPath(nil, "") + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), versionProbeTimeout) + defer cancel() + return checkLaunchPrerequisitesFor(ctx, binaryPath, vhostVsockDevicePath) +} + +// checkLaunchPrerequisitesFor verifies that a resolved QEMU binary and the +// host vsock device can back an ordinary launch: +// +// - the binary must actually execute and report a parseable version +// (versionFromBinary), because every cold start persists ResolveVersion's +// result and treats failure as fatal — a non-executable or broken binary +// accepted by GetBinaryPath's bare os.Stat must not report available; +// - the vhost-vsock host device must be a character device that this process +// can open read/write, because every created instance receives a nonzero +// vsock CID and buildArgs unconditionally attaches a vhost-vsock device. +// +// Split from checkLaunchPrerequisites so unavailable cases are testable with +// fake binaries and device paths regardless of the host's QEMU install. +func checkLaunchPrerequisitesFor(ctx context.Context, binaryPath, vsockDevicePath string) error { + if err := validateExecutable(binaryPath); err != nil { + return fmt.Errorf("qemu binary %s is not executable: %w", binaryPath, err) + } + if _, err := versionFromBinary(ctx, binaryPath); err != nil { + return fmt.Errorf("qemu binary %s is not usable: %w", binaryPath, err) + } + return validateVsockDevice(vsockDevicePath, os.OpenFile) +} + +func validateVsockDevice(path string, openFile func(string, int, os.FileMode) (*os.File, error)) error { + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("vsock device %s is required for instance launches (load the vhost_vsock kernel module): %w", path, err) + } + if info.Mode().Type() != os.ModeDevice|os.ModeCharDevice { + return fmt.Errorf("vsock device %s must be a character device (load the vhost_vsock kernel module)", path) + } + device, err := openFile(path, os.O_RDWR, 0) + if err != nil { + return fmt.Errorf("vsock device %s is not accessible read/write for instance launches: %w", path, err) + } + if err := device.Close(); err != nil { + return fmt.Errorf("close vsock device %s after launch check: %w", path, err) + } + return nil +} diff --git a/lib/hypervisor/qemu/register_linux_test.go b/lib/hypervisor/qemu/register_linux_test.go new file mode 100644 index 00000000..fdd611a4 --- /dev/null +++ b/lib/hypervisor/qemu/register_linux_test.go @@ -0,0 +1,208 @@ +//go:build linux + +package qemu + +import ( + "context" + "os" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/kernel/hypeman/lib/hypervisor" + "github.com/stretchr/testify/require" +) + +// TestMicroVMRegistrationMatchesBoardSupport pins that qemu-microvm's presence +// in the capability registry tracks the same machine-type resolver that gates +// launches, so the registry never advertises a board the host cannot boot. +func TestMicroVMRegistrationMatchesBoardSupport(t *testing.T) { + t.Parallel() + + _, qemuRegistered := hypervisor.CapabilitiesForType(hypervisor.TypeQEMU) + require.True(t, qemuRegistered, "standard qemu must register on Linux") + + _, boardErr := resolveMachineTypeForPlatform(MachineTypeMicroVM, runtime.GOOS, runtime.GOARCH) + _, microVMRegistered := hypervisor.CapabilitiesForType(hypervisor.TypeQEMUMicroVM) + require.Equal(t, boardErr == nil, microVMRegistered, + "qemu-microvm registration must match microvm board support on %s/%s", runtime.GOOS, runtime.GOARCH) +} + +// TestQEMUAvailabilityTracksLaunchPrerequisites pins that registered QEMU +// runtimes report availability from the full launch-prerequisite check — +// binary resolution, executability/version, and the vhost-vsock host device — +// not just registration: hypeman deliberately starts without a system QEMU +// (cmd/api/main.go only warns) and validates KVM only. +func TestQEMUAvailabilityTracksLaunchPrerequisites(t *testing.T) { + t.Parallel() + + prereqErr := checkLaunchPrerequisites() + checked := 0 + for _, rt := range hypervisor.RegisteredRuntimes() { + if rt.Type != hypervisor.TypeQEMU && rt.Type != hypervisor.TypeQEMUMicroVM { + continue + } + checked++ + require.Equal(t, prereqErr == nil, rt.Available(), + "%s availability must match the launch-prerequisite check", rt.Type) + if prereqErr != nil { + require.Error(t, rt.LaunchErr) + } else { + require.NoError(t, rt.LaunchErr) + } + } + require.GreaterOrEqual(t, checked, 1, "standard qemu must be registered on Linux") +} + +// TestLaunchCheckCacheSharedByRegistrations pins that both QEMU registry +// entries resolve availability through the same shared cache instance with +// the short production TTL, so one registry read (which checks both boards) +// executes at most one probe per TTL window while repaired prerequisites +// still surface promptly. +func TestLaunchCheckCacheSharedByRegistrations(t *testing.T) { + t.Parallel() + require.NotNil(t, launchPrereqCache) + require.Equal(t, launchCheckCacheTTL, launchPrereqCache.ttl) +} + +// fakeQEMUBinary writes an executable script that mimics `qemu --version` +// output, standing in for a working system QEMU. +func fakeQEMUBinary(t *testing.T, dir string) string { + t.Helper() + path := filepath.Join(dir, "qemu-system-fake") + script := "#!/bin/sh\necho 'QEMU emulator version 8.2.0 (fake)'\n" + require.NoError(t, os.WriteFile(path, []byte(script), 0o755)) + return path +} + +// fakeVsockDevice uses a harmless character device as a stand-in for +// /dev/vhost-vsock. The prerequisite check verifies node type and O_RDWR +// access; it deliberately does not issue vhost ioctls during diagnostics. +func fakeVsockDevice(t *testing.T, _ string) string { + t.Helper() + const path = "/dev/null" + info, err := os.Stat(path) + require.NoError(t, err) + require.Equal(t, os.ModeDevice|os.ModeCharDevice, info.Mode().Type()) + return path +} + +// TestCheckLaunchPrerequisitesFor covers the unavailable cases GetBinaryPath's +// bare os.Stat lookup cannot see: non-executable binaries, binaries that fail +// to run or report an unparseable version, and a missing vhost-vsock device — +// each of which makes every ordinary QEMU launch fail and must therefore +// report unavailable. +func TestCheckLaunchPrerequisitesFor(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + t.Run("working binary and device pass", func(t *testing.T) { + dir := t.TempDir() + binary, vsock := fakeQEMUBinary(t, dir), fakeVsockDevice(t, dir) + require.NoError(t, retryingETXTBSY(t, func() error { + return checkLaunchPrerequisitesFor(ctx, binary, vsock) + })) + }) + + t.Run("missing binary fails", func(t *testing.T) { + dir := t.TempDir() + err := checkLaunchPrerequisitesFor(ctx, filepath.Join(dir, "missing"), fakeVsockDevice(t, dir)) + require.ErrorContains(t, err, "not executable") + }) + + t.Run("non-executable binary fails", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "qemu-system-fake") + require.NoError(t, os.WriteFile(path, []byte("#!/bin/sh\necho 'QEMU emulator version 8.2.0'\n"), 0o644)) + err := checkLaunchPrerequisitesFor(ctx, path, fakeVsockDevice(t, dir)) + require.ErrorContains(t, err, "not executable") + }) + + t.Run("broken binary fails", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "qemu-system-fake") + require.NoError(t, os.WriteFile(path, []byte("#!/bin/sh\nexit 1\n"), 0o755)) + vsock := fakeVsockDevice(t, dir) + err := retryingETXTBSY(t, func() error { + return checkLaunchPrerequisitesFor(ctx, path, vsock) + }) + require.ErrorContains(t, err, "not usable") + }) + + t.Run("unparseable version fails", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "qemu-system-fake") + require.NoError(t, os.WriteFile(path, []byte("#!/bin/sh\necho 'not qemu'\n"), 0o755)) + vsock := fakeVsockDevice(t, dir) + err := retryingETXTBSY(t, func() error { + return checkLaunchPrerequisitesFor(ctx, path, vsock) + }) + require.ErrorContains(t, err, "not usable") + }) + + t.Run("missing vsock device fails", func(t *testing.T) { + dir := t.TempDir() + binary := fakeQEMUBinary(t, dir) + err := retryingETXTBSY(t, func() error { + return checkLaunchPrerequisitesFor(ctx, binary, filepath.Join(dir, "no-vhost-vsock")) + }) + require.ErrorContains(t, err, "vsock device") + require.ErrorContains(t, err, "vhost_vsock") + }) + + t.Run("regular file is not a vsock device", func(t *testing.T) { + dir := t.TempDir() + binary := fakeQEMUBinary(t, dir) + path := filepath.Join(dir, "vhost-vsock") + require.NoError(t, os.WriteFile(path, nil, 0o600)) + err := retryingETXTBSY(t, func() error { + return checkLaunchPrerequisitesFor(ctx, binary, path) + }) + require.ErrorContains(t, err, "must be a character device") + }) + + t.Run("directory is not a vsock device", func(t *testing.T) { + dir := t.TempDir() + binary := fakeQEMUBinary(t, dir) + err := retryingETXTBSY(t, func() error { + return checkLaunchPrerequisitesFor(ctx, binary, dir) + }) + require.ErrorContains(t, err, "must be a character device") + }) + + t.Run("inaccessible character device fails", func(t *testing.T) { + err := validateVsockDevice("/dev/null", func(string, int, os.FileMode) (*os.File, error) { + return nil, os.ErrPermission + }) + require.ErrorContains(t, err, "not accessible read/write") + require.ErrorIs(t, err, os.ErrPermission) + }) + + t.Run("hung binary fails at the context deadline", func(t *testing.T) { + // A wedged QEMU binary must fail the prerequisite check when its + // bounded context expires instead of blocking the capability request + // (and leaking a subprocess) indefinitely. `exec sleep` replaces the + // shell so this covers the direct-child case; descendant cleanup is + // pinned by TestVersionFromBinaryKillsProcessGroupOnTimeout (cancelled + // probe) and TestVersionFromBinaryKillsDescendantsWhenWrapperExitsFirst + // (wrapper exits first), which assert versionFromBinary reaps the + // whole tree on both completion paths. + dir := t.TempDir() + path := filepath.Join(dir, "qemu-system-fake") + require.NoError(t, os.WriteFile(path, []byte("#!/bin/sh\nexec sleep 60\n"), 0o755)) + vsock := fakeVsockDevice(t, dir) + var start time.Time + err := retryingETXTBSY(t, func() error { + // Fresh deadline per attempt so an ETXTBSY retry doesn't eat it. + shortCtx, cancel := context.WithTimeout(ctx, 100*time.Millisecond) + defer cancel() + start = time.Now() + return checkLaunchPrerequisitesFor(shortCtx, path, vsock) + }) + require.ErrorContains(t, err, "not usable") + require.Less(t, time.Since(start), 10*time.Second, + "a hung probe must return at the deadline, not run to completion") + }) +} diff --git a/lib/hypervisor/register_compat_test.go b/lib/hypervisor/register_compat_test.go new file mode 100644 index 00000000..17de5877 --- /dev/null +++ b/lib/hypervisor/register_compat_test.go @@ -0,0 +1,34 @@ +package hypervisor + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestRegisterCapabilitiesCompat pins the deprecated RegisterCapabilities +// wrapper that keeps custom backends built against earlier versions of this +// module compiling: it must register a runtime whose capabilities resolve to +// exactly the given static set and which reports available (no launch +// check), matching the old registration semantics. +func TestRegisterCapabilitiesCompat(t *testing.T) { + t.Parallel() + typ := Type("register-capabilities-compat-test") + want := Capabilities{SupportsSnapshot: true, SupportsPause: true, SupportsVsock: true} + RegisterCapabilities(typ, want) + + got, ok := CapabilitiesForType(typ) + require.True(t, ok, "RegisterCapabilities must register the type") + require.Equal(t, want, got) + + for _, rt := range RegisteredRuntimes() { + if rt.Type != typ { + continue + } + require.NoError(t, rt.LaunchErr) + require.True(t, rt.Available(), "static registration implies launchability") + require.Equal(t, want, rt.Capabilities) + return + } + t.Fatal("RegisterCapabilities must add the runtime to the registry") +} diff --git a/lib/hypervisor/registry_concurrency_test.go b/lib/hypervisor/registry_concurrency_test.go new file mode 100644 index 00000000..bed33d0d --- /dev/null +++ b/lib/hypervisor/registry_concurrency_test.go @@ -0,0 +1,89 @@ +package hypervisor + +import ( + "fmt" + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestRegistryConcurrentRegisterAndEnumerate exercises the public registry +// under concurrent registration and enumeration. RegisterRuntime is public +// API, so custom backends may register while capability requests iterate the +// registry; without synchronization this crashes with "concurrent map +// iteration and map write" (and the race detector reports it). Registered +// types use unique test-only names so production registrations shared with +// unrelated parallel tests are never mutated. +func TestRegistryConcurrentRegisterAndEnumerate(t *testing.T) { + t.Parallel() + + const writers = 8 + const iterations = 50 + + var wg sync.WaitGroup + for w := range writers { + wg.Add(2) + go func() { + defer wg.Done() + for i := range iterations { + typ := Type(fmt.Sprintf("registry-concurrency-test-%d-%d", w, i)) + RegisterRuntime(typ, RuntimeRegistration{ + Capabilities: func() Capabilities { return Capabilities{SupportsVsock: true} }, + LaunchCheck: func() error { return nil }, + }) + caps, ok := CapabilitiesForType(typ) + require.True(t, ok) + require.True(t, caps.SupportsVsock) + } + }() + go func() { + defer wg.Done() + for range iterations { + for _, rt := range RegisteredRuntimes() { + _ = rt.Available() + } + _, _ = CapabilitiesForType(Type("registry-concurrency-test-missing")) + } + }() + } + wg.Wait() +} + +// TestRegistryCallbacksRunOutsideLock pins that enumeration resolves +// registration callbacks after releasing the registry lock: a Capabilities +// resolver or LaunchCheck that re-enters the registry — including taking the +// write lock via RegisterRuntime — must not deadlock. If callbacks executed +// under even a read lock, the nested RegisterRuntime would block forever. +func TestRegistryCallbacksRunOutsideLock(t *testing.T) { + t.Parallel() + + reentrant := Type("registry-reentrant-callback-test") + nested := Type("registry-reentrant-callback-test-nested") + RegisterRuntime(reentrant, RuntimeRegistration{ + Capabilities: func() Capabilities { + RegisterRuntime(nested, RuntimeRegistration{ + Capabilities: func() Capabilities { return Capabilities{SupportsPause: true} }, + }) + return Capabilities{SupportsVsock: true} + }, + LaunchCheck: func() error { + _, _ = CapabilitiesForType(nested) + return nil + }, + }) + + found := false + for _, rt := range RegisteredRuntimes() { + if rt.Type == reentrant { + found = true + require.True(t, rt.Capabilities.SupportsVsock) + require.True(t, rt.Available()) + } + } + require.True(t, found, "re-entrant registration must not deadlock or drop the runtime") + + caps, ok := CapabilitiesForType(nested) + require.True(t, ok, "registration performed inside a resolver must land in the registry") + require.True(t, caps.SupportsPause) +} diff --git a/lib/hypervisor/vz/client.go b/lib/hypervisor/vz/client.go index 2fb0a62a..53e56f4a 100644 --- a/lib/hypervisor/vz/client.go +++ b/lib/hypervisor/vz/client.go @@ -10,7 +10,6 @@ import ( "io" "net" "net/http" - "runtime" "time" "github.com/kernel/hypeman/lib/hypervisor" @@ -84,7 +83,20 @@ func (c *Client) Capabilities() hypervisor.Capabilities { func capabilities() hypervisor.Capabilities { return hypervisor.Capabilities{ - SupportsSnapshot: runtime.GOARCH == "arm64", + // Snapshot/standby support is runtime-derived: it requires Apple + // Silicon AND macOS 14+. An arm64-only check would overstate + // support on macOS 13 hosts. + SupportsSnapshot: saveRestoreSupported(), + // PrepareFork is implemented for every source state (fork.go): a + // stopped-source fork clones disks with no machine-state snapshot + // involved, so it works even where Virtualization.framework lacks VM + // save/restore (macOS 13). Gating fork on the save/restore probe + // would hide that valid operation. Forking a standby or running + // source does restore/create snapshots, but that is the documented + // contract of the fork feature itself: those source states + // additionally require the standby feature, which this backend + // derives from the save/restore probe (SupportsSnapshot above). + SupportsFork: true, SupportsHotplugMemory: false, SupportsBalloonControl: true, SupportsPause: true, diff --git a/lib/hypervisor/vz/save_restore_support.go b/lib/hypervisor/vz/save_restore_support.go new file mode 100644 index 00000000..a6264418 --- /dev/null +++ b/lib/hypervisor/vz/save_restore_support.go @@ -0,0 +1,40 @@ +package vz + +import ( + "strconv" + "strings" +) + +// saveRestoreMinMacOSMajor is the minimum macOS major version whose +// Virtualization.framework exposes VM save/restore (VZVirtualMachine +// saveMachineStateToURL / restoreMachineStateFromURL). +const saveRestoreMinMacOSMajor = 14 + +// SaveRestoreSupported reports whether Virtualization.framework VM +// save/restore (snapshots, and therefore standby) is available on a host with +// the given GOOS, GOARCH, and macOS product version (e.g. "14.5.1"). The +// product version is only consulted on darwin/arm64; an empty or unparsable +// version is treated as unsupported so a failed probe never overstates +// support. +func SaveRestoreSupported(goos, goarch, productVersion string) bool { + if goos != "darwin" || goarch != "arm64" { + return false + } + major, ok := parseMacOSMajorVersion(productVersion) + return ok && major >= saveRestoreMinMacOSMajor +} + +// parseMacOSMajorVersion extracts the major component from a macOS product +// version string like "14", "14.5", or "14.5.1". +func parseMacOSMajorVersion(productVersion string) (int, bool) { + productVersion = strings.TrimSpace(productVersion) + if productVersion == "" { + return 0, false + } + majorStr, _, _ := strings.Cut(productVersion, ".") + major, err := strconv.Atoi(majorStr) + if err != nil || major < 0 { + return 0, false + } + return major, true +} diff --git a/lib/hypervisor/vz/save_restore_support_darwin.go b/lib/hypervisor/vz/save_restore_support_darwin.go new file mode 100644 index 00000000..6b8833e6 --- /dev/null +++ b/lib/hypervisor/vz/save_restore_support_darwin.go @@ -0,0 +1,26 @@ +//go:build darwin + +package vz + +import ( + "runtime" + + "golang.org/x/sys/unix" +) + +// macOSProductVersion returns the host macOS product version (e.g. "14.5.1"). +// It is a variable so tests can stub the probe. +var macOSProductVersion = func() string { + v, err := unix.Sysctl("kern.osproductversion") + if err != nil { + return "" + } + return v +} + +// saveRestoreSupported reports whether this host supports VZ VM save/restore. +// Detection is runtime-derived (Apple Silicon + macOS 14+) so static +// capabilities never overstate snapshot/standby support on older macOS. +func saveRestoreSupported() bool { + return SaveRestoreSupported(runtime.GOOS, runtime.GOARCH, macOSProductVersion()) +} diff --git a/lib/hypervisor/vz/save_restore_support_darwin_test.go b/lib/hypervisor/vz/save_restore_support_darwin_test.go new file mode 100644 index 00000000..731e9112 --- /dev/null +++ b/lib/hypervisor/vz/save_restore_support_darwin_test.go @@ -0,0 +1,32 @@ +//go:build darwin + +package vz + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestCapabilitiesDeriveSnapshotFromHost proves the hypervisor's advertised +// capabilities track the probed macOS version instead of a static arch check. +func TestCapabilitiesDeriveSnapshotFromHost(t *testing.T) { + orig := macOSProductVersion + t.Cleanup(func() { macOSProductVersion = orig }) + + macOSProductVersion = func() string { return "13.6" } + require.False(t, capabilities().SupportsSnapshot, "macOS 13 must not advertise snapshot/standby support") + require.True(t, capabilities().SupportsFork, + "stopped-source forks clone disks without machine-state save/restore, so macOS 13 must still advertise fork") + require.False(t, capabilities().SupportsStandby(), + "macOS 13 must not advertise standby, so hot-source forks (which require standby) are not promised") + + macOSProductVersion = func() string { return "14.5" } + require.Equal(t, saveRestoreSupported(), capabilities().SupportsSnapshot) + require.True(t, capabilities().SupportsFork, "vz PrepareFork is implemented for every source state") + + macOSProductVersion = func() string { return "" } + require.False(t, capabilities().SupportsSnapshot, "failed version probe must not advertise snapshot support") + require.True(t, capabilities().SupportsFork, + "fork does not depend on the save/restore probe: stopped-source forks work regardless") +} diff --git a/lib/hypervisor/vz/save_restore_support_test.go b/lib/hypervisor/vz/save_restore_support_test.go new file mode 100644 index 00000000..6b3d4c68 --- /dev/null +++ b/lib/hypervisor/vz/save_restore_support_test.go @@ -0,0 +1,33 @@ +package vz + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSaveRestoreSupported(t *testing.T) { + tests := []struct { + name string + goos string + goarch string + productVersion string + want bool + }{ + {name: "linux arm64 is never vz", goos: "linux", goarch: "arm64", productVersion: "14.5", want: false}, + {name: "darwin amd64 unsupported", goos: "darwin", goarch: "amd64", productVersion: "14.5", want: false}, + {name: "macOS 13 overstates nothing", goos: "darwin", goarch: "arm64", productVersion: "13.6", want: false}, + {name: "macOS 14 minimum", goos: "darwin", goarch: "arm64", productVersion: "14.0", want: true}, + {name: "macOS 14 patch", goos: "darwin", goarch: "arm64", productVersion: "14.5.1", want: true}, + {name: "macOS 15", goos: "darwin", goarch: "arm64", productVersion: "15.2", want: true}, + {name: "macOS 26", goos: "darwin", goarch: "arm64", productVersion: "26.0", want: true}, + {name: "empty version is unsupported", goos: "darwin", goarch: "arm64", productVersion: "", want: false}, + {name: "unparsable version is unsupported", goos: "darwin", goarch: "arm64", productVersion: "unknown", want: false}, + {name: "major-only version", goos: "darwin", goarch: "arm64", productVersion: "14", want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, SaveRestoreSupported(tt.goos, tt.goarch, tt.productVersion)) + }) + } +} diff --git a/lib/hypervisor/vz/starter.go b/lib/hypervisor/vz/starter.go index 72efefa1..d62026b5 100644 --- a/lib/hypervisor/vz/starter.go +++ b/lib/hypervisor/vz/starter.go @@ -24,7 +24,12 @@ import ( func init() { hypervisor.RegisterSocketName(hypervisor.TypeVZ, "vz.sock") - hypervisor.RegisterCapabilities(hypervisor.TypeVZ, capabilities()) + // Capabilities are resolved per registry read: snapshot/standby/fork + // support depends on the host macOS version probe, not on init order. + // No LaunchCheck: the vz-shim binary ships embedded in hypeman. + hypervisor.RegisterRuntime(hypervisor.TypeVZ, hypervisor.RuntimeRegistration{ + Capabilities: capabilities, + }) hypervisor.RegisterVsockSocketName(hypervisor.TypeVZ, "vz.vsock") hypervisor.RegisterVsockDialerFactory(hypervisor.TypeVZ, NewVsockDialer) hypervisor.RegisterClientFactory(hypervisor.TypeVZ, func(socketPath string) (hypervisor.Hypervisor, error) { diff --git a/lib/instances/fork_test.go b/lib/instances/fork_test.go index 2763eab4..d51a687e 100644 --- a/lib/instances/fork_test.go +++ b/lib/instances/fork_test.go @@ -222,7 +222,11 @@ func TestForkInstanceStoppedSourceUsesReadLock(t *testing.T) { manager, _ := setupTestManager(t) ctx := context.Background() hvType := hypervisor.Type("concurrent-fork-test") - hypervisor.RegisterCapabilities(hvType, hypervisor.Capabilities{SupportsConcurrentForkPrepare: true}) + hypervisor.RegisterRuntime(hvType, hypervisor.RuntimeRegistration{ + Capabilities: func() hypervisor.Capabilities { + return hypervisor.Capabilities{SupportsConcurrentForkPrepare: true} + }, + }) manager.vmStarters[hvType] = concurrentForkPrepareTestStarter{} sourceID := "fork-stopped-read-lock-source" @@ -288,7 +292,11 @@ func TestForkInstanceStandbyRunningTargetSkipsSourceWriteLockWithoutAlias(t *tes manager, _ := setupTestManager(t) ctx := context.Background() hvType := hypervisor.Type("concurrent-fork-restore-test") - hypervisor.RegisterCapabilities(hvType, hypervisor.Capabilities{SupportsConcurrentForkPrepare: true}) + hypervisor.RegisterRuntime(hvType, hypervisor.RuntimeRegistration{ + Capabilities: func() hypervisor.Capabilities { + return hypervisor.Capabilities{SupportsConcurrentForkPrepare: true} + }, + }) hypervisor.RegisterClientFactory(hvType, func(string) (hypervisor.Hypervisor, error) { return lifecycleNoopHypervisor{state: hypervisor.StateRunning}, nil }) diff --git a/lib/instances/manager.go b/lib/instances/manager.go index 8e8e25f3..5d595d82 100644 --- a/lib/instances/manager.go +++ b/lib/instances/manager.go @@ -690,6 +690,15 @@ func (m *manager) UpdateInstance(ctx context.Context, id string, req UpdateInsta return inst, err } +// DefaultHypervisor returns the effective default hypervisor type used for +// launches that do not specify one. It is deliberately not part of the +// Manager interface — widening that interface would break alternate +// implementations (mocks, wrappers) compiled against this public module — so +// callers that need it type-assert for this method instead. +func (m *manager) DefaultHypervisor() hypervisor.Type { + return m.defaultHypervisor +} + // ListInstances returns instances, optionally filtered by the given criteria. // Pass nil to return all instances. func (m *manager) ListInstances(ctx context.Context, filter *ListInstancesFilter) ([]Instance, error) { diff --git a/lib/network/default_network_test.go b/lib/network/default_network_test.go new file mode 100644 index 00000000..16b38897 --- /dev/null +++ b/lib/network/default_network_test.go @@ -0,0 +1,27 @@ +package network + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNetworkModelIsTyped(t *testing.T) { + t.Parallel() + model := NetworkModel() + require.Contains(t, []Model{ModelBridge, ModelNAT}, model) +} + +func TestGuestToGuestEnabled(t *testing.T) { + t.Parallel() + require.False(t, GuestToGuestEnabled(nil)) + require.False(t, GuestToGuestEnabled(&Network{Isolated: true}), + "isolated default network blocks direct guest-to-guest traffic") + if NetworkModel() == ModelBridge { + require.True(t, GuestToGuestEnabled(&Network{Isolated: false}), + "a non-isolated bridge network permits direct guest-to-guest traffic") + } else { + require.False(t, GuestToGuestEnabled(&Network{Isolated: false}), + "NAT networking never provides direct guest-to-guest reachability") + } +} diff --git a/lib/network/model.go b/lib/network/model.go new file mode 100644 index 00000000..d26dbe66 --- /dev/null +++ b/lib/network/model.go @@ -0,0 +1,13 @@ +package network + +// Model identifies the guest networking model a host provides. Each platform +// backend returns its model from NetworkModel; API layers map it onto their +// own wire enums so this package stays free of API dependencies. +type Model string + +const ( + // ModelBridge is a Linux bridge with per-VM TAP devices. + ModelBridge Model = "bridge" + // ModelNAT is hypervisor-provided NAT (Virtualization.framework on macOS). + ModelNAT Model = "nat" +) diff --git a/lib/network/model_darwin.go b/lib/network/model_darwin.go new file mode 100644 index 00000000..24fc9db5 --- /dev/null +++ b/lib/network/model_darwin.go @@ -0,0 +1,17 @@ +//go:build darwin + +package network + +// NetworkModel identifies the guest networking model in use on this host. +// macOS hosts use Virtualization.framework-provided NAT. +func NetworkModel() Model { + return ModelNAT +} + +// GuestToGuestEnabled reports whether direct VM-to-VM traffic is permitted on +// the given network. Each vz guest sits behind its own NAT context, so direct +// guest-to-guest reachability is never provided regardless of the network's +// isolation flag. +func GuestToGuestEnabled(_ *Network) bool { + return false +} diff --git a/lib/network/model_linux.go b/lib/network/model_linux.go new file mode 100644 index 00000000..4ef0e909 --- /dev/null +++ b/lib/network/model_linux.go @@ -0,0 +1,18 @@ +//go:build linux + +package network + +// NetworkModel identifies the guest networking model in use on this host. +// Linux hosts use a bridge with per-VM TAP devices. +func NetworkModel() Model { + return ModelBridge +} + +// GuestToGuestEnabled reports whether direct VM-to-VM traffic is permitted on +// the given network. Hypeman provisions its default networks with per-TAP +// port isolation (Isolated=true), which blocks direct guest-to-guest traffic. +// The flag is read from the network rather than assumed so a non-isolated +// network is reported truthfully. +func GuestToGuestEnabled(n *Network) bool { + return n != nil && !n.Isolated +} diff --git a/lib/oapi/oapi.go b/lib/oapi/oapi.go index f2c40652..52ab3b2c 100644 --- a/lib/oapi/oapi.go +++ b/lib/oapi/oapi.go @@ -81,6 +81,12 @@ const ( BuilderStatusReady BuilderStatus = "ready" ) +// Defines values for CapabilitiesNetworkModel. +const ( + Bridge CapabilitiesNetworkModel = "bridge" + Nat CapabilitiesNetworkModel = "nat" +) + // Defines values for CreateInstanceRequestHypervisor. const ( CreateInstanceRequestHypervisorCloudHypervisor CreateInstanceRequestHypervisor = "cloud-hypervisor" @@ -490,6 +496,131 @@ type Builder struct { // BuilderStatus Builder lifecycle status type BuilderStatus string +// Capabilities defines model for Capabilities. +type Capabilities struct { + DefaultRuntime CapabilitiesDefaultRuntime `json:"default_runtime"` + + // Features Stable server-level feature IDs: API surfaces this server exposes + // regardless of which runtime backs an instance. Always present: + // "instances", "images", "builds", "volumes", "ingress", "exec", + // "logs". Host-conditional: "devices" (device passthrough + // management, Linux hosts only) and "rosetta-emulation" (Apple + // Silicon macOS hosts with Rosetta currently installed, per the same + // availability probe launches enforce). Per-runtime features are + // reported under each runtimes[] entry. + Features []string `json:"features"` + Host CapabilitiesHost `json:"host"` + Images CapabilitiesImages `json:"images"` + Network CapabilitiesNetwork `json:"network"` + + // Runtimes Every runtime this server build supports on this host platform, + // each with its own availability flag and feature IDs. Hosts + // commonly support several runtimes at once (for example + // cloud-hypervisor, firecracker, qemu, and qemu-microvm on + // linux/amd64). A listed runtime is only launchable when its + // "available" flag is true. Entries are sorted by name. + Runtimes []CapabilitiesRuntime `json:"runtimes"` + Server CapabilitiesServer `json:"server"` +} + +// CapabilitiesDefaultRuntime defines model for CapabilitiesDefaultRuntime. +type CapabilitiesDefaultRuntime struct { + // Available Whether the default runtime can launch on this host: it appears in + // runtimes and its launch prerequisites are met (matches that + // entry's "available"). When false, launches that rely on the + // default will fail until the server is reconfigured with an + // available runtime or the missing prerequisite (for example the + // QEMU system binary) is installed. + Available bool `json:"available"` + + // Name Runtime used for launches that do not name one + Name string `json:"name"` +} + +// CapabilitiesHost defines model for CapabilitiesHost. +type CapabilitiesHost struct { + // Arch Host CPU architecture + Arch string `json:"arch"` + + // Os Host operating system + Os string `json:"os"` +} + +// CapabilitiesImages defines model for CapabilitiesImages. +type CapabilitiesImages struct { + // DefaultPlatform Image platform selected when a create request omits one + DefaultPlatform string `json:"default_platform"` + + // Platforms Image platforms (os/arch) this host can run. On Apple Silicon macOS + // this includes linux/amd64 only when Rosetta is currently installed + // — probed via the same Virtualization.framework availability check + // launches enforce — so a listed platform is launchable right now. + // Install Rosetta (softwareupdate --install-rosetta) to enable it. + Platforms []string `json:"platforms"` +} + +// CapabilitiesNetwork defines model for CapabilitiesNetwork. +type CapabilitiesNetwork struct { + // Gateway Guest-visible host gateway IP. Guests reach host services (including + // host ingress) through this address. Omitted when no default network + // has been resolved on this host yet. + Gateway *string `json:"gateway,omitempty"` + + // GuestToGuest Whether direct VM-to-VM traffic is permitted on the default network + GuestToGuest bool `json:"guest_to_guest"` + + // Model Guest networking model. "bridge" is a Linux bridge with per-VM TAP + // devices; "nat" is hypervisor-provided NAT (macOS). + Model CapabilitiesNetworkModel `json:"model"` + + // Subnet Guest subnet CIDR + Subnet *string `json:"subnet,omitempty"` +} + +// CapabilitiesNetworkModel Guest networking model. "bridge" is a Linux bridge with per-VM TAP +// devices; "nat" is hypervisor-provided NAT (macOS). +type CapabilitiesNetworkModel string + +// CapabilitiesRuntime defines model for CapabilitiesRuntime. +type CapabilitiesRuntime struct { + // Available Whether this runtime's launch prerequisites are currently met on + // this host. Listed runtimes are supported by this server build on + // this platform; available=false means a host prerequisite is + // missing (for example qemu requires a runnable system-installed + // QEMU binary and the host vhost-vsock device) and launches naming + // this runtime will fail until it is installed. + Available bool `json:"available"` + + // Features Stable feature IDs supported by this runtime on this host: + // "snapshots" (snapshot/restore), "standby" (pause + memory + // snapshot, with later restore), "fork" (clone an instance from a + // stopped source; forking a standby or running source + // restores/creates snapshots and additionally requires "standby"), + // "pause" (pause/resume), "hotplug-memory" (live memory resize), + // "balloon-control" (runtime balloon target changes), "vsock" + // (guest vsock communication), "gpu-passthrough" (GPU/PCI device + // passthrough), "disk-io-limit" (disk I/O rate limiting), + // "disk-resize" (live disk resize). Values are host- and + // configuration-truthful: vz omits snapshots and standby on macOS + // 13, which lacks Virtualization.framework VM save/restore, while + // still advertising fork (stopped-source clones need no + // save/restore there), and cloud-hypervisor reports "disk-resize" + // only when the configured default version supports it. + Features []string `json:"features"` + + // Name Runtime identifier + Name string `json:"name"` +} + +// CapabilitiesServer defines model for CapabilitiesServer. +type CapabilitiesServer struct { + // ApiVersion API contract version (matches the OpenAPI document info version) + ApiVersion string `json:"api_version"` + + // Version Server build version (short git revision, with "-dirty" suffix for uncommitted builds, or "unknown") + Version string `json:"version"` +} + // CreateBuilderRequest defines model for CreateBuilderRequest. type CreateBuilderRequest struct { // DiskSizeGb Cache disk size in gigabytes. Omit to use the server default. @@ -2132,6 +2263,9 @@ type ClientInterface interface { // GetBuildEvents request GetBuildEvents(ctx context.Context, id string, params *GetBuildEventsParams, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetCapabilities request + GetCapabilities(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + // ListDevices request ListDevices(ctx context.Context, params *ListDevicesParams, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -2450,6 +2584,18 @@ func (c *Client) GetBuildEvents(ctx context.Context, id string, params *GetBuild return c.Client.Do(req) } +func (c *Client) GetCapabilities(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetCapabilitiesRequest(c.Server) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) ListDevices(ctx context.Context, params *ListDevicesParams, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewListDevicesRequest(c.Server, params) if err != nil { @@ -3647,6 +3793,33 @@ func NewGetBuildEventsRequest(server string, id string, params *GetBuildEventsPa return req, nil } +// NewGetCapabilitiesRequest generates requests for GetCapabilities +func NewGetCapabilitiesRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/capabilities") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewListDevicesRequest generates requests for ListDevices func NewListDevicesRequest(server string, params *ListDevicesParams) (*http.Request, error) { var err error @@ -5941,6 +6114,9 @@ type ClientWithResponsesInterface interface { // GetBuildEventsWithResponse request GetBuildEventsWithResponse(ctx context.Context, id string, params *GetBuildEventsParams, reqEditors ...RequestEditorFn) (*GetBuildEventsResponse, error) + // GetCapabilitiesWithResponse request + GetCapabilitiesWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetCapabilitiesResponse, error) + // ListDevicesWithResponse request ListDevicesWithResponse(ctx context.Context, params *ListDevicesParams, reqEditors ...RequestEditorFn) (*ListDevicesResponse, error) @@ -6375,6 +6551,29 @@ func (r GetBuildEventsResponse) StatusCode() int { return 0 } +type GetCapabilitiesResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *Capabilities + JSON500 *Error +} + +// Status returns HTTPResponse.Status +func (r GetCapabilitiesResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetCapabilitiesResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type ListDevicesResponse struct { Body []byte HTTPResponse *http.Response @@ -7735,6 +7934,15 @@ func (c *ClientWithResponses) GetBuildEventsWithResponse(ctx context.Context, id return ParseGetBuildEventsResponse(rsp) } +// GetCapabilitiesWithResponse request returning *GetCapabilitiesResponse +func (c *ClientWithResponses) GetCapabilitiesWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetCapabilitiesResponse, error) { + rsp, err := c.GetCapabilities(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetCapabilitiesResponse(rsp) +} + // ListDevicesWithResponse request returning *ListDevicesResponse func (c *ClientWithResponses) ListDevicesWithResponse(ctx context.Context, params *ListDevicesParams, reqEditors ...RequestEditorFn) (*ListDevicesResponse, error) { rsp, err := c.ListDevices(ctx, params, reqEditors...) @@ -8778,6 +8986,39 @@ func ParseGetBuildEventsResponse(rsp *http.Response) (*GetBuildEventsResponse, e return response, nil } +// ParseGetCapabilitiesResponse parses an HTTP response from a GetCapabilitiesWithResponse call +func ParseGetCapabilitiesResponse(rsp *http.Response) (*GetCapabilitiesResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetCapabilitiesResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest Capabilities + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ParseListDevicesResponse parses an HTTP response from a ListDevicesWithResponse call func ParseListDevicesResponse(rsp *http.Response) (*ListDevicesResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -11116,6 +11357,9 @@ type ServerInterface interface { // Stream build events (SSE) // (GET /builds/{id}/events) GetBuildEvents(w http.ResponseWriter, r *http.Request, id string, params GetBuildEventsParams) + // Get host capabilities + // (GET /capabilities) + GetCapabilities(w http.ResponseWriter, r *http.Request) // List registered devices // (GET /devices) ListDevices(w http.ResponseWriter, r *http.Request, params ListDevicesParams) @@ -11335,6 +11579,12 @@ func (_ Unimplemented) GetBuildEvents(w http.ResponseWriter, r *http.Request, id w.WriteHeader(http.StatusNotImplemented) } +// Get host capabilities +// (GET /capabilities) +func (_ Unimplemented) GetCapabilities(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + // List registered devices // (GET /devices) func (_ Unimplemented) ListDevices(w http.ResponseWriter, r *http.Request, params ListDevicesParams) { @@ -11953,6 +12203,26 @@ func (siw *ServerInterfaceWrapper) GetBuildEvents(w http.ResponseWriter, r *http handler.ServeHTTP(w, r) } +// GetCapabilities operation middleware +func (siw *ServerInterfaceWrapper) GetCapabilities(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetCapabilities(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // ListDevices operation middleware func (siw *ServerInterfaceWrapper) ListDevices(w http.ResponseWriter, r *http.Request) { @@ -13740,6 +14010,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/builds/{id}/events", wrapper.GetBuildEvents) }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/capabilities", wrapper.GetCapabilities) + }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/devices", wrapper.ListDevices) }) @@ -14355,6 +14628,31 @@ func (response GetBuildEvents500JSONResponse) VisitGetBuildEventsResponse(w http return json.NewEncoder(w).Encode(response) } +type GetCapabilitiesRequestObject struct { +} + +type GetCapabilitiesResponseObject interface { + VisitGetCapabilitiesResponse(w http.ResponseWriter) error +} + +type GetCapabilities200JSONResponse Capabilities + +func (response GetCapabilities200JSONResponse) VisitGetCapabilitiesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetCapabilities500JSONResponse Error + +func (response GetCapabilities500JSONResponse) VisitGetCapabilitiesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + type ListDevicesRequestObject struct { Params ListDevicesParams } @@ -16611,6 +16909,9 @@ type StrictServerInterface interface { // Stream build events (SSE) // (GET /builds/{id}/events) GetBuildEvents(ctx context.Context, request GetBuildEventsRequestObject) (GetBuildEventsResponseObject, error) + // Get host capabilities + // (GET /capabilities) + GetCapabilities(ctx context.Context, request GetCapabilitiesRequestObject) (GetCapabilitiesResponseObject, error) // List registered devices // (GET /devices) ListDevices(ctx context.Context, request ListDevicesRequestObject) (ListDevicesResponseObject, error) @@ -17066,6 +17367,30 @@ func (sh *strictHandler) GetBuildEvents(w http.ResponseWriter, r *http.Request, } } +// GetCapabilities operation middleware +func (sh *strictHandler) GetCapabilities(w http.ResponseWriter, r *http.Request) { + var request GetCapabilitiesRequestObject + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetCapabilities(ctx, request.(GetCapabilitiesRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetCapabilities") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetCapabilitiesResponseObject); ok { + if err := validResponse.VisitGetCapabilitiesResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // ListDevices operation middleware func (sh *strictHandler) ListDevices(w http.ResponseWriter, r *http.Request, params ListDevicesParams) { var request ListDevicesRequestObject @@ -18493,347 +18818,381 @@ func (sh *strictHandler) GetVolume(w http.ResponseWriter, r *http.Request, id st // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", + "eSdugOoBAA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/lib/scopes/scopes.go b/lib/scopes/scopes.go index 84c7c592..4773437a 100644 --- a/lib/scopes/scopes.go +++ b/lib/scopes/scopes.go @@ -222,8 +222,9 @@ var RouteScopes = map[string]Scope{ "DELETE /devices/{id}": DeviceDelete, "GET /devices/{id}": DeviceRead, - // Health & Resources + // Health, Capabilities & Resources "GET /health": ResourceRead, + "GET /capabilities": ResourceRead, "GET /resources": ResourceRead, "POST /resources/memory/reclaim": ResourceWrite, diff --git a/openapi.yaml b/openapi.yaml index fcaa132f..b117d203 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1425,6 +1425,172 @@ components: type: string enum: [ok] example: ok + + Capabilities: + type: object + required: [server, host, default_runtime, runtimes, network, images, features] + properties: + server: + $ref: "#/components/schemas/CapabilitiesServer" + host: + $ref: "#/components/schemas/CapabilitiesHost" + default_runtime: + $ref: "#/components/schemas/CapabilitiesDefaultRuntime" + runtimes: + type: array + items: + $ref: "#/components/schemas/CapabilitiesRuntime" + description: | + Every runtime this server build supports on this host platform, + each with its own availability flag and feature IDs. Hosts + commonly support several runtimes at once (for example + cloud-hypervisor, firecracker, qemu, and qemu-microvm on + linux/amd64). A listed runtime is only launchable when its + "available" flag is true. Entries are sorted by name. + network: + $ref: "#/components/schemas/CapabilitiesNetwork" + images: + $ref: "#/components/schemas/CapabilitiesImages" + features: + type: array + items: + type: string + description: | + Stable server-level feature IDs: API surfaces this server exposes + regardless of which runtime backs an instance. Always present: + "instances", "images", "builds", "volumes", "ingress", "exec", + "logs". Host-conditional: "devices" (device passthrough + management, Linux hosts only) and "rosetta-emulation" (Apple + Silicon macOS hosts with Rosetta currently installed, per the same + availability probe launches enforce). Per-runtime features are + reported under each runtimes[] entry. + + CapabilitiesServer: + type: object + required: [version, api_version] + properties: + version: + type: string + description: Server build version (short git revision, with "-dirty" suffix for uncommitted builds, or "unknown") + example: 76a2fdb3 + api_version: + type: string + description: API contract version (matches the OpenAPI document info version) + example: 0.2.0 + + CapabilitiesHost: + type: object + required: [os, arch] + properties: + os: + type: string + description: Host operating system + example: linux + arch: + type: string + description: Host CPU architecture + example: arm64 + + CapabilitiesDefaultRuntime: + type: object + required: [name, available] + properties: + name: + type: string + description: Runtime used for launches that do not name one + example: cloud-hypervisor + available: + type: boolean + description: | + Whether the default runtime can launch on this host: it appears in + runtimes and its launch prerequisites are met (matches that + entry's "available"). When false, launches that rely on the + default will fail until the server is reconfigured with an + available runtime or the missing prerequisite (for example the + QEMU system binary) is installed. + + CapabilitiesRuntime: + type: object + required: [name, available, features] + properties: + name: + type: string + description: Runtime identifier + example: cloud-hypervisor + available: + type: boolean + description: | + Whether this runtime's launch prerequisites are currently met on + this host. Listed runtimes are supported by this server build on + this platform; available=false means a host prerequisite is + missing (for example qemu requires a runnable system-installed + QEMU binary and the host vhost-vsock device) and launches naming + this runtime will fail until it is installed. + features: + type: array + items: + type: string + description: | + Stable feature IDs supported by this runtime on this host: + "snapshots" (snapshot/restore), "standby" (pause + memory + snapshot, with later restore), "fork" (clone an instance from a + stopped source; forking a standby or running source + restores/creates snapshots and additionally requires "standby"), + "pause" (pause/resume), "hotplug-memory" (live memory resize), + "balloon-control" (runtime balloon target changes), "vsock" + (guest vsock communication), "gpu-passthrough" (GPU/PCI device + passthrough), "disk-io-limit" (disk I/O rate limiting), + "disk-resize" (live disk resize). Values are host- and + configuration-truthful: vz omits snapshots and standby on macOS + 13, which lacks Virtualization.framework VM save/restore, while + still advertising fork (stopped-source clones need no + save/restore there), and cloud-hypervisor reports "disk-resize" + only when the configured default version supports it. + + CapabilitiesNetwork: + type: object + required: [model, guest_to_guest] + properties: + model: + type: string + enum: [bridge, nat] + description: | + Guest networking model. "bridge" is a Linux bridge with per-VM TAP + devices; "nat" is hypervisor-provided NAT (macOS). + gateway: + type: string + description: | + Guest-visible host gateway IP. Guests reach host services (including + host ingress) through this address. Omitted when no default network + has been resolved on this host yet. + example: 10.100.0.1 + subnet: + type: string + description: Guest subnet CIDR + example: 10.100.0.0/16 + guest_to_guest: + type: boolean + description: Whether direct VM-to-VM traffic is permitted on the default network + + CapabilitiesImages: + type: object + required: [platforms, default_platform] + properties: + platforms: + type: array + items: + type: string + description: | + Image platforms (os/arch) this host can run. On Apple Silicon macOS + this includes linux/amd64 only when Rosetta is currently installed + — probed via the same Virtualization.framework availability check + launches enforce — so a listed platform is launchable right now. + Install Rosetta (softwareupdate --install-rosetta) to enable it. + example: [linux/arm64, linux/amd64] + default_platform: + type: string + description: Image platform selected when a create request omits one + example: linux/arm64 IngressMatch: type: object @@ -2263,6 +2429,36 @@ paths: schema: $ref: "#/components/schemas/Health" + /capabilities: + get: + summary: Get host capabilities + description: | + Returns machine-readable host capabilities: server and API version, host + OS/architecture, every runtime available on this host with its per-runtime + feature IDs, the configured default runtime and whether it is available, + guest networking model and host gateway, supported image platforms, and + stable server-level feature IDs. + + Runtime-derived values reflect the actual host (for example, snapshot and + standby support on macOS is gated on the host OS version), so clients can + gate behavior on capabilities without hard-coding hypervisor knowledge. + operationId: getCapabilities + security: + - bearerAuth: [] + responses: + 200: + description: Host capabilities + content: + application/json: + schema: + $ref: "#/components/schemas/Capabilities" + 500: + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /resources: get: summary: Get host resource capacity and allocations diff --git a/stainless.yaml b/stainless.yaml index 3c40590a..031a5f51 100644 --- a/stainless.yaml +++ b/stainless.yaml @@ -65,6 +65,18 @@ resources: methods: check: get /health + capabilities: + models: + capabilities: "#/components/schemas/Capabilities" + capabilities_server: "#/components/schemas/CapabilitiesServer" + capabilities_host: "#/components/schemas/CapabilitiesHost" + capabilities_default_runtime: "#/components/schemas/CapabilitiesDefaultRuntime" + capabilities_runtime: "#/components/schemas/CapabilitiesRuntime" + capabilities_network: "#/components/schemas/CapabilitiesNetwork" + capabilities_images: "#/components/schemas/CapabilitiesImages" + methods: + get: get /capabilities + images: # Configure the models--named types--defined in the resource. Each key in the # object is the name of the model and the value is either the name of a schema in