Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e8a1bad
Add Windows snapshots and forks
sjmiller609 Aug 20, 2026
716aeb6
Document TPM identity for Windows memory forks
sjmiller609 Aug 20, 2026
cb78539
Isolate the Windows snapshots CI gates
sjmiller609 Aug 20, 2026
9fe5fdc
Consolidate Windows snapshot coverage
sjmiller609 Aug 20, 2026
7df3857
Protect Windows fork identity invariants
sjmiller609 Aug 25, 2026
4aa7063
Keep Windows copy coverage at lifecycle end
sjmiller609 Aug 25, 2026
87db7de
Use default Windows memory for lifecycle coverage
sjmiller609 Aug 25, 2026
a17d4f8
Run guest-control checks after snapshot lifecycle
sjmiller609 Aug 25, 2026
6120167
Keep Windows snapshot scenarios within gate budget
sjmiller609 Aug 25, 2026
22363a2
Preserve Windows memory-fork network identity
sjmiller609 Aug 25, 2026
511faae
Focus Windows lifecycle gate on snapshot invariants
sjmiller609 Aug 25, 2026
3dda4c8
Bound Windows memory-fork integration coverage
sjmiller609 Aug 25, 2026
967b06d
Compare inherited TPM state before memory restore
sjmiller609 Aug 25, 2026
838b70a
Inspect stopped-fork TPM reset before boot
sjmiller609 Aug 25, 2026
6d92d23
Rotate stopped-fork network identity
sjmiller609 Aug 25, 2026
f7ace34
Keep snapshot lifecycle gate off host networking
sjmiller609 Aug 27, 2026
0a1be72
Skip network leases in Windows snapshot gates
sjmiller609 Aug 27, 2026
3fc9a65
Clean Windows gate processes between attempts
sjmiller609 Aug 27, 2026
09ce3d1
Keep Windows test socket paths short
sjmiller609 Aug 27, 2026
97e8bad
Keep test bridge identity tied to subnet
sjmiller609 Aug 27, 2026
8080100
Preserve live test bridges during stale cleanup
sjmiller609 Aug 27, 2026
519bb45
Warm QEMU before the primitive gate
sjmiller609 Aug 27, 2026
5d22773
Track Windows scratch directories for job cleanup
sjmiller609 Aug 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/scripts/run-windows-integration-test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail

if (( $# != 3 )); then
echo "usage: $0 <gate-env> <test-name> <package>" >&2
exit 2
fi

gate_env=$1
test_name=$2
package=$3
run_prefix="ci-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
tmpdir=$(sudo mktemp -d /ci/wXXXXXX)
Comment thread
sjmiller609 marked this conversation as resolved.
echo "WINDOWS_TEST_TMPDIR=$tmpdir" >> "$GITHUB_ENV"
test_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH"

cleanup() {
local pids=()
local pid
while read -r pid; do
if sudo grep -zFqx "HYPEMAN_UFFD_SYSTEMD_INSTANCE_PREFIX=$run_prefix" "/proc/$pid/environ" 2>/dev/null; then
pids+=("$pid")
fi
done < <(pgrep -x 'qemu-system-.*|swtpm' || true)

if (( ${#pids[@]} > 0 )); then
sudo kill -TERM "${pids[@]}" 2>/dev/null || true
sleep 2
for pid in "${pids[@]}"; do
if sudo kill -0 "$pid" 2>/dev/null; then
sudo kill -KILL "$pid" 2>/dev/null || true
fi
done
fi
sudo rm -rf "$tmpdir"
}
trap cleanup EXIT

for attempt in 1 2 3; do
cleanup
sudo install -d -m 1777 "$tmpdir"
if sudo env \
"PATH=$test_path" \
"TMPDIR=$tmpdir" \
Comment thread
cursor[bot] marked this conversation as resolved.
"CI=true" \
"HYPEMAN_UFFD_SYSTEMD_INSTANCE_PREFIX=$run_prefix" \
"$gate_env=1" \
"HYPEMAN_WINDOWS_OVMF_CODE=$HYPEMAN_WINDOWS_OVMF_CODE" \
"HYPEMAN_WINDOWS_OVMF_VARS=$HYPEMAN_WINDOWS_OVMF_VARS" \
go test -count=1 -run "^${test_name}$" -timeout 2m "$package"; then
exit 0
fi
cleanup
test "$attempt" = 3 || sleep 5
done
exit 1
41 changes: 9 additions & 32 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -124,40 +124,16 @@ jobs:
test -f "$HYPEMAN_WINDOWS_OVMF_VARS"
test -r /ci/windows/image-agent.qcow2

- name: Test Windows hypervisor primitives
run: |
TEST_PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH"
for attempt in 1 2 3; do
if sudo env \
"PATH=$TEST_PATH" \
"CI=true" \
"HYPEMAN_RUN_WINDOWS_CONFIG_INTEGRATION=1" \
"HYPEMAN_WINDOWS_OVMF_CODE=$HYPEMAN_WINDOWS_OVMF_CODE" \
"HYPEMAN_WINDOWS_OVMF_VARS=$HYPEMAN_WINDOWS_OVMF_VARS" \
go test -count=1 -run '^TestWindowsConfigIntegration$' -timeout 2m ./lib/hypervisor/qemu; then
exit 0
fi
test "$attempt" = 3 || sleep 5
done
exit 1

- name: Test Windows lifecycle
run: |
make build-embedded
TEST_PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH"
for attempt in 1 2 3; do
if sudo env \
"PATH=$TEST_PATH" \
"CI=true" \
"HYPEMAN_RUN_WINDOWS_LIFECYCLE_INTEGRATION=1" \
"HYPEMAN_WINDOWS_OVMF_CODE=$HYPEMAN_WINDOWS_OVMF_CODE" \
"HYPEMAN_WINDOWS_OVMF_VARS=$HYPEMAN_WINDOWS_OVMF_VARS" \
go test -count=1 -run '^TestWindowsLifecycleIntegration$' -timeout 2m ./lib/instances; then
exit 0
fi
test "$attempt" = 3 || sleep 5
done
exit 1
bash .github/scripts/run-windows-integration-test.sh HYPEMAN_RUN_WINDOWS_LIFECYCLE_INTEGRATION TestWindowsLifecycleIntegration ./lib/instances

- name: Test Windows stopped forks
run: bash .github/scripts/run-windows-integration-test.sh HYPEMAN_RUN_WINDOWS_LIFECYCLE_INTEGRATION TestWindowsStoppedForkIntegration ./lib/instances

- name: Test Windows hypervisor primitives
run: bash .github/scripts/run-windows-integration-test.sh HYPEMAN_RUN_WINDOWS_CONFIG_INTEGRATION TestWindowsConfigIntegration ./lib/hypervisor/qemu

# Slash-command runs are maintainer-approved and need authenticated pulls
# for images that are not covered by the prewarm cache.
Expand Down Expand Up @@ -307,7 +283,7 @@ jobs:

run_prefix="ci-${{ github.run_id }}-${{ github.run_attempt }}"
guest_pids=()
mapfile -t vmm_pids < <(pgrep -x 'cloud-hyperviso|firecracker|qemu-system-.*' || true)
mapfile -t vmm_pids < <(pgrep -x 'cloud-hyperviso|firecracker|qemu-system-.*|swtpm' || true)
for pid in "${vmm_pids[@]}"; do
proc="/proc/$pid"
cmdline="$(cat "$proc/cmdline" 2>/dev/null | tr '\0' ' ' || true)"
Expand All @@ -333,6 +309,7 @@ jobs:
echo "$units" | xargs -r sudo systemctl reset-failed || true
fi
sudo rm -f /run/hypeman/uffd/ci-${{ github.run_id }}-${{ github.run_attempt }}-*.env
sudo rm -rf -- "${WINDOWS_TEST_TMPDIR:-/ci/no-windows-test-tmpdir}"
rm -f "${{ runner.temp }}/hypeman-uffd-pager-${{ github.run_id }}-${{ github.run_attempt }}"

test-darwin:
Expand Down
1 change: 1 addition & 0 deletions docs/windows-images.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ A machine image uses these OCI config labels:
| `io.hypeman.machine-image.base` | omitted | digest-pinned base reference |
| `io.hypeman.machine-image.tpm` | `2.0` | `2.0` |
| `io.hypeman.machine-image.secure-boot` | `required` | `required` |
| `io.hypeman.machine-image.bitlocker` | omitted | `disabled` for forkable images; `reseal-required` otherwise |

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.

Expand Down
23 changes: 23 additions & 0 deletions docs/windows-snapshots.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Windows snapshots and forks

Windows 11 QEMU instances support standby, restore, stopped snapshots, and forks. Snapshot payloads treat the writable qcow2 disk, Secure Boot NVRAM, software TPM state, saved QEMU configuration, and memory image as one machine.

## Same-instance standby and restore

Standby pauses QEMU, captures memory and device state, stops QEMU and swtpm, and retains the instance disk, NVRAM, and TPM directory. Restore starts swtpm from that same state before loading QEMU memory. The Windows machine identity and TPM remain unchanged.

## Fork identity

A fork receives independent disk and NVRAM files. A stopped fork removes the copied TPM state before cold boot, so swtpm initializes a new endorsement key and TPM identity. A memory fork retains the parent's TPM identity because QEMU includes the TPM's permanent and volatile state in its migration stream. Workloads that depend on unique TPM attestation must use stopped forks.

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.

Fork admission requires the image OCI label:

```text
io.hypeman.machine-image.bitlocker=disabled
```

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.

Stopped forks cold-boot with a unique vsock CID and can run concurrently. A standby snapshot contains the Windows VioSock CID and NIC identity in guest memory, so a memory-restored child initially retains both. 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 a host-device collision. Creating a running fork directly from a running Windows source therefore requires `target_state=Stopped`.
26 changes: 26 additions & 0 deletions lib/guest/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -942,6 +942,32 @@ func CopyFromInstance(ctx context.Context, dialer hypervisor.VsockDialer, opts C
return nil
}

func RebindInstanceIdentity(ctx context.Context, dialer hypervisor.VsockDialer, instanceID string, waitForAgent time.Duration) (string, error) {
deadline := time.Now().Add(waitForAgent)
for {
conn, err := GetOrCreateConn(ctx, dialer)
if err == nil {
attemptCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
resp, rpcErr := NewGuestServiceClient(conn).RebindIdentity(attemptCtx, &RebindIdentityRequest{InstanceId: instanceID})
cancel()
if rpcErr == nil {
return resp.MachineId, nil
}
err = fmt.Errorf("rebind guest identity: %w", rpcErr)
}
retryable := isRetryableConnectionError(err) || status.Code(err) == codes.DeadlineExceeded
if !retryable || waitForAgent == 0 || time.Now().After(deadline) {
return "", err
}
CloseConn(dialer.Key())
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(guestExecSlowRetryInterval):
}
}
}

// ShutdownInstance sends a shutdown signal to the guest VM's init process (PID 1).
// The guest-agent forwards the signal to init, which forwards it to the entrypoint.
// sig is the signal number to send (0 = SIGTERM default).
Expand Down
129 changes: 114 additions & 15 deletions lib/guest/guest.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions lib/guest/guest.proto
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ service GuestService {

// ReconfigureNetwork updates the guest network identity without spawning shell commands
rpc ReconfigureNetwork(ReconfigureNetworkRequest) returns (ReconfigureNetworkResponse);

// RebindIdentity assigns a forked guest a new machine identity.
rpc RebindIdentity(RebindIdentityRequest) returns (RebindIdentityResponse);
}

// ExecRequest represents messages from client to server
Expand Down Expand Up @@ -176,3 +179,11 @@ message ReconfigureNetworkRequest {

// ReconfigureNetworkResponse acknowledges the network reconfiguration request
message ReconfigureNetworkResponse {}

message RebindIdentityRequest {
string instance_id = 1;
}

message RebindIdentityResponse {
string machine_id = 1;
}
Loading
Loading