From f6b6bce7459c62e85b9001fba36a7486946bb81f Mon Sep 17 00:00:00 2001 From: Mario Loriedo Date: Thu, 16 Apr 2026 10:51:08 +0200 Subject: [PATCH 1/3] Minor hyperv stubber.go and vsock.go refactoring Signed-off-by: Mario Loriedo --- pkg/machine/hyperv/stubber.go | 8 ++++++-- pkg/machine/hyperv/vsock/vsock.go | 34 +++++++++++++++---------------- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/pkg/machine/hyperv/stubber.go b/pkg/machine/hyperv/stubber.go index 15aed69ffd..5c7e9dc401 100644 --- a/pkg/machine/hyperv/stubber.go +++ b/pkg/machine/hyperv/stubber.go @@ -67,7 +67,9 @@ func (h HyperVStubber) CreateVM(_ define.CreateVMOpts, mc *vmconfigs.MachineConf if err := h.canCreate(); err != nil { // If it returns ErrHypervRegistryInitRequiresElevation and we're not already re-executing, // offer to elevate automatically if user is in admin group - if err == ErrHypervRegistryInitRequiresElevation && !windows.IsReExecuting() && windows.IsInAdministratorsGroup() { + if errors.Is(err, ErrHypervRegistryInitRequiresElevation) && + !windows.IsReExecuting() && + windows.IsInAdministratorsGroup() { message := fmt.Sprintf("%s.\n\n%s", ErrHypervPrepareHostForHyperV.Error(), windows.UACConfirmationPrompt) return launchElevate(message) } @@ -210,7 +212,9 @@ func (h HyperVStubber) Remove(mc *vmconfigs.MachineConfig) ([]string, func() err if err := h.canRemove(mc); err != nil { // If we get ErrHypervRegistryRemoveRequiresElevation and we're not already re-executing, // and the user has admin rights (is in admin group), offer to elevate automatically - if err == ErrHypervRegistryRemoveRequiresElevation && !windows.IsReExecuting() && windows.IsInAdministratorsGroup() { + if errors.Is(err, ErrHypervRegistryRemoveRequiresElevation) && + !windows.IsReExecuting() && + windows.IsInAdministratorsGroup() { message := "Removing this Hyper-V machine requires admin rights to clean up the Windows Registry.\n\n" + windows.UACConfirmationPrompt return nil, nil, launchElevate(message) diff --git a/pkg/machine/hyperv/vsock/vsock.go b/pkg/machine/hyperv/vsock/vsock.go index cec3300562..a6bfdde7f3 100644 --- a/pkg/machine/hyperv/vsock/vsock.go +++ b/pkg/machine/hyperv/vsock/vsock.go @@ -90,8 +90,7 @@ type HVSockRegistryEntry struct { // ToolName identifies the application that created this registry entry (e.g., Podman). // This provides information about the entry's origin and can be used for filter entries // if purpose is not enough. - ToolName string `json:"creator_tool,omitempty"` - Key registry.Key `json:"key,omitempty"` + ToolName string `json:"creator_tool,omitempty"` } // Add creates a new Windows registry entry with string values from the @@ -108,23 +107,23 @@ func (hv *HVSockRegistryEntry) Add() error { return fmt.Errorf("%q: %s", ErrVSockRegistryEntryExists, hv.KeyName) } parentKey, err := registry.OpenKey(registry.LOCAL_MACHINE, VsockRegistryPath, registry.QUERY_VALUE) + if err != nil { + return err + } defer func() { if err := parentKey.Close(); err != nil { logrus.Error(err) } }() + newKey, _, err := registry.CreateKey(parentKey, hv.KeyName, registry.WRITE) if err != nil { return err } - newKey, _, err := registry.CreateKey(parentKey, hv.KeyName, registry.WRITE) defer func() { if err := newKey.Close(); err != nil { logrus.Error(err) } }() - if err != nil { - return err - } if err := newKey.SetStringValue(HvsockPurpose, hv.Purpose.string()); err != nil { return err @@ -190,10 +189,7 @@ func findOpenHVSockPort() (uint64, error) { // the port is good to go return uint64(port), nil } - if err != nil { - // something went wrong - return 0, err - } + return 0, err } return 0, errors.New("unable to find a free port for hvsock use") } @@ -234,6 +230,11 @@ func LoadHVSockRegistryEntry(port uint64) (*HVSockRegistryEntry, error) { if err != nil { return nil, err } + defer func(key registry.Key) { + if err := key.Close(); err != nil { + logrus.Errorf("failed to close registry key: %v", err) + } + }(k) p, _, err := k.GetStringValue(HvsockPurpose) if err != nil { return nil, err @@ -253,7 +254,6 @@ func LoadHVSockRegistryEntry(port uint64) (*HVSockRegistryEntry, error) { KeyName: keyName, Purpose: purpose, Port: port, - Key: k, MachineName: m, }, nil } @@ -290,8 +290,6 @@ func (hv *HVSockRegistryEntry) ListenSetupWait() (func() error, io.Closer, error // loadAllHVSockRegistryEntries loads HVSock registry entries, filtered by purpose and optionally limited by size. // If limit is -1, it returns all matching entries. Otherwise, it returns up to 'limit' entries. -// The caller is responsible for closing the registry.Key in each returned HVSockRegistryEntry. -// Non-matching or excess keys are closed within this function. func loadHVSockRegistryEntries(purpose HVSockPurpose, limit int) ([]*HVSockRegistryEntry, error) { parentKey, err := registry.OpenKey(registry.LOCAL_MACHINE, VsockRegistryPath, registry.ENUMERATE_SUB_KEYS) if err != nil { @@ -322,23 +320,24 @@ func loadHVSockRegistryEntries(purpose HVSockPurpose, limit int) ([]*HVSockRegis logrus.Debugf("Could not open registry entry %s: %v", fqPath, err) continue } + defer func(key registry.Key) { + if err := key.Close(); err != nil { + logrus.Errorf("failed to close registry key: %v", err) + } + }(k) p, _, err := k.GetStringValue(HvsockPurpose) if err != nil { logrus.Debugf("Could not read purpose from registry entry %s: %v", fqPath, err) - k.Close() continue } toolName, _, err := k.GetStringValue(HvsockToolName) if err != nil { logrus.Debugf("Could not read tool name from registry entry %s: %v", fqPath, err) - k.Close() continue } - k.Close() - entryPurpose, err := toHVSockPurpose(p) if err != nil { logrus.Debugf("Could not convert purpose string %q for entry %s: %v", p, fqPath, err) @@ -370,7 +369,6 @@ func loadHVSockRegistryEntries(purpose HVSockPurpose, limit int) ([]*HVSockRegis KeyName: subKeyName, Purpose: entryPurpose, Port: port, - Key: k, ToolName: PodmanToolName, }) } From 92d186f20d2a41bb779f76784f967fd037a039cf Mon Sep 17 00:00:00 2001 From: Mario Loriedo Date: Thu, 16 Apr 2026 11:15:51 +0200 Subject: [PATCH 2/3] Hyperv machine init/rm: skip win registry operations when possible Hyper-V VMs require some specific Windows registry keys to allow the communication between the host and the guest. Creating these registry keys require elevated privileges. These keys were created during the first Podman `machine init` and removed when the last Podman machine is removed. In this commit we skip the creation of the keys if they already exist in the registry. So that admin privileges aren't required anymore, even for the creation of the first Podman machine. In other words, if the keys are pre-created by an administrator, user will be able to create and remove machines without requiring to run any elevated command. Even for the first podman machine. Signed-off-by: Mario Loriedo --- Makefile | 2 +- pkg/machine/hyperv/stubber.go | 118 ++++++++++++---- pkg/machine/hyperv/stubber_test.go | 180 ++++++++++++++++++++++++ pkg/machine/hyperv/volumes.go | 1 + pkg/machine/hyperv/vsock/vsock.go | 47 ++++++- pkg/machine/vmconfigs/config_windows.go | 2 + 6 files changed, 314 insertions(+), 36 deletions(-) create mode 100644 pkg/machine/hyperv/stubber_test.go diff --git a/Makefile b/Makefile index 411c8cc490..1473a2541b 100644 --- a/Makefile +++ b/Makefile @@ -663,7 +663,7 @@ localunit: test/goecho/goecho test/version/version UNIT=1 $(GINKGO) \ -r \ $(TESTFLAGS) \ - --skip-package test/e2e,pkg/bindings,hack,pkg/machine/e2e,pkg/machine/wsl \ + --skip-package test/e2e,pkg/bindings,hack,pkg/machine/e2e,pkg/machine/wsl,pkg/machine/hyperv \ --cover \ --covermode atomic \ --coverprofile coverprofile \ diff --git a/pkg/machine/hyperv/stubber.go b/pkg/machine/hyperv/stubber.go index 5c7e9dc401..1d707bb7d4 100644 --- a/pkg/machine/hyperv/stubber.go +++ b/pkg/machine/hyperv/stubber.go @@ -31,6 +31,22 @@ type HyperVStubber struct { vmconfigs.HyperVConfig } +type permissionChecks struct { + isElevatedProcess func() bool + isHyperVAdminMember func() bool + vsockEntriesExist func(int) bool + existingMachinesNum func() (int, error) +} + +func (h HyperVStubber) defaultPermissionChecks() permissionChecks { + return permissionChecks{ + isElevatedProcess: windows.HasAdminRights, + isHyperVAdminMember: IsHyperVAdminsGroupMember, + vsockEntriesExist: vsock.CheckIfHVSockRegistryEntriesExist, + existingMachinesNum: h.countMachinesWithToolname, + } +} + func (h HyperVStubber) UserModeNetworkEnabled(_ *vmconfigs.MachineConfig) bool { return true } @@ -59,12 +75,12 @@ func (h HyperVStubber) CreateVM(_ define.CreateVMOpts, mc *vmconfigs.MachineConf // Allow creation in these two cases: // 1. if the user is Admin - // 2. if the user has Hyper-V admin rights and there is at least one *NEW* machine. + // 2. if the user has Hyper-V admin rights and a vsock registry entry exists // *NEW* machines are those created with the vsock entry having the ToolName field. // - // This is to prevent a non-admin user from creating the first machine - // which would require adding vsock entries into the Windows Registry. - if err := h.canCreate(); err != nil { + // This is to prevent to prevent an error for trying to create a vsock entry + // in the Windows registry if the user doesn't have the privileges. + if err := h.canCreate(len(mc.Mounts)); err != nil { // If it returns ErrHypervRegistryInitRequiresElevation and we're not already re-executing, // offer to elevate automatically if user is in admin group if errors.Is(err, ErrHypervRegistryInitRequiresElevation) && @@ -254,24 +270,36 @@ func (h HyperVStubber) Remove(mc *vmconfigs.MachineConfig) ([]string, func() err return []string{}, rmFunc, nil } -func (h HyperVStubber) canCreate() error { - // if admin, can always create - if windows.HasAdminRights() { +// canCreate checks if the user can create an Hyper-V machine. +// It returns `nil` if the user can create it. The conditions are: +// - the command is running as Administrator (elevated mode) +// OR +// - vsock entries in the Windows registry already exist and +// the user is a member of the Hyper-V Administrators group +// +// The `mounts` parameter is the number of Mounts of the machine. A +// specific Windows registry entry is necessary for every single mount. +// +// It returns `ErrHypervRegistryInitRequiresElevation` if the vsock +// entries in the Windows registry don't exist. +// +// It returns `ErrHypervUserNotInAdminGroup` if the user doesn't +// belong to the Hyper-V administrators group. +func (h HyperVStubber) canCreate(mounts int) error { + return checkCanCreate(h.defaultPermissionChecks(), mounts) +} + +func checkCanCreate(checks permissionChecks, mounts int) error { + if checks.isElevatedProcess() { return nil } - // not admin, check if there is at least one existing machine - // if so, user could create more machines just by being member of the hyperv admin group - machines, err := h.countMachinesWithToolname() - if err != nil { - return err - } - // no existing machines, require to be admin - if machines == 0 { + if !checks.vsockEntriesExist(mounts) { return ErrHypervRegistryInitRequiresElevation } - // at least 1 machine exists - // if user is member of the hyperv admin group, allow creation - return VerifyHyperVPermissions() + if !checks.isHyperVAdminMember() { + return ErrHypervUserNotInAdminGroup + } + return nil } // launchElevate attempts to automatically re-run the command as administrator @@ -307,31 +335,59 @@ func isLegacyMachine(mc *vmconfigs.MachineConfig) bool { return mc.HyperVHypervisor != nil && mc.HyperVHypervisor.ReadyVsock.MachineName != "" } +// canRemove checks if the user can remove an Hyper-V machine. +// It returns `nil` when the user can remove it. The conditions are: +// - the command is running as Administrator (elevated mode) +// OR +// - the machine isn't a legacy one (one that doesn't share the vsock) AND +// the user is a member of the Hyper-V Administrators group AND +// it's not the last machine OR the vsock entries have `KeepAfterMachineRemove=true` +// +// It returns `ErrHypervRegistryRemoveRequiresElevation` if the machine is legacy one OR +// if the vsock entry in the Windows registry doesn't have `KeepAfterMachineRemove=true`. +// +// It returns `ErrHypervUserNotInAdminGroup` if the user doesn't +// belogn to the Hyper-V administrators group. func (h HyperVStubber) canRemove(mc *vmconfigs.MachineConfig) error { - // if admin, can always remove - if windows.HasAdminRights() { + return checkCanRemove(mc, h.defaultPermissionChecks()) +} + +func checkCanRemove(mc *vmconfigs.MachineConfig, checks permissionChecks) error { + if checks.isElevatedProcess() { return nil } - - // if machine is legacy (machineName field), require admin rights to remove if isLegacyMachine(mc) { - return ErrHypervRegistryRemoveRequiresElevation + return ErrHypervLegacyMachineRequiresElevation } - - // not admin, check if there are multiple machines - // if so, user could remove the machine just by being member of the hyperv admin group - // (only the last machine removal requires Registry changes) - machines, err := h.countMachinesWithToolname() + if !checks.isHyperVAdminMember() { + return ErrHypervUserNotInAdminGroup + } + machines, err := checks.existingMachinesNum() if err != nil { return err } - // more than 1 machine exists, allow removal if user has hyperv admin rights - if machines > 1 { - return VerifyHyperVPermissions() + if canSkipVSockEntriesRemoval(machines, *mc.HyperVHypervisor) { + return nil } return ErrHypervRegistryRemoveRequiresElevation } +func canSkipVSockEntriesRemoval(machines int, hv vmconfigs.HyperVConfig) bool { + if machines > 1 { + return true + } + if !hv.NetworkVSock.KeepAfterMachineRemove || + !hv.ReadyVsock.KeepAfterMachineRemove { + return false + } + for _, m := range hv.FileserverVSocks { + if !m.KeepAfterMachineRemove { + return false + } + } + return true +} + // canStartOrStop checks if the machine can be started or stopped. // Legacy machines require admin rights to start or stop. func (h HyperVStubber) canStartOrStop(mc *vmconfigs.MachineConfig) error { diff --git a/pkg/machine/hyperv/stubber_test.go b/pkg/machine/hyperv/stubber_test.go new file mode 100644 index 0000000000..2b91d29ca4 --- /dev/null +++ b/pkg/machine/hyperv/stubber_test.go @@ -0,0 +1,180 @@ +//go:build windows + +package hyperv + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "go.podman.io/podman/v6/pkg/machine/hyperv/vsock" + "go.podman.io/podman/v6/pkg/machine/vmconfigs" +) + +func TestCanCreate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + isElevated bool + vsockEntriesExist bool + isHyperVAdmin bool + mounts int + expectedErr error + }{ + { + name: "in elevated process can always create", + isElevated: true, + vsockEntriesExist: false, + isHyperVAdmin: false, + mounts: 2, + expectedErr: nil, + }, + { + name: "not elevated, no vsock entries", + isElevated: false, + vsockEntriesExist: false, + isHyperVAdmin: true, + expectedErr: ErrHypervRegistryInitRequiresElevation, + }, + { + name: "not elevated, vsock entries exist, not hyperv admin", + isElevated: false, + vsockEntriesExist: true, + isHyperVAdmin: false, + expectedErr: ErrHypervUserNotInAdminGroup, + }, + { + name: "not elevated, vsock entries exist, hyperv admin", + isElevated: false, + vsockEntriesExist: true, + isHyperVAdmin: true, + expectedErr: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + checks := permissionChecks{ + isElevatedProcess: func() bool { return tt.isElevated }, + isHyperVAdminMember: func() bool { return tt.isHyperVAdmin }, + vsockEntriesExist: func(int) bool { return tt.vsockEntriesExist }, + } + + err := checkCanCreate(checks, tt.mounts) + assert.ErrorIs(t, err, tt.expectedErr) + }) + } +} + +func TestCanRemove(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + isElevatedProcess bool + isHyperVAdminMember bool + isLegacyMachine bool + skipVsockEntriesRemoval bool + isLastMachine bool + expectedErr error + }{ + { + name: "elevated process can always remove", + isElevatedProcess: true, + isHyperVAdminMember: false, + isLegacyMachine: false, + skipVsockEntriesRemoval: false, + isLastMachine: true, + expectedErr: nil, + }, + { + name: "not elevated, legacy machine requires elevation", + isElevatedProcess: false, + isHyperVAdminMember: false, + isLegacyMachine: true, + skipVsockEntriesRemoval: false, + isLastMachine: true, + expectedErr: ErrHypervLegacyMachineRequiresElevation, + }, + { + name: "not elevated, not hyperv admins member", + isElevatedProcess: false, + isHyperVAdminMember: false, + isLegacyMachine: false, + skipVsockEntriesRemoval: false, + isLastMachine: true, + expectedErr: ErrHypervUserNotInAdminGroup, + }, + { + name: "not elevated, hyperv admins member, not last machine", + isElevatedProcess: false, + isHyperVAdminMember: true, + isLegacyMachine: false, + skipVsockEntriesRemoval: false, + isLastMachine: false, + expectedErr: nil, + }, + { + name: "not elevated, hyperv admins member, last machine, skip vsock entries removal", + isElevatedProcess: false, + isHyperVAdminMember: true, + isLegacyMachine: false, + skipVsockEntriesRemoval: true, + isLastMachine: true, + expectedErr: nil, + }, + { + name: "not elevated, hyperv admins member, last machine, don't skip vsock entries removal", + isElevatedProcess: false, + isHyperVAdminMember: true, + isLegacyMachine: false, + skipVsockEntriesRemoval: false, + isLastMachine: true, + expectedErr: ErrHypervRegistryRemoveRequiresElevation, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + checks := permissionChecks{ + isElevatedProcess: func() bool { return tt.isElevatedProcess }, + isHyperVAdminMember: func() bool { return tt.isHyperVAdminMember }, + existingMachinesNum: func() (int, error) { + if tt.isLastMachine { + return 1, nil + } else { + return 2, nil + } + }, + } + + vsockMachineName := "" + if tt.isLegacyMachine { + vsockMachineName = "podmanmachine" + } + + mc := &vmconfigs.MachineConfig{ + HyperVHypervisor: &vmconfigs.HyperVConfig{ + ReadyVsock: vsock.HVSockRegistryEntry{ + MachineName: vsockMachineName, + KeepAfterMachineRemove: tt.skipVsockEntriesRemoval, + }, + NetworkVSock: vsock.HVSockRegistryEntry{ + KeepAfterMachineRemove: tt.skipVsockEntriesRemoval, + }, + }, + } + + err := checkCanRemove(mc, checks) + if tt.expectedErr != nil { + assert.EqualError(t, err, tt.expectedErr.Error()) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/pkg/machine/hyperv/volumes.go b/pkg/machine/hyperv/volumes.go index a353e1cb39..b17e3beb73 100644 --- a/pkg/machine/hyperv/volumes.go +++ b/pkg/machine/hyperv/volumes.go @@ -73,6 +73,7 @@ func createShares(mc *vmconfigs.MachineConfig) (err error) { } mount.VSockNumber = &testVsock.Port + mc.HyperVHypervisor.FileserverVSocks = append(mc.HyperVHypervisor.FileserverVSocks, *testVsock) logrus.Debugf("Going to share directory %s via 9p on vsock %d", mount.Source, testVsock.Port) } return nil diff --git a/pkg/machine/hyperv/vsock/vsock.go b/pkg/machine/hyperv/vsock/vsock.go index a6bfdde7f3..1126f54078 100644 --- a/pkg/machine/hyperv/vsock/vsock.go +++ b/pkg/machine/hyperv/vsock/vsock.go @@ -27,6 +27,11 @@ const ( PodmanToolName = "podman" // HvsockPurpose is the string identifier for the sock purpose in a registry entry HvsockPurpose = "Purpose" + // HvsockKeepAfterMachineRemove is a flag to avoid that the entry gets deleted when + // the last machine gets deleted. This is important for use cases where an Administrator + // wants to pre-create the registry key so that the user can create and delete machines + // without requiring admin privileges. + HvsockKeepAfterMachineRemove = "KeepAfterMachineRemove" // VsockRegistryPath describes the registry path to where the hvsock registry entries live VsockRegistryPath = `SOFTWARE\Microsoft\Windows NT\CurrentVersion\Virtualization\GuestCommunicationServices` // LinuxVM is the default guid for a Linux VM on Windows @@ -91,6 +96,12 @@ type HVSockRegistryEntry struct { // This provides information about the entry's origin and can be used for filter entries // if purpose is not enough. ToolName string `json:"creator_tool,omitempty"` + + // KeepAfterMachineRemove is set to true if the registry entry should not be deleted + // when the user deletes or reset the Podman machines. + // This is useful to let unprivileged users create and delete machines without + // requiring privileges to edit the registry. + KeepAfterMachineRemove bool `json:"keep_after_machine_remove,omitempty"` } // Add creates a new Windows registry entry with string values from the @@ -338,6 +349,12 @@ func loadHVSockRegistryEntries(purpose HVSockPurpose, limit int) ([]*HVSockRegis continue } + keep, _, err := k.GetIntegerValue(HvsockKeepAfterMachineRemove) + keepBool := false + if err == nil { + keepBool = keep != 0 + } + entryPurpose, err := toHVSockPurpose(p) if err != nil { logrus.Debugf("Could not convert purpose string %q for entry %s: %v", p, fqPath, err) @@ -366,16 +383,34 @@ func loadHVSockRegistryEntries(purpose HVSockPurpose, limit int) ([]*HVSockRegis } allEntries = append(allEntries, &HVSockRegistryEntry{ - KeyName: subKeyName, - Purpose: entryPurpose, - Port: port, - ToolName: PodmanToolName, + KeyName: subKeyName, + Purpose: entryPurpose, + Port: port, + ToolName: PodmanToolName, + KeepAfterMachineRemove: keepBool, }) } return allEntries, nil } +func CheckIfHVSockRegistryEntriesExist(mountsNum int) bool { + // The number or required HVSock registry entries + // depends on the purpose + requiredEntries := map[HVSockPurpose]int{ + Network: 1, + Events: 1, + Fileserver: mountsNum, + } + for p, i := range requiredEntries { + entries, err := loadHVSockRegistryEntries(p, i) + if len(entries) < i || err != nil { + return false + } + } + return true +} + func LoadHVSockRegistryEntryByPurpose(purpose HVSockPurpose) (*HVSockRegistryEntry, error) { entries, err := loadHVSockRegistryEntries(purpose, 1) if err != nil { @@ -427,6 +462,10 @@ func RemoveAllHVSockRegistryEntries() error { var removalErrors []error for _, sock := range allSocks { + // Don't remove registry entries if KeepAfterMachineRemove is set to true + if sock.KeepAfterMachineRemove { + continue + } if err := sock.Remove(); err != nil { logrus.Errorf("unable to remove registry entry for %s: %q", sock.KeyName, err) removalErrors = append(removalErrors, fmt.Errorf("failed to remove sock %s: %w", sock.KeyName, err)) diff --git a/pkg/machine/vmconfigs/config_windows.go b/pkg/machine/vmconfigs/config_windows.go index 163d4bcb97..a287835156 100644 --- a/pkg/machine/vmconfigs/config_windows.go +++ b/pkg/machine/vmconfigs/config_windows.go @@ -12,6 +12,8 @@ type HyperVConfig struct { ReadyVsock vsock.HVSockRegistryEntry // NetworkVSock is for the user networking NetworkVSock vsock.HVSockRegistryEntry + // FileserverVSocks are for machine mounts (one entry per mount) + FileserverVSocks []vsock.HVSockRegistryEntry } type WSLConfig struct { From 15c9ca130ffb3d12619817a842cc52e3fc738b4a Mon Sep 17 00:00:00 2001 From: Mario Loriedo Date: Mon, 13 Apr 2026 22:49:12 +0200 Subject: [PATCH 3/3] Add command `podman system hyperv-prep` Introducing a new `podmand system` subcommand to prepare a Windows host to run Hyper-V based Podman machines: `hyperv-prep`. When executed it: - creates of the registry keys for VSocks - adds the current user to the Hyper-V administrators group This command requires an administrator terminal. Signed-off-by: Mario Loriedo --- cmd/podman/system/hyperv_prep_windows.go | 267 ++++++++++++++++++ contrib/cirrus/win-unit-main.ps1 | 6 + .../markdown/podman-system-hyperv-prep.1.md | 71 +++++ docs/source/markdown/podman-system.1.md | 1 + docs/tutorials/podman-for-windows.md | 22 +- hack/man-page-checker | 12 +- pkg/machine/hyperv/hutil.go | 53 ++++ pkg/machine/hyperv/stubber.go | 4 +- pkg/machine/hyperv/volumes.go | 2 +- pkg/machine/hyperv/vsock/vsock.go | 59 ++-- test/e2e/common_windows_test.go | 86 ++++++ test/e2e/system_hyperv_prep_test.go | 96 +++++++ winmake.ps1 | 25 +- 13 files changed, 673 insertions(+), 31 deletions(-) create mode 100644 cmd/podman/system/hyperv_prep_windows.go create mode 100644 docs/source/markdown/podman-system-hyperv-prep.1.md create mode 100644 test/e2e/common_windows_test.go create mode 100644 test/e2e/system_hyperv_prep_test.go diff --git a/cmd/podman/system/hyperv_prep_windows.go b/cmd/podman/system/hyperv_prep_windows.go new file mode 100644 index 0000000000..c5a3f7e91b --- /dev/null +++ b/cmd/podman/system/hyperv_prep_windows.go @@ -0,0 +1,267 @@ +//go:build windows && remote + +package system + +import ( + "bufio" + "errors" + "fmt" + "os" + "os/user" + "strings" + + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "go.podman.io/common/pkg/completion" + "go.podman.io/podman/v6/cmd/podman/registry" + "go.podman.io/podman/v6/cmd/podman/validate" + "go.podman.io/podman/v6/pkg/machine/hyperv" + "go.podman.io/podman/v6/pkg/machine/hyperv/vsock" + "go.podman.io/podman/v6/pkg/machine/windows" +) + +const ( + mountsFlag = "mounts" + defaultMounts = 2 +) + +var ( + hypervPrepDescription = `Command for Windows administrators who need to configure an host for running Hyper-V-based Podman machines. + + This command creates the required registry entries in HKEY_LOCAL_MACHINE for Hyper-V vsock communication and adds the current user to the Hyper-V Administrators group. + + After this command execution, a non-admin user can manage Hyper-V-based Podman machines (create,start,stop,delete). + + This command requires administrator privileges except when using the flag --status.` + + hypervPrepCommand = &cobra.Command{ + Use: "hyperv-prep [options]", + Args: validate.NoArgs, + Short: "Prepare the host to run Hyper-V-based Podman machines", + Long: hypervPrepDescription, + PersistentPreRunE: validate.NoOp, + RunE: hypervPrep, + ValidArgsFunction: completion.AutocompleteNone, + Example: `podman system hyperv-prep +podman system hyperv-prep --status +podman system hyperv-prep --reset --force`, + } + showStatus bool + resetEntries bool + mounts int +) + +func init() { + registry.Commands = append(registry.Commands, registry.CliCommand{ + Command: hypervPrepCommand, + Parent: systemCmd, + }) + flags := hypervPrepCommand.Flags() + flags.BoolVar(&showStatus, "status", false, "Show vsock registry entries and Hyper-V group membership status") + flags.BoolVar(&resetEntries, "reset", false, "Remove all Podman vsock registry entries and optionally remove user from Hyper-V Administrators group") + flags.BoolVarP(&force, "force", "f", false, "Don't ask for confirmation during reset. Valid only when used with --reset.") + flags.IntVar(&mounts, mountsFlag, defaultMounts, "Number of vsock entries to create for mount purpose") + hypervPrepCommand.MarkFlagsMutuallyExclusive("status", "reset", mountsFlag) + hypervPrepCommand.MarkFlagsMutuallyExclusive("status", "force") + _ = hypervPrepCommand.RegisterFlagCompletionFunc(mountsFlag, completion.AutocompleteNone) +} + +func hypervPrep(_ *cobra.Command, _ []string) error { + // --status can run without administrator privileges + if showStatus { + if err := doStatusForRegistries(); err != nil { + return err + } + doStatusForGroupMembership() + return nil + } + + if !windows.HasAdminRights() { + return fmt.Errorf("this command requires administrator privileges, please run in an elevated terminal") + } + + if resetEntries { + if err := doResetForRegistries(); err != nil { + return err + } + return doResetForGroupMembership() + } + + if err := doPreparationForRegistries(); err != nil { + return err + } + return doPreparationForGroupMembership() +} + +func doPreparationForRegistries() error { + // Every VSock has a purpose (Network, Events or Fileserver) and the + // VSocks with purpose Fileserver need as many as the machine mounts. + entriesPerPurpose := map[vsock.HVSockPurpose]int{ + vsock.Network: 1, + vsock.Events: 1, + vsock.Fileserver: mounts, + } + + var created []string + for purpose, entries := range entriesPerPurpose { + for range entries { + if entry, err := vsock.NewHVSockRegistryEntry(purpose, true); err != nil { + if errors.Is(err, vsock.ErrVSockRegistryEntryExists) { + logrus.Infof("Registry entry for %s already exists, skipping", purpose) + continue + } + return err + } else { + created = append(created, fmt.Sprintf("%s (port %d)", purpose.String(), entry.Port)) + } + } + } + + if len(created) == 0 { + fmt.Println("All required registry entries already exist") + } else { + fmt.Println("Successfully created registry entries for:") + for _, entry := range created { + fmt.Printf(" - %s\n", entry) + } + fmt.Println("These entries will persist even when all machines are removed.") + } + + return nil +} + +func doPreparationForGroupMembership() error { + if hyperv.IsHyperVAdminsGroupMember() { + fmt.Println("User is already a member of the Hyper-V Administrators group") + return nil + } + u, err := user.Current() + if err != nil { + return fmt.Errorf("failed to get current user: %w", err) + } + if err := hyperv.AddUserToHyperVAdminGroup(u.Username); err != nil { + return fmt.Errorf("failed to add user to Hyper-V Administrators group: %w", err) + } + fmt.Printf("\nAdded user %s to the Hyper-V Administrators group\n", u.Username) + fmt.Println("Note: You may need to log out and log back in for the new group membership to take effect.") + return nil +} + +func doStatusForRegistries() error { + purposes := []vsock.HVSockPurpose{vsock.Network, vsock.Events, vsock.Fileserver} + fmt.Printf("Hyper-V vsock registry entries:\n") + foundAny := false + for _, purpose := range purposes { + entries, err := vsock.LoadAllHVSockRegistryEntriesByPurpose(purpose) + if err != nil { + logrus.Debugf("Error loading registry entries for %s: %v", purpose.String(), err) + continue + } + + if len(entries) > 0 { + foundAny = true + for _, entry := range entries { + fmt.Print(entry) + } + } + } + if !foundAny { + fmt.Println(" No vsock registry entries found.") + } + return nil +} + +func doStatusForGroupMembership() { + fmt.Println("Hyper-V Administrators group membership:") + if hyperv.IsHyperVAdminsGroupMember() { + fmt.Println(" Current user is a member") + } else { + fmt.Println(" Current user is NOT a member") + } +} + +func doResetForRegistries() error { + purposes := []vsock.HVSockPurpose{vsock.Network, vsock.Events, vsock.Fileserver} + var allEntries []*vsock.HVSockRegistryEntry + // Load all entries for each purpose + for _, purpose := range purposes { + entries, err := vsock.LoadAllHVSockRegistryEntriesByPurpose(purpose) + if err != nil { + logrus.Debugf("Error loading registry entries for %s: %v", purpose.String(), err) + continue + } + allEntries = append(allEntries, entries...) + } + if len(allEntries) == 0 { + fmt.Println("No vsock registry entries found to remove.") + return nil + } + if !force { + fmt.Println("Existing VSock registry entries for Podman:") + for _, entry := range allEntries { + fmt.Print(entry) + } + reader := bufio.NewReader(os.Stdin) + fmt.Print("Do you want to delete these entries from the Windows registry? [y/N] ") + answer, err := reader.ReadString('\n') + if err != nil { + return err + } + if strings.ToLower(answer)[0] != 'y' { + return nil + } + } + var removed []string + var removalErrors []error + for _, entry := range allEntries { + if err := entry.Remove(); err != nil { + logrus.Errorf("Failed to remove registry entry %s: %v", entry.KeyName, err) + removalErrors = append(removalErrors, fmt.Errorf("failed to remove %s: %w", entry.KeyName, err)) + } else { + removed = append(removed, fmt.Sprintf("%s (port %d)", entry.Purpose.String(), entry.Port)) + } + } + if len(removed) > 0 { + fmt.Println("Successfully removed registry entries for:") + for _, entry := range removed { + fmt.Printf(" - %s\n", entry) + } + } + if len(removalErrors) > 0 { + fmt.Println("\nSome entries could not be removed. See logs for details.") + return errors.Join(removalErrors...) + } + return nil +} + +func doResetForGroupMembership() error { + inGroup, err := hyperv.IsCurrentUserInHyperVAdminGroup() + if err != nil { + return fmt.Errorf("failed to check Hyper-V Administrators group membership: %w", err) + } + if !inGroup { + fmt.Println("Current user isn't a member of the Hyper-V Administrators group, it won't be removed.") + return nil + } + if !force { + reader := bufio.NewReader(os.Stdin) + fmt.Print("Do you want to remove the current user from the Hyper-V Administrators group? [y/N] ") + answer, err := reader.ReadString('\n') + if err != nil { + return err + } + + if strings.ToLower(answer)[0] != 'y' { + return nil + } + } + u, err := user.Current() + if err != nil { + return fmt.Errorf("failed to get current user: %w", err) + } + if err := hyperv.RemoveUserFromHyperVAdminGroup(u.Username); err != nil { + return fmt.Errorf("failed to remove user from Hyper-V Administrators group: %w", err) + } + fmt.Printf("Removed user %s from the Hyper-V Administrators group\n", u.Username) + return nil +} diff --git a/contrib/cirrus/win-unit-main.ps1 b/contrib/cirrus/win-unit-main.ps1 index b945f4a155..5f0151a5e2 100644 --- a/contrib/cirrus/win-unit-main.ps1 +++ b/contrib/cirrus/win-unit-main.ps1 @@ -10,4 +10,10 @@ if ($Env:CI -eq "true") { Run-Command ".\winmake.ps1 localunit" +Run-Command ".\winmake.ps1 ginkgo-run" # non-machine e2e integration + # test. Currently it's only + # checking the hyperv-prep + # command and it doesn't deserve + # a specific task. + Pop-Location diff --git a/docs/source/markdown/podman-system-hyperv-prep.1.md b/docs/source/markdown/podman-system-hyperv-prep.1.md new file mode 100644 index 0000000000..3813b2aa89 --- /dev/null +++ b/docs/source/markdown/podman-system-hyperv-prep.1.md @@ -0,0 +1,71 @@ +% podman-system-hyperv-prep 1 + +## NAME +podman\-system\-hyperv\-prep - A Windows administrator command to prepare a host that is going to run Hyper-V based Podman machines + +## SYNOPSIS +**podman system hyperv-prep** [*options*] + +## DESCRIPTION +Prepare a Windows host to run Hyper-V based Podman machines by creating the +required registry entries in HKEY_LOCAL_MACHINE for Hyper-V VSock communication +and adding the current user to the Hyper-V Administrators group. + +The registry entries are marked to persist even when all machines are removed, +and the group membership allows the user to manage Hyper-V virtual machines. +Together, these avoid the need for administrator privileges during normal machine +operations. + +This command requires administrator privileges and is only available on Windows. +The **--status** option can be used without administrator privileges. + +## OPTIONS + +#### **--mounts**=*number* + +Number of VSock entries for mount purpose. Every mounted host folder of a running machine needs a dedicated VSock. There should be enough VSock to satisfy the needs of running Podman machines on the host. The default is **2**. + +#### **--force**, **-f** + +Skip confirmation prompts during reset. Only valid with **--reset**. + +#### **--reset** + +Remove all Podman VSock registry entries and remove the current user from the +Hyper-V Administrators group. Prompts for confirmation before each action unless +**--force** is specified. + +#### **--status** + +Show the list of VSock registry entries and the current user's Hyper-V +Administrators group membership status. + +## EXAMPLE + +Create the required registry entries and add the current user to the Hyper-V +Administrators group: +``` +podman system hyperv-prep +``` + +Show existing registry entries and group membership status: +``` +podman system hyperv-prep --status +``` + +Remove all Podman VSock registry entries and the current user from the Hyper-V +Administrators group (with confirmation prompts): +``` +podman system hyperv-prep --reset +``` + +Reset without confirmation prompts: +``` +podman system hyperv-prep --reset --force +``` + +## SEE ALSO +**[podman(1)](podman.1.md)**, **[podman-system(1)](podman-system.1.md)** + +## HISTORY +April 2026 diff --git a/docs/source/markdown/podman-system.1.md b/docs/source/markdown/podman-system.1.md index 0f20ced2c4..7cc40b4286 100644 --- a/docs/source/markdown/podman-system.1.md +++ b/docs/source/markdown/podman-system.1.md @@ -15,6 +15,7 @@ The system command allows management of the podman systems | ------- | ------------------------------------------------------------ | ------------------------------------------------------------------------ | | check | [podman-system-check(1)](podman-system-check.1.md) | Perform consistency checks on image and container storage. | connection | [podman-system-connection(1)](podman-system-connection.1.md) | Manage the destination(s) for Podman service(s) | +| hyperv-prep| [podman-system-hyperv-prep(1)](podman-system-hyperv-prep.1.md) | A Windows administrator command to prepare a host that is going to run Hyper-V based Podman machines | | df | [podman-system-df(1)](podman-system-df.1.md) | Show podman disk usage. | | events | [podman-events(1)](podman-events.1.md) | Monitor Podman events | | info | [podman-info(1)](podman-info.1.md) | Display Podman related system information. | diff --git a/docs/tutorials/podman-for-windows.md b/docs/tutorials/podman-for-windows.md index 41ed4809f5..ab825d3f86 100644 --- a/docs/tutorials/podman-for-windows.md +++ b/docs/tutorials/podman-for-windows.md @@ -55,9 +55,13 @@ a VM, you must have a VM that supports nested virtualization. Hyper-V is only available on Windows Enterprise, Pro, or Education editions (not Home). The command to initialize the first Hyper-V Podman machine, and the command -to remove the last one, both require administrator privileges. Other commands for -machine management (start, stop, etc...) require that the current user is a member -of the Hyper-V administrators group. +to remove the last one, both require administrator privileges because they create +and delete machine-scope registry keys. Additionally, all Hyper-V machine commands +require that the current user is a member of the Hyper-V Administrators group. To +avoid these requirements, an administrator can run `podman system hyperv-prep` +once to pre-create the registry entries and add the current user to the Hyper-V +Administrators group, after which no machine operation requires administrator +privileges. It is also recommended to install the modern "Windows Terminal," which provides a superior user experience to the standard PowerShell and CMD @@ -176,10 +180,14 @@ PS C:\Users\User> podman machine init --provider hyperv **Note:** Hyper-V requires administrator privileges to initialize the first podman machine. Similarly it requires administrator privileges to remove the -last machine. That's because these operation create and delete -machine-scope registry keys, required to support the communication between the -guest OS and the host. Other commands such as machine start and stop require -that the current user is a member of the Hyper-V Administrator group. +last machine. That's because these operations create and delete machine-scope +registry keys, required to support the communication between the guest OS and +the host. Additionally, all Hyper-V machine commands require that the current +user is a member of the Hyper-V Administrators group. To avoid these +requirements, run `podman system hyperv-prep` once from an administrator +terminal to pre-create persistent registry entries and add the current user +to the Hyper-V Administrators group, so that all subsequent machine operations +can run without administrator privileges. Starting Machine ---------------- diff --git a/hack/man-page-checker b/hack/man-page-checker index 748da6b7a0..04c41139e9 100755 --- a/hack/man-page-checker +++ b/hack/man-page-checker @@ -53,6 +53,11 @@ for md in $(ls -1 *-*.1.md | grep -v remote);do # dashes. parent="podman.1.md" fi + if [[ $parent =~ "podman-system-hyperv" ]]; then + # podman-system-hyperv-prep.1.md: "hyperv-prep" is a single + # hyphenated subcommand of "podman system", not a sub-subcommand. + parent="podman-system.1.md" + fi x=3 if expr -- "$parent" : ".*-" >/dev/null; then x=4 @@ -79,7 +84,8 @@ function compare_usage() { local from_man="$2" # Special case: this is an external call to docker - if [[ $cmd = "podman compose" ]] || [[ $cmd = "podmansh" ]]; then + # or a Windows-only command not available on Linux + if [[ $cmd = "podman compose" ]] || [[ $cmd = "podmansh" ]] || [[ $cmd = "podman system hyperv-prep" ]]; then return fi @@ -158,6 +164,10 @@ for md in *.1.md;do # special case: the command is "auto-update", with a hyphen md_nodash='podman auto-update' fi + if [[ $md_nodash = 'podman system hyperv prep' ]]; then + # special case: the command is "hyperv-prep", with a hyphen + md_nodash='podman system hyperv-prep' + fi if [[ "$cmd" != "$md_nodash" ]] && [[ "$cmd" != "podman-remote" ]]; then echo printf "Inconsistent program name in SYNOPSIS in %s:\n" $md diff --git a/pkg/machine/hyperv/hutil.go b/pkg/machine/hyperv/hutil.go index 16b4c9dbee..1cd9db6535 100644 --- a/pkg/machine/hyperv/hutil.go +++ b/pkg/machine/hyperv/hutil.go @@ -24,6 +24,7 @@ var ( // Lazily load the NetAPI32 DLL and the function we need modnetapi32 = syswindows.NewLazySystemDLL("netapi32.dll") procNetLocalGroupAddMembers = modnetapi32.NewProc("NetLocalGroupAddMembers") + procNetLocalGroupDelMembers = modnetapi32.NewProc("NetLocalGroupDelMembers") procNetLocalGroupGetMembers = modnetapi32.NewProc("NetLocalGroupGetMembers") procNetApiBufferFree = modnetapi32.NewProc("NetApiBufferFree") ) @@ -90,6 +91,40 @@ func AddUserToHyperVAdminGroup(username string) error { return nil } +// RemoveUserFromHyperVAdminGroup removes the specified user from the local Hyper-V Administrators group. +func RemoveUserFromHyperVAdminGroup(username string) error { + groupPtr, err := getHyperVAdminGroupNamePtr() + if err != nil { + return err + } + + userSid, _, _, err := syswindows.LookupSID("", username) + if err != nil { + return fmt.Errorf("failed to look up SID for user %q: %w", username, err) + } + + info := localGroupMembersInfo0{ + Sid: userSid, + } + + // C Signature: NET_API_STATUS NetLocalGroupDelMembers(LPCWSTR servername, LPCWSTR groupname, DWORD level, LPBYTE buf, DWORD totalentries) + ret, _, _ := procNetLocalGroupDelMembers.Call( + 0, // ServerName (0 = local machine) + uintptr(unsafe.Pointer(groupPtr)), // GroupName + 0, // Level 0 (we are passing a SID) + uintptr(unsafe.Pointer(&info)), // Buffer containing our struct + 1, // Total Entries being removed + ) + + errno := syswindows.Errno(ret) + // ERROR_MEMBER_NOT_IN_ALIAS = "The specified account name is not a member of the group." + if errno != syswindows.NO_ERROR && errno != syswindows.ERROR_MEMBER_NOT_IN_ALIAS { + return fmt.Errorf("NetLocalGroupDelMembers failed with error: %w", errno) + } + + return nil +} + func getHyperVAdminGroupNamePtr() (*uint16, error) { hypervAdminsSid, err := syswindows.CreateWellKnownSid(syswindows.WinBuiltinHyperVAdminsSid) if err != nil { @@ -175,6 +210,24 @@ func isUserInGroup(groupPtr *uint16, userSid *syswindows.SID) (bool, error) { return false, nil } +// IsCurrentUserInHyperVAdminGroup checks the group database directly +// (via NetLocalGroupGetMembers) rather than the process token, so it +// reflects membership changes that haven't taken effect in the current +// logon session yet. +func IsCurrentUserInHyperVAdminGroup() (bool, error) { + userSid, err := getCurrentUserSID() + if err != nil { + return false, fmt.Errorf("failed to look up SID for current user: %w", err) + } + + groupPtr, err := getHyperVAdminGroupNamePtr() + if err != nil { + return false, fmt.Errorf("failed to get localized Hyper-V Admin group name: %w", err) + } + + return isUserInGroup(groupPtr, userSid) +} + // VerifyHyperVPermissions returns `nil` if the user has the privileges to manage // Hyper-V VMs. This can be either because the command is run // as an administrator or because the user is a member of the Hyper-V Admins group. diff --git a/pkg/machine/hyperv/stubber.go b/pkg/machine/hyperv/stubber.go index 1d707bb7d4..3dc638919a 100644 --- a/pkg/machine/hyperv/stubber.go +++ b/pkg/machine/hyperv/stubber.go @@ -139,7 +139,7 @@ func (h HyperVStubber) CreateVM(_ define.CreateVMOpts, mc *vmconfigs.MachineConf if !windows.HasAdminRights() { return ErrHypervRegistryInitRequiresElevation } - networkHVSock, err = vsock.NewHVSockRegistryEntry(vsock.Network) + networkHVSock, err = vsock.NewHVSockRegistryEntry(vsock.Network, false) if err != nil { return err } @@ -690,7 +690,7 @@ func (h HyperVStubber) PrepareIgnition(mc *vmconfigs.MachineConfig, _ *ignition. } return nil, ErrHypervRegistryInitRequiresElevation } - readySock, err = vsock.NewHVSockRegistryEntry(vsock.Events) + readySock, err = vsock.NewHVSockRegistryEntry(vsock.Events, false) if err != nil { return nil, err } diff --git a/pkg/machine/hyperv/volumes.go b/pkg/machine/hyperv/volumes.go index b17e3beb73..bc3adb804e 100644 --- a/pkg/machine/hyperv/volumes.go +++ b/pkg/machine/hyperv/volumes.go @@ -66,7 +66,7 @@ func createShares(mc *vmconfigs.MachineConfig) (err error) { } return ErrHypervRegistryUpdateRequiresElevation } - testVsock, err = vsock.NewHVSockRegistryEntry(vsock.Fileserver) + testVsock, err = vsock.NewHVSockRegistryEntry(vsock.Fileserver, false) if err != nil { return err } diff --git a/pkg/machine/hyperv/vsock/vsock.go b/pkg/machine/hyperv/vsock/vsock.go index 1126f54078..7b9f085b34 100644 --- a/pkg/machine/hyperv/vsock/vsock.go +++ b/pkg/machine/hyperv/vsock/vsock.go @@ -50,7 +50,7 @@ const ( Fileserver ) -func (hv HVSockPurpose) string() string { +func (hv HVSockPurpose) String() string { switch hv { case Network: return "Network" @@ -63,7 +63,7 @@ func (hv HVSockPurpose) string() string { } func (hv HVSockPurpose) Equal(purpose string) bool { - return hv.string() == purpose + return hv.String() == purpose } func toHVSockPurpose(p string) (HVSockPurpose, error) { @@ -104,6 +104,18 @@ type HVSockRegistryEntry struct { KeepAfterMachineRemove bool `json:"keep_after_machine_remove,omitempty"` } +func (hv *HVSockRegistryEntry) String() string { + keepStatus := "" + if hv.KeepAfterMachineRemove { + keepStatus = " (persistent)" + } + return fmt.Sprintf("- Key: %s\n Purpose: %s\n Port: %d%s\n", + hv.KeyName, + hv.Purpose, + hv.Port, + keepStatus) +} + // Add creates a new Windows registry entry with string values from the // HVSockRegistryEntry. func (hv *HVSockRegistryEntry) Add() error { @@ -115,7 +127,7 @@ func (hv *HVSockRegistryEntry) Add() error { return err } if exists { - return fmt.Errorf("%q: %s", ErrVSockRegistryEntryExists, hv.KeyName) + return fmt.Errorf("%w: %s", ErrVSockRegistryEntryExists, hv.KeyName) } parentKey, err := registry.OpenKey(registry.LOCAL_MACHINE, VsockRegistryPath, registry.QUERY_VALUE) if err != nil { @@ -136,10 +148,20 @@ func (hv *HVSockRegistryEntry) Add() error { } }() - if err := newKey.SetStringValue(HvsockPurpose, hv.Purpose.string()); err != nil { + if err := newKey.SetStringValue(HvsockPurpose, hv.Purpose.String()); err != nil { return err } - return newKey.SetStringValue(HvsockToolName, hv.ToolName) + if err := newKey.SetStringValue(HvsockToolName, hv.ToolName); err != nil { + return err + } + // Save KeepAfterMachineRemove flag if set + if hv.KeepAfterMachineRemove { + var value uint32 = 1 + if err := newKey.SetDWordValue(HvsockKeepAfterMachineRemove, value); err != nil { + return err + } + } + return nil } // Remove deletes the registry key and its string values @@ -155,7 +177,7 @@ func (hv *HVSockRegistryEntry) validate() error { if hv.Port < 1 { return errors.New("port must be larger than 1") } - if len(hv.Purpose.string()) < 1 { + if len(hv.Purpose.String()) < 1 { return errors.New("required field purpose is empty") } if len(hv.ToolName) < 1 { @@ -180,9 +202,9 @@ func (hv *HVSockRegistryEntry) exists() (bool, error) { return false, err } -// findOpenHVSockPort looks for an open random port. it verifies the port is not +// FindOpenHVSockPort looks for an open random port. it verifies the port is not // already being used by another hvsock in the Windows registry. -func findOpenHVSockPort() (uint64, error) { +func FindOpenHVSockPort() (uint64, error) { // If we cannot find a free port in 10 attempts, something is wrong for range 10 { port, err := utils.GetRandomPort() @@ -207,19 +229,20 @@ func findOpenHVSockPort() (uint64, error) { // NewHVSockRegistryEntry is a constructor to make a new registry entry in Windows. After making the new // object, you must call the add() method to *actually* add it to the Windows registry. -func NewHVSockRegistryEntry(purpose HVSockPurpose) (*HVSockRegistryEntry, error) { +func NewHVSockRegistryEntry(purpose HVSockPurpose, keep bool) (*HVSockRegistryEntry, error) { // a so-called wildcard entry ... everything from FACB -> 6D3 is MS special sauce // for a " linux vm". this first segment is hexi for the hvsock port number // 00000400-FACB-11E6-BD58-64006A7986D3 - port, err := findOpenHVSockPort() + port, err := FindOpenHVSockPort() if err != nil { return nil, err } r := HVSockRegistryEntry{ - KeyName: portToKeyName(port), - Purpose: purpose, - Port: port, - ToolName: PodmanToolName, + KeyName: PortToKeyName(port), + Purpose: purpose, + Port: port, + ToolName: PodmanToolName, + KeepAfterMachineRemove: keep, } if err := r.Add(); err != nil { return nil, err @@ -227,7 +250,7 @@ func NewHVSockRegistryEntry(purpose HVSockPurpose) (*HVSockRegistryEntry, error) return &r, nil } -func portToKeyName(port uint64) string { +func PortToKeyName(port uint64) string { // this could be flattened but given the complexity, I thought it might // be more difficult to read hexi := strings.ToUpper(fmt.Sprintf("%08x", port)) @@ -235,7 +258,7 @@ func portToKeyName(port uint64) string { } func LoadHVSockRegistryEntry(port uint64) (*HVSockRegistryEntry, error) { - keyName := portToKeyName(port) + keyName := PortToKeyName(port) fqPath := fmt.Sprintf("%s\\%s", VsockRegistryPath, keyName) k, err := openVSockRegistryEntry(fqPath) if err != nil { @@ -361,7 +384,7 @@ func loadHVSockRegistryEntries(purpose HVSockPurpose, limit int) ([]*HVSockRegis continue } - if !entryPurpose.Equal(purpose.string()) { + if !entryPurpose.Equal(purpose.String()) { continue } @@ -417,7 +440,7 @@ func LoadHVSockRegistryEntryByPurpose(purpose HVSockPurpose) (*HVSockRegistryEnt return nil, err } if len(entries) != 1 { - return nil, fmt.Errorf("no hvsock registry entry found for purpose: %s", purpose.string()) + return nil, fmt.Errorf("no hvsock registry entry found for purpose: %s", purpose.String()) } return entries[0], nil diff --git a/test/e2e/common_windows_test.go b/test/e2e/common_windows_test.go new file mode 100644 index 0000000000..9de67d0e52 --- /dev/null +++ b/test/e2e/common_windows_test.go @@ -0,0 +1,86 @@ +//go:build windows + +package integration + +import ( + "os" + "path/filepath" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.podman.io/podman/v6/pkg/machine/windows" + . "go.podman.io/podman/v6/test/utils" +) + +type PodmanTestIntegration struct { + PodmanTest + TempDir string +} + +var podmanTest *PodmanTestIntegration + +type PodmanSessionIntegration struct { + *PodmanSession +} + +func TestMain(m *testing.M) { + os.Exit(m.Run()) +} + +func TestE2E(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "E2E Suite - Windows") +} + +var _ = BeforeEach(func() { + tempDir, err := os.MkdirTemp("", "podman_test") + Expect(err).ToNot(HaveOccurred()) + podmanTest = PodmanTestCreate(tempDir) +}) + +var _ = AfterEach(func() { + if podmanTest != nil && podmanTest.TempDir != "" { + os.RemoveAll(podmanTest.TempDir) + } +}) + +func PodmanTestCreate(tempDir string) *PodmanTestIntegration { + cwd, _ := os.Getwd() + podmanBinary := filepath.Join(cwd, "../../bin/windows/podman.exe") + if envBinary := os.Getenv("PODMAN_BINARY"); envBinary != "" { + podmanBinary = envBinary + } + + p := &PodmanTestIntegration{ + PodmanTest: PodmanTest{ + PodmanBinary: podmanBinary, + }, + TempDir: tempDir, + } + p.PodmanMakeOptions = p.makeOptions + return p +} + +func skipIfNotAdmin(reason string) { + if !windows.HasAdminRights() { + Skip(reason) + } +} + +func (p *PodmanTestIntegration) makeOptions(args []string, _ PodmanExecOptions) []string { + return args +} + +func (p *PodmanTestIntegration) Podman(args []string) *PodmanSessionIntegration { + podmanSession := p.PodmanExecBaseWithOptions(args, PodmanExecOptions{}) + return &PodmanSessionIntegration{podmanSession} +} + +func (p *PodmanTestIntegration) PodmanExitCleanly(args ...string) *PodmanSessionIntegration { + GinkgoHelper() + session := p.Podman(args) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + return session +} diff --git a/test/e2e/system_hyperv_prep_test.go b/test/e2e/system_hyperv_prep_test.go new file mode 100644 index 0000000000..a03f5a6106 --- /dev/null +++ b/test/e2e/system_hyperv_prep_test.go @@ -0,0 +1,96 @@ +//go:build windows + +package integration + +import ( + "os/exec" + "os/user" + "strings" + + "go.podman.io/podman/v6/pkg/machine/hyperv/vsock" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + . "go.podman.io/podman/v6/test/utils" +) + +var _ = Describe("podman system hyperv-prep", func() { + It("rejects incompatible flags together", func() { + session := podmanTest.Podman([]string{"system", "hyperv-prep", "--status", "--reset"}) + session.WaitWithDefaultTimeout() + Expect(session).To(ExitWithError(125, "none of the others can be")) + session = podmanTest.Podman([]string{"system", "hyperv-prep", "--status", "--mounts", "1"}) + session.WaitWithDefaultTimeout() + Expect(session).To(ExitWithError(125, "none of the others can be")) + session = podmanTest.Podman([]string{"system", "hyperv-prep", "--reset", "--mounts", "1"}) + session.WaitWithDefaultTimeout() + Expect(session).To(ExitWithError(125, "none of the others can be")) + }) + + It("creates registry entries and resets them", func() { + skipIfNotAdmin("test requires an elevated (admin) terminal") + + // Preconditions: no existing registry entries and user not in Hyper-V Administrators group + Expect(vsock.CheckIfHVSockRegistryEntriesExist(1)).To(BeFalse(), + "vsock registry entries already exist, cannot run test") + Expect(isCurrentUserHyperVAdmin()).To(BeFalse(), + "user is already a member of the Hyper-V Administrators group, cannot run test") + + // Run `--status` a first time to check that it reports correctly that: + // - No vsock registry keys exist + // - User isn't a member of the Hyper-V Administrators group + session := podmanTest.Podman([]string{"system", "hyperv-prep", "--status"}) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + Expect(session.OutputToString()).To(ContainSubstring("No vsock registry entries found")) + Expect(session.OutputToString()).To(ContainSubstring("Current user is NOT a member")) + + // Run hyperv-prep to create registry entries + session = podmanTest.Podman([]string{"system", "hyperv-prep"}) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + Expect(session.OutputToString()).To(ContainSubstring("Network")) + Expect(session.OutputToString()).To(ContainSubstring("Events")) + Expect(session.OutputToString()).To(ContainSubstring("Fileserver")) + + // Check that running hyperv-prep worked: + // - The registry entries for vsock have been created (for 2 mounts) + // - The user is a member of the Hyper-V Administrators group + Expect(vsock.CheckIfHVSockRegistryEntriesExist(2)).To(BeTrue(), + "vsock registry entries don't exist after running hyperv-prep") + Expect(isCurrentUserHyperVAdmin()).To(BeTrue(), + "user isn't a member of the Hyper-V Administrators group after running hyperv-prep") + + // Run --status a second time to shows the created entries + session = podmanTest.Podman([]string{"system", "hyperv-prep", "--status"}) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + Expect(session.OutputToString()).To(ContainSubstring("Network")) + Expect(session.OutputToString()).To(ContainSubstring("Events")) + Expect(session.OutputToString()).To(ContainSubstring("Fileserver")) + + // Reset the entries to clean up + session = podmanTest.Podman([]string{"system", "hyperv-prep", "--reset", "--force"}) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + Expect(session.OutputToString()).To(ContainSubstring("Successfully removed")) + }) +}) + +func isCurrentUserHyperVAdmin() bool { + u, err := user.Current() + if err != nil { + return false + } + out, err := exec.Command("powershell", "-NoProfile", "-Command", + `Get-LocalGroupMember -Name "Hyper-V Administrators"`).Output() + if err != nil { + return false + } + for _, line := range strings.Split(string(out), "\n") { + if strings.Contains(line, u.Username) { + return true + } + } + return false +} diff --git a/winmake.ps1 b/winmake.ps1 index ae3b7467db..c0820b7c9b 100644 --- a/winmake.ps1 +++ b/winmake.ps1 @@ -78,6 +78,15 @@ function Local-Machine { param ( [string]$files ); + Ginkgo-Run -files $files -testPath 'pkg/machine/e2e/.' -defaultTimeout '50m' +} + +function Ginkgo-Run { + param ( + [string]$files, + [string]$testPath = 'test/e2e/.', + [string]$defaultTimeout = '5m' + ); Build-Ginkgo if ($files) { $files = "--focus-file ""$files""" @@ -89,8 +98,8 @@ function Local-Machine { $focus = "--focus ""$FOCUS"" --silence-skips" } - if ($null -eq $ENV:GINKGOTIMEOUT) { $ENV:GINKGOTIMEOUT = '--timeout=50m' } - Run-Command "./bin/ginkgo.exe -vv --tags `"$remotetags`" ${ENV:GINKGOTIMEOUT} --trace --no-color $focus $files pkg/machine/e2e/." + if ($null -eq $ENV:GINKGOTIMEOUT) { $ENV:GINKGOTIMEOUT = "--timeout=$defaultTimeout" } + Run-Command "./bin/ginkgo.exe -vv --tags `"$remotetags`" ${ENV:GINKGOTIMEOUT} --trace --no-color $focus $files $testPath" } # Expect starting directory to be /podman @@ -379,6 +388,12 @@ switch ($target) { } Local-Machine -files $files } + 'ginkgo-run' { + if ($params.Count -gt 1) { + $files = $params[1] + } + Ginkgo-Run -files $files + } 'clean' { Make-Clean } @@ -431,6 +446,12 @@ switch ($target) { Write-Host 'Example: Run specific machine tests ' Write-Host ' .\winmake localmachine 'basic_test.go"" Write-Host + Write-Host 'Example: Run all e2e tests ' + Write-Host ' .\winmake ginkgo-run' + Write-Host + Write-Host 'Example: Run specific e2e tests ' + Write-Host ' .\winmake ginkgo-run "system_hyperv_prep_test.go"' + Write-Host Write-Host 'Example: Download win-gvproxy and win-sshproxy helpers' Write-Host ' .\winmake win-gvproxy' Write-Host