Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
17 changes: 17 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,23 @@ jobs:
done
exit 1

- name: Test Windows stopped forks
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_LIFECYCLE_INTEGRATION=1" \
"HYPEMAN_WINDOWS_OVMF_CODE=$HYPEMAN_WINDOWS_OVMF_CODE" \
"HYPEMAN_WINDOWS_OVMF_VARS=$HYPEMAN_WINDOWS_OVMF_VARS" \
go test -count=1 -run '^TestWindowsStoppedForkIntegration$' -timeout 2m ./lib/instances; then
exit 0
fi
test "$attempt" = 3 || sleep 5
done
exit 1

# Slash-command runs are maintainer-approved and need authenticated pulls
# for images that are not covered by the prewarm cache.
- name: Login to Docker Hub
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 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`.
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;
}
40 changes: 40 additions & 0 deletions lib/guest/guest_grpc.pb.go

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

Loading
Loading