Skip to content

Commit 8c18bc9

Browse files
committed
Consolidate Windows snapshot coverage
1 parent eee1032 commit 8c18bc9

7 files changed

Lines changed: 156 additions & 256 deletions

File tree

.github/workflows/test.yml

Lines changed: 13 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -150,29 +150,22 @@ jobs:
150150
done
151151
exit 1
152152
153-
- name: Test Windows snapshots and forks
153+
- name: Test Windows stopped forks
154154
run: |
155155
TEST_PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH"
156-
for test_name in \
157-
TestWindowsStandbyRestoreIntegration \
158-
TestWindowsStandbyForkIntegration \
159-
TestWindowsForkIntegration; do
160-
passed=false
161-
for attempt in 1 2 3; do
162-
if sudo env \
163-
"PATH=$TEST_PATH" \
164-
"CI=true" \
165-
"HYPEMAN_RUN_WINDOWS_SNAPSHOT_INTEGRATION=1" \
166-
"HYPEMAN_WINDOWS_OVMF_CODE=$HYPEMAN_WINDOWS_OVMF_CODE" \
167-
"HYPEMAN_WINDOWS_OVMF_VARS=$HYPEMAN_WINDOWS_OVMF_VARS" \
168-
go test -count=1 -run "^${test_name}$" -timeout 2m ./lib/instances; then
169-
passed=true
170-
break
171-
fi
172-
test "$attempt" = 3 || sleep 5
173-
done
174-
test "$passed" = true || exit 1
156+
for attempt in 1 2 3; do
157+
if sudo env \
158+
"PATH=$TEST_PATH" \
159+
"CI=true" \
160+
"HYPEMAN_RUN_WINDOWS_LIFECYCLE_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 '^TestWindowsStoppedForkIntegration$' -timeout 2m ./lib/instances; then
164+
exit 0
165+
fi
166+
test "$attempt" = 3 || sleep 5
175167
done
168+
exit 1
176169
177170
# Slash-command runs are maintainer-approved and need authenticated pulls
178171
# for images that are not covered by the prewarm cache.

docs/windows-images.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ A machine image uses these OCI config labels:
1313
| `io.hypeman.machine-image.base` | omitted | digest-pinned base reference |
1414
| `io.hypeman.machine-image.tpm` | `2.0` | `2.0` |
1515
| `io.hypeman.machine-image.secure-boot` | `required` | `required` |
16-
| `io.hypeman.machine-image.bitlocker` | omitted | `disabled` for forkable personas; `reseal-required` otherwise |
16+
| `io.hypeman.machine-image.bitlocker` | omitted | `disabled` for forkable images; `reseal-required` otherwise |
1717

1818
The base must be pulled before its dependent Windows images. A base cannot be deleted while any cached image references its digest. Instance references are not tracked by the image cache, matching existing Linux behavior: do not delete a base while a dependent Windows instance exists.
1919

docs/windows-snapshots.md

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,12 @@ A fork receives independent disk and NVRAM files. A stopped fork removes the cop
1212

1313
The Windows guest agent writes a new `MachineGuid` and records the child instance ID before the child is returned. Memory forks retain the source SID and hostname, and services that cached `MachineGuid` before standby may observe the previous value until the next cold boot.
1414

15-
Fork admission requires the persona OCI label:
15+
Fork admission requires the image OCI label:
1616

1717
```text
1818
io.hypeman.machine-image.bitlocker=disabled
1919
```
2020

21-
Personas marked `reseal-required`, unlabeled personas, and unknown policies can still use same-instance snapshots, but cannot be forked. Hypeman does not expose a child whose encrypted disk was cloned without resealing it to the child's TPM.
21+
Images marked `reseal-required`, unlabeled images, and unknown policies can still use same-instance snapshots, but cannot be forked. Hypeman does not expose a child whose encrypted disk was cloned without resealing it to the child's TPM.
2222

2323
Stopped forks cold-boot with a unique vsock CID and can run concurrently. A standby snapshot contains the Windows VioSock driver's current CID in guest memory, so a memory-restored child initially retains that CID. The source and child must not be restored concurrently until the child has been stopped and cold-started; Hypeman reports a state error instead of allowing QEMU to fail with a CID collision. Creating a running fork directly from a running Windows source therefore requires `target_state=Stopped`.
24-
25-
## Integration gates
26-
27-
- `TestWindowsStandbyRestoreIntegration` verifies identity-preserving standby/restore and the stopped snapshot restore/fork APIs.
28-
- `TestWindowsStandbyForkIntegration` verifies a captured desktop memory fork, inherited guest and TPM endorsement-key state, a fresh `MachineGuid`, and independent NVRAM/disk files.
29-
- `TestWindowsForkIntegration` verifies independent guest writes and a fresh TPM endorsement key for cold-booted stopped forks.
30-
31-
The private Windows fixture and its license are not stored in this repository.

lib/instances/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@ Windows uses the same host-side TAP allocation as Linux. Once the guest agent is
3636

3737
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.
3838

39+
### Windows snapshots and forks
40+
41+
A Windows machine consists of its writable qcow2 disk, Secure Boot NVRAM, software TPM state, saved QEMU configuration, and—while in standby—memory and device state. Same-instance restore keeps these components together so Windows identity and TPM state remain stable.
42+
43+
Forking separates identity according to the boot path. A stopped fork receives independent disk and NVRAM files, clears copied TPM state, cold-boots with a new VioSock CID, and asks the guest agent to assign a new `MachineGuid`. A memory fork must retain the captured TPM and VioSock state from QEMU's migration stream; it receives independent files and a new `MachineGuid`, but inherits the TPM endorsement key until its next cold boot. The source and memory-restored child cannot run concurrently with the same captured CID.
44+
45+
Fork admission requires the image to declare `io.hypeman.machine-image.bitlocker=disabled`. Other policies remain valid for same-instance snapshots but are rejected for forks because Hypeman does not reseal BitLocker keys to a child TPM.
46+
3947
### Why Config Disk? (configdisk.go)
4048

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

lib/instances/windows.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ func validateWindowsCreate(req CreateInstanceRequest, image *images.Image, caps
5757

5858
func validateWindowsForkPolicy(stored *StoredMetadata) error {
5959
if stored != nil && isWindowsPlatform(stored.Platform) && stored.WindowsBitLockerPolicy != "disabled" {
60-
return fmt.Errorf("%w: Windows forks require a persona declared with %s=disabled", ErrNotSupported, images.MachineImageBitLockerLabel)
60+
return fmt.Errorf("%w: Windows forks require an image declared with %s=disabled", ErrNotSupported, images.MachineImageBitLockerLabel)
6161
}
6262
return nil
6363
}

lib/instances/windows_networking_integration_linux_test.go renamed to lib/instances/windows_lifecycle_integration_linux_test.go

Lines changed: 131 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,26 +24,97 @@ import (
2424
)
2525

2626
func TestWindowsLifecycleIntegration(t *testing.T) {
27+
manager, p, image := setupWindowsLifecycleIntegration(t)
28+
ctx := context.Background()
29+
source := createWindowsLifecycleInstance(t, ctx, manager, image, "windows-lifecycle-source", true)
30+
31+
assertWindowsGuestControl(t, ctx, manager, source.Id)
32+
assertWindowsNetworkReady(t, ctx, manager, source.Id, source.IP)
33+
sourceMachineID := windowsMachineID(t, ctx, manager, source.Id)
34+
sourceTPMEK := windowsTPMEK(t, ctx, manager, source.Id)
35+
windowsPowerShell(t, ctx, manager, source.Id, `New-Item -ItemType Directory -Force C:\ProgramData\Hypeman | Out-Null; Set-Content C:\ProgramData\Hypeman\standby.txt inherited`)
36+
37+
standby, err := manager.StandbyInstance(ctx, source.Id, StandbyInstanceRequest{})
38+
require.NoError(t, err)
39+
require.Equal(t, StateStandby, standby.State)
40+
forked, err := manager.ForkInstance(ctx, source.Id, ForkInstanceRequest{
41+
Name: "windows-standby-child",
42+
TargetState: StateRunning,
43+
})
44+
require.NoError(t, err)
45+
t.Cleanup(func() { _ = deleteTestInstanceNow(context.Background(), manager, forked.Id) })
46+
assert.Equal(t, source.VsockCID, forked.VsockCID, "memory-restored Windows forks retain the captured VioSock CID until cold boot")
47+
assert.NotEqual(t, sourceMachineID, windowsMachineID(t, ctx, manager, forked.Id))
48+
assert.Equal(t, sourceTPMEK, windowsTPMEK(t, ctx, manager, forked.Id), "memory forks inherit TPM state from the QEMU migration stream")
49+
assert.Equal(t, "inherited", windowsPowerShell(t, ctx, manager, forked.Id, `Get-Content C:\ProgramData\Hypeman\standby.txt`))
50+
assertIndependentFile(t, p.InstanceOVMFVars(source.Id), p.InstanceOVMFVars(forked.Id))
51+
assertIndependentFile(t, p.InstanceWindowsDisk(source.Id), p.InstanceWindowsDisk(forked.Id))
52+
53+
stoppedFork, err := manager.StopInstance(ctx, forked.Id)
54+
require.NoError(t, err)
55+
require.Equal(t, StateStopped, stoppedFork.State)
56+
restored, err := manager.RestoreInstance(ctx, source.Id)
57+
require.NoError(t, err)
58+
waitForWindowsRunning(t, ctx, manager, restored.Id)
59+
assert.Equal(t, sourceMachineID, windowsMachineID(t, ctx, manager, restored.Id), "same-instance restore must preserve Windows identity")
60+
assertWindowsNetworkReady(t, ctx, manager, restored.Id, source.IP)
61+
}
62+
63+
func TestWindowsStoppedForkIntegration(t *testing.T) {
64+
manager, p, image := setupWindowsLifecycleIntegration(t)
65+
ctx := context.Background()
66+
source := createWindowsLifecycleInstance(t, ctx, manager, image, "windows-stopped-fork-source", false)
67+
sourceMachineID := windowsMachineID(t, ctx, manager, source.Id)
68+
sourceTPMEK := windowsTPMEK(t, ctx, manager, source.Id)
69+
windowsPowerShell(t, ctx, manager, source.Id, `New-Item -ItemType Directory -Force C:\ProgramData\Hypeman | Out-Null; Set-Content C:\ProgramData\Hypeman\identity.txt source`)
70+
71+
stopped, err := manager.StopInstance(ctx, source.Id)
72+
require.NoError(t, err)
73+
require.Equal(t, StateStopped, stopped.State)
74+
forked, err := manager.ForkInstance(ctx, source.Id, ForkInstanceRequest{
75+
Name: "windows-stopped-child",
76+
TargetState: StateRunning,
77+
})
78+
require.NoError(t, err)
79+
t.Cleanup(func() { _ = deleteTestInstanceNow(context.Background(), manager, forked.Id) })
80+
assert.NotEqual(t, sourceMachineID, windowsMachineID(t, ctx, manager, forked.Id), "fork must receive a new Windows machine identity")
81+
assert.NotEqual(t, sourceTPMEK, windowsTPMEK(t, ctx, manager, forked.Id), "cold forks must initialize a fresh TPM")
82+
assert.NotEqual(t, source.VsockCID, forked.VsockCID, "forks need unique vsock CIDs")
83+
assertIndependentFile(t, p.InstanceOVMFVars(source.Id), p.InstanceOVMFVars(forked.Id))
84+
assertIndependentFile(t, p.InstanceWindowsDisk(source.Id), p.InstanceWindowsDisk(forked.Id))
85+
require.DirExists(t, p.InstanceTPMDir(forked.Id))
86+
87+
sourceDiskBefore, err := os.Stat(p.InstanceWindowsDisk(source.Id))
88+
require.NoError(t, err)
89+
windowsPowerShell(t, ctx, manager, forked.Id, `Set-Content C:\ProgramData\Hypeman\identity.txt child`)
90+
assert.Equal(t, "child", windowsPowerShell(t, ctx, manager, forked.Id, `Get-Content C:\ProgramData\Hypeman\identity.txt`))
91+
sourceDiskAfter, err := os.Stat(p.InstanceWindowsDisk(source.Id))
92+
require.NoError(t, err)
93+
assert.Equal(t, sourceDiskBefore.ModTime(), sourceDiskAfter.ModTime(), "child writes must not modify the stopped source disk")
94+
}
95+
96+
func setupWindowsLifecycleIntegration(t *testing.T) (*manager, *paths.Paths, *images.Image) {
97+
t.Helper()
2798
if os.Getenv("HYPEMAN_RUN_WINDOWS_LIFECYCLE_INTEGRATION") != "1" {
28-
t.Skip("run by the dedicated Windows networking CI gate")
99+
t.Skip("run by the dedicated Windows lifecycle CI gates")
29100
}
30101
fixture := os.Getenv("HYPEMAN_WINDOWS_TEST_AGENT_IMAGE")
31102
if fixture == "" {
32103
fixture = "/ci/windows/image-agent.qcow2"
33104
}
34105
if _, err := os.Stat(fixture); err != nil {
35106
if os.Getenv("CI") == "true" {
36-
t.Fatalf("required Windows networking fixture is missing: %s", fixture)
107+
t.Fatalf("required Windows lifecycle fixture is missing: %s", fixture)
37108
}
38-
t.Skipf("Windows networking fixture is unavailable: %s", fixture)
109+
t.Skipf("Windows lifecycle fixture is unavailable: %s", fixture)
39110
}
40111
acquireHeavyIO(t)
41112

42113
manager, dataDir := setupTestManagerForQEMU(t)
43114
p := paths.New(dataDir)
44-
const digestHex = "abababababababababababababababababababababababababababababababab"
115+
const digestHex = "acacacacacacacacacacacacacacacacacacacacacacacacacacacacacacacac"
45116
image := &images.Image{
46-
Name: "registry.example/windows/image:networking-integration",
117+
Name: "registry.example/windows/image:lifecycle-integration",
47118
Digest: "sha256:" + digestHex,
48119
Platform: "windows/amd64",
49120
Status: images.StatusReady,
@@ -52,6 +123,7 @@ func TestWindowsLifecycleIntegration(t *testing.T) {
52123
Base: "registry.example/windows/base@sha256:cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd",
53124
TPM: "2.0",
54125
SecureBoot: "required",
126+
BitLocker: "disabled",
55127
VirtualSize: 80 << 30,
56128
},
57129
}
@@ -60,28 +132,32 @@ func TestWindowsLifecycleIntegration(t *testing.T) {
60132
require.NoError(t, err)
61133
require.NoError(t, forkvm.CopyRegularFile(fixture, imagePath))
62134
require.NoError(t, os.Chmod(imagePath, 0444))
135+
return manager, p, image
136+
}
63137

64-
ctx := context.Background()
138+
func createWindowsLifecycleInstance(t *testing.T, ctx context.Context, manager *manager, image *images.Image, name string, networkEnabled bool) *Instance {
139+
t.Helper()
65140
instance, err := manager.CreateInstance(ctx, CreateInstanceRequest{
66-
Name: "windows-networking-integration",
141+
Name: name,
67142
Image: image.Name,
68143
Platform: "windows/amd64",
69-
Size: 8 << 30,
144+
Size: 4 << 30,
70145
Vcpus: 4,
71-
NetworkEnabled: true,
146+
NetworkEnabled: networkEnabled,
72147
Hypervisor: hypervisor.TypeQEMU,
73148
})
74149
require.NoError(t, err)
75150
t.Cleanup(func() { _ = deleteTestInstanceNow(context.Background(), manager, instance.Id) })
76-
require.NotEmpty(t, instance.IP)
77-
require.NotEmpty(t, instance.MAC)
151+
waitForWindowsRunning(t, ctx, manager, instance.Id)
152+
return instance
153+
}
154+
155+
func waitForWindowsRunning(t *testing.T, ctx context.Context, manager *manager, instanceID string) {
156+
t.Helper()
78157
require.Eventually(t, func() bool {
79-
current, err := manager.GetInstance(ctx, instance.Id)
158+
current, err := manager.GetInstance(ctx, instanceID)
80159
return err == nil && current.State == StateRunning
81-
}, 4*time.Minute, time.Second)
82-
83-
assertWindowsGuestControl(t, ctx, manager, instance.Id)
84-
assertWindowsNetworkReady(t, ctx, manager, instance.Id, instance.IP)
160+
}, 75*time.Second, 500*time.Millisecond)
85161
}
86162

87163
func assertWindowsGuestControl(t *testing.T, ctx context.Context, manager *manager, instanceID string) {
@@ -100,7 +176,7 @@ func assertWindowsGuestControl(t *testing.T, ctx context.Context, manager *manag
100176
require.NoError(t, err, stderr.String())
101177
assert.Less(t, time.Since(jobStart), 10*time.Second)
102178

103-
time.Sleep(5 * time.Second)
179+
time.Sleep(time.Second)
104180
stdout.Reset()
105181
stderr.Reset()
106182
exit, err = guest.ExecIntoInstance(ctx, dialer, guest.ExecOptions{
@@ -201,3 +277,41 @@ func assertWindowsNetworkReady(t *testing.T, ctx context.Context, manager *manag
201277
ping := exec.Command("ping", "-c", "3", "-W", "2", expectedIP)
202278
require.NoError(t, ping.Run(), "allocated Windows IP did not answer ICMP")
203279
}
280+
281+
func windowsMachineID(t *testing.T, ctx context.Context, manager *manager, instanceID string) string {
282+
t.Helper()
283+
return windowsPowerShell(t, ctx, manager, instanceID, `(Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Cryptography').MachineGuid`)
284+
}
285+
286+
func windowsTPMEK(t *testing.T, ctx context.Context, manager *manager, instanceID string) string {
287+
t.Helper()
288+
hash := windowsPowerShell(t, ctx, manager, instanceID, `(Get-TpmEndorsementKeyInfo -HashAlgorithm Sha256).PublicKeyHash`)
289+
require.NotEmpty(t, hash, "TPM endorsement key hash")
290+
return hash
291+
}
292+
293+
func windowsPowerShell(t *testing.T, ctx context.Context, manager *manager, instanceID, command string) string {
294+
t.Helper()
295+
dialer, err := manager.GetVsockDialer(ctx, instanceID)
296+
require.NoError(t, err)
297+
var stdout, stderr bytes.Buffer
298+
exit, err := guest.ExecIntoInstance(ctx, dialer, guest.ExecOptions{
299+
Command: []string{"powershell.exe", "-NoProfile", "-NonInteractive", "-Command", command},
300+
Stdout: &stdout,
301+
Stderr: &stderr,
302+
WaitForAgent: 30 * time.Second,
303+
Timeout: 30,
304+
})
305+
require.NoError(t, err, stderr.String())
306+
require.Equal(t, 0, exit.Code, stderr.String())
307+
return string(bytes.TrimSpace(stdout.Bytes()))
308+
}
309+
310+
func assertIndependentFile(t *testing.T, source, fork string) {
311+
t.Helper()
312+
sourceInfo, err := os.Stat(source)
313+
require.NoError(t, err)
314+
forkInfo, err := os.Stat(fork)
315+
require.NoError(t, err)
316+
assert.False(t, os.SameFile(sourceInfo, forkInfo), "%s and %s must not share an inode", source, fork)
317+
}

0 commit comments

Comments
 (0)