Skip to content

Commit 99d7d11

Browse files
committed
Consolidate Windows lifecycle coverage
1 parent a5f5e06 commit 99d7d11

5 files changed

Lines changed: 112 additions & 206 deletions

File tree

.github/workflows/test.yml

Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -132,35 +132,18 @@ jobs:
132132
done
133133
exit 1
134134
135-
- name: Test Windows guest control
135+
- name: Test Windows lifecycle
136136
run: |
137137
make build-embedded
138138
TEST_PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH"
139139
for attempt in 1 2 3; do
140140
if sudo env \
141141
"PATH=$TEST_PATH" \
142142
"CI=true" \
143-
"HYPEMAN_RUN_WINDOWS_GUEST_CONTROL_INTEGRATION=1" \
143+
"HYPEMAN_RUN_WINDOWS_LIFECYCLE_INTEGRATION=1" \
144144
"HYPEMAN_WINDOWS_OVMF_CODE=$HYPEMAN_WINDOWS_OVMF_CODE" \
145145
"HYPEMAN_WINDOWS_OVMF_VARS=$HYPEMAN_WINDOWS_OVMF_VARS" \
146-
go test -count=1 -run '^TestWindowsGuestAgentIntegration$' -timeout 2m ./lib/instances; then
147-
exit 0
148-
fi
149-
test "$attempt" = 3 || sleep 5
150-
done
151-
exit 1
152-
153-
- name: Test Windows networking
154-
run: |
155-
TEST_PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH"
156-
for attempt in 1 2 3; do
157-
if sudo env \
158-
"PATH=$TEST_PATH" \
159-
"CI=true" \
160-
"HYPEMAN_RUN_WINDOWS_NETWORKING_INTEGRATION=1" \
161-
"HYPEMAN_WINDOWS_OVMF_CODE=$HYPEMAN_WINDOWS_OVMF_CODE" \
162-
"HYPEMAN_WINDOWS_OVMF_VARS=$HYPEMAN_WINDOWS_OVMF_VARS" \
163-
go test -count=1 -run '^TestWindowsNetworkingIntegration$' -timeout 2m ./lib/instances; then
146+
go test -count=1 -run '^TestWindowsLifecycleIntegration$' -timeout 2m ./lib/instances; then
164147
exit 0
165148
fi
166149
test "$attempt" = 3 || sleep 5

docs/windows-networking.md

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,6 @@
22

33
Hypeman can attach a Windows 11 QEMU guest to its normal TAP/bridge network. The public instance model remains unchanged: `NetworkEnabled` allocates the address, MAC, gateway, netmask, DNS servers, and TAP device used for Linux guests.
44

5-
After the Windows guest agent becomes reachable over virtio-vsock, Hypeman sends the allocation through the typed `ReconfigureNetwork` RPC. The Windows agent:
5+
Create and start apply the current allocation before the instance becomes ready. A configuration failure fails the lifecycle operation rather than exposing a guest with partial networking.
66

7-
1. finds the virtio-net adapter by its allocated MAC address;
8-
2. removes stale IPv4 addresses and default routes;
9-
3. creates the allocated IPv4 address and default route with Windows IP Helper APIs; and
10-
4. applies the allocated DNS servers with `SetInterfaceDnsSettings`.
11-
12-
Windows never uses the Linux shell-command fallback. Create fails and stops the VM if the typed reconfiguration fails. Start applies the current allocation again, allowing an instance to receive a different address or MAC after it was stopped.
13-
14-
RDP is not a Hypeman API. A prepared persona may enable RDP, and callers can reach TCP port 3389 through the instance's generic allocated IP after applying their normal ingress policy.
15-
16-
## Integration fixture
17-
18-
`TestWindowsNetworkingIntegration` uses the private `HYPEMAN_WINDOWS_TEST_AGENT_PERSONA` fixture (default `/ci/windows/persona-agent.qcow2`). It verifies the address from inside Windows, performs a DNS lookup, checks ICMP, and opens the RDP TCP port over the allocated TAP network. The fixture and its Windows license are not stored in this repository.
7+
RDP is not a Hypeman API. A prepared image may enable RDP, and callers can reach TCP port 3389 through the instance's generic allocated IP after applying their normal ingress policy.

lib/instances/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ The launchable Windows image already defines its virtual disk size, so instance
3030

3131
A Windows VM remains `Initializing` until its guest agent answers over VioSock. This avoids treating firmware completion as application readiness.
3232

33+
### Windows networking
34+
35+
Windows uses the same host-side TAP allocation as Linux. Once the guest agent is reachable, the manager sends the complete allocation through the typed `ReconfigureNetwork` RPC. The agent selects the virtio-net adapter by MAC address, replaces stale IPv4 addresses and default routes, and applies DNS through native Windows APIs. It never invokes the Linux shell-command fallback.
36+
37+
Create treats network configuration as part of readiness and tears down a VM if it fails. Start reapplies the current allocation because a stopped instance may receive a different address or MAC before its next boot.
38+
3339
### Why Config Disk? (configdisk.go)
3440

3541
**What:** Read-only erofs disk with instance configuration

lib/instances/windows_guest_agent_integration_linux_test.go

Lines changed: 0 additions & 159 deletions
This file was deleted.

lib/instances/windows_networking_integration_linux_test.go

Lines changed: 101 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"net"
1010
"os"
1111
"os/exec"
12+
"path/filepath"
1213
"strings"
1314
"testing"
1415
"time"
@@ -22,13 +23,13 @@ import (
2223
"github.com/stretchr/testify/require"
2324
)
2425

25-
func TestWindowsNetworkingIntegration(t *testing.T) {
26-
if os.Getenv("HYPEMAN_RUN_WINDOWS_NETWORKING_INTEGRATION") != "1" {
26+
func TestWindowsLifecycleIntegration(t *testing.T) {
27+
if os.Getenv("HYPEMAN_RUN_WINDOWS_LIFECYCLE_INTEGRATION") != "1" {
2728
t.Skip("run by the dedicated Windows networking CI gate")
2829
}
29-
fixture := os.Getenv("HYPEMAN_WINDOWS_TEST_AGENT_PERSONA")
30+
fixture := os.Getenv("HYPEMAN_WINDOWS_TEST_AGENT_IMAGE")
3031
if fixture == "" {
31-
fixture = "/ci/windows/persona-agent.qcow2"
32+
fixture = "/ci/windows/image-agent.qcow2"
3233
}
3334
if _, err := os.Stat(fixture); err != nil {
3435
if os.Getenv("CI") == "true" {
@@ -42,23 +43,23 @@ func TestWindowsNetworkingIntegration(t *testing.T) {
4243
p := paths.New(dataDir)
4344
const digestHex = "abababababababababababababababababababababababababababababababab"
4445
image := &images.Image{
45-
Name: "registry.example/windows/persona:networking-integration",
46+
Name: "registry.example/windows/image:networking-integration",
4647
Digest: "sha256:" + digestHex,
4748
Platform: "windows/amd64",
4849
Status: images.StatusReady,
4950
Machine: &images.MachineImage{
50-
Kind: images.MachineImageWindowsPersona,
51+
Kind: images.MachineImageWindowsImage,
5152
Base: "registry.example/windows/base@sha256:cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd",
5253
TPM: "2.0",
5354
SecureBoot: "required",
5455
VirtualSize: 80 << 30,
5556
},
5657
}
5758
manager.imageManager = windowsFixtureImageManager{image: image}
58-
personaPath, err := images.GetMachineDiskPath(p, image.Name, image.Digest, image.Machine)
59+
imagePath, err := images.GetMachineDiskPath(p, image.Name, image.Digest, image.Machine)
5960
require.NoError(t, err)
60-
require.NoError(t, forkvm.CopyRegularFile(fixture, personaPath))
61-
require.NoError(t, os.Chmod(personaPath, 0444))
61+
require.NoError(t, forkvm.CopyRegularFile(fixture, imagePath))
62+
require.NoError(t, os.Chmod(imagePath, 0444))
6263

6364
ctx := context.Background()
6465
instance, err := manager.CreateInstance(ctx, CreateInstanceRequest{
@@ -74,17 +75,103 @@ func TestWindowsNetworkingIntegration(t *testing.T) {
7475
t.Cleanup(func() { _ = deleteTestInstanceNow(context.Background(), manager, instance.Id) })
7576
require.NotEmpty(t, instance.IP)
7677
require.NotEmpty(t, instance.MAC)
78+
require.Eventually(t, func() bool {
79+
current, err := manager.GetInstance(ctx, instance.Id)
80+
return err == nil && current.State == StateRunning
81+
}, 4*time.Minute, time.Second)
7782

83+
assertWindowsGuestControl(t, ctx, manager, instance.Id)
7884
assertWindowsNetworkReady(t, ctx, manager, instance.Id, instance.IP)
7985
}
8086

81-
func assertWindowsNetworkReady(t *testing.T, ctx context.Context, manager *manager, instanceID, expectedIP string) {
87+
func assertWindowsGuestControl(t *testing.T, ctx context.Context, manager *manager, instanceID string) {
8288
t.Helper()
83-
require.Eventually(t, func() bool {
84-
current, err := manager.GetInstance(ctx, instanceID)
85-
return err == nil && current.State == StateRunning
86-
}, 4*time.Minute, time.Second)
89+
dialer, err := manager.GetVsockDialer(ctx, instanceID)
90+
require.NoError(t, err)
91+
92+
var stdout, stderr bytes.Buffer
93+
jobStart := time.Now()
94+
exit, err := guest.ExecIntoInstance(ctx, dialer, guest.ExecOptions{
95+
Command: []string{"powershell.exe", "-NoProfile", "-NonInteractive", "-Command", `Copy-Item "$env:SystemRoot\System32\ping.exe" "$env:TEMP\hypeman-job-child.exe" -Force; & "$env:TEMP\hypeman-job-child.exe" -n 60 127.0.0.1`},
96+
Stdout: &stdout,
97+
Stderr: &stderr,
98+
Timeout: 2,
99+
})
100+
require.NoError(t, err, stderr.String())
101+
assert.Less(t, time.Since(jobStart), 10*time.Second)
102+
103+
time.Sleep(5 * time.Second)
104+
stdout.Reset()
105+
stderr.Reset()
106+
exit, err = guest.ExecIntoInstance(ctx, dialer, guest.ExecOptions{
107+
Command: []string{"powershell.exe", "-NoProfile", "-NonInteractive", "-Command", `if (Get-Process hypeman-job-child -ErrorAction SilentlyContinue) { exit 42 }`},
108+
Stdout: &stdout,
109+
Stderr: &stderr,
110+
Timeout: 15,
111+
})
112+
require.NoError(t, err, stderr.String())
113+
require.Equal(t, 0, exit.Code, "job object left a child process running")
114+
115+
stdout.Reset()
116+
stderr.Reset()
117+
exit, err = guest.ExecIntoInstance(ctx, dialer, guest.ExecOptions{
118+
Command: []string{"powershell.exe", "-NoProfile", "-NonInteractive", "-Command", "[Console]::Out.Write('HYPEMAN_SYSTEM_OK')"},
119+
Stdout: &stdout,
120+
Stderr: &stderr,
121+
Timeout: 30,
122+
})
123+
require.NoError(t, err, stderr.String())
124+
require.Equal(t, 0, exit.Code)
125+
assert.Equal(t, "HYPEMAN_SYSTEM_OK", stdout.String())
126+
127+
stdout.Reset()
128+
stderr.Reset()
129+
resizes := make(chan *guest.WindowSize, 1)
130+
resizes <- &guest.WindowSize{Rows: 37, Cols: 101}
131+
close(resizes)
132+
exit, err = guest.ExecIntoInstance(ctx, dialer, guest.ExecOptions{
133+
Command: []string{"cmd.exe", "/d", "/c", "ping -n 2 127.0.0.1 >nul & echo HYPEMAN_CONPTY_OK"},
134+
Stdout: &stdout,
135+
Stderr: &stderr,
136+
TTY: true,
137+
Rows: 31,
138+
Cols: 97,
139+
ResizeChan: resizes,
140+
Timeout: 30,
141+
})
142+
require.NoError(t, err, stderr.String())
143+
require.Equal(t, 0, exit.Code)
144+
assert.Contains(t, stdout.String(), "HYPEMAN_CONPTY_OK")
87145

146+
stdout.Reset()
147+
exit, err = guest.ExecIntoInstance(ctx, dialer, guest.ExecOptions{
148+
Command: []string{"cmd.exe", "/d", "/c", "echo", "HYPEMAN_DESKTOP_OK"},
149+
Stdout: &stdout,
150+
Session: guest.ExecSession_EXEC_SESSION_DESKTOP,
151+
Timeout: 30,
152+
})
153+
require.NoError(t, err)
154+
require.Equal(t, 0, exit.Code)
155+
assert.Contains(t, stdout.String(), "HYPEMAN_DESKTOP_OK")
156+
157+
source := filepath.Join(t.TempDir(), "roundtrip.txt")
158+
require.NoError(t, os.WriteFile(source, []byte("HYPEMAN_COPY_OK"), 0644))
159+
require.NoError(t, guest.CopyToInstance(ctx, dialer, guest.CopyToInstanceOptions{
160+
SrcPath: source,
161+
DstPath: `C:\ProgramData\Hypeman\roundtrip.txt`,
162+
}))
163+
destination := t.TempDir()
164+
require.NoError(t, guest.CopyFromInstance(ctx, dialer, guest.CopyFromInstanceOptions{
165+
SrcPath: `C:\ProgramData\Hypeman\roundtrip.txt`,
166+
DstPath: destination,
167+
}))
168+
contents, err := os.ReadFile(filepath.Join(destination, "roundtrip.txt"))
169+
require.NoError(t, err)
170+
assert.Equal(t, "HYPEMAN_COPY_OK", string(contents))
171+
}
172+
173+
func assertWindowsNetworkReady(t *testing.T, ctx context.Context, manager *manager, instanceID, expectedIP string) {
174+
t.Helper()
88175
dialer, err := manager.GetVsockDialer(ctx, instanceID)
89176
require.NoError(t, err)
90177
allocation, err := manager.networkManager.GetAllocation(ctx, instanceID)

0 commit comments

Comments
 (0)