From 00831431a4fa50153338441b268bf21b7917150f Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:26:42 +0000 Subject: [PATCH 1/7] Add OCI Windows machine images --- .github/workflows/test.yml | 4 + docs/windows-images.md | 21 ++ lib/images/machine.go | 286 ++++++++++++++++++ lib/images/machine_oci_integration_test.go | 113 +++++++ lib/images/machine_test.go | 188 ++++++++++++ lib/images/manager.go | 46 ++- lib/images/manager_test.go | 2 +- lib/images/platform.go | 26 +- lib/images/platform_test.go | 10 +- lib/images/storage.go | 8 + lib/images/types.go | 1 + lib/instances/create.go | 93 ++++-- lib/instances/create_image.go | 2 +- lib/instances/create_image_test.go | 23 ++ lib/instances/start.go | 28 +- lib/instances/windows.go | 157 ++++++++++ .../windows_images_integration_linux_test.go | 128 ++++++++ lib/instances/windows_test.go | 70 +++++ lib/paths/paths.go | 20 ++ 19 files changed, 1150 insertions(+), 76 deletions(-) create mode 100644 docs/windows-images.md create mode 100644 lib/images/machine.go create mode 100644 lib/images/machine_oci_integration_test.go create mode 100644 lib/images/machine_test.go create mode 100644 lib/instances/windows.go create mode 100644 lib/instances/windows_images_integration_linux_test.go create mode 100644 lib/instances/windows_test.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index caf47c339..4e5988a68 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -113,6 +113,10 @@ jobs: done test -f "$HYPEMAN_WINDOWS_OVMF_CODE" test -f "$HYPEMAN_WINDOWS_OVMF_VARS" + test -r /ci/windows/base.raw + test -r /ci/windows/persona.qcow2 + qemu-img info --output=json /ci/windows/persona.qcow2 \ + | jq -e '.format == "qcow2" and .["backing-filename-format"] == "raw"' >/dev/null - name: Test Windows hypervisor primitives run: | diff --git a/docs/windows-images.md b/docs/windows-images.md new file mode 100644 index 000000000..98ba2b527 --- /dev/null +++ b/docs/windows-images.md @@ -0,0 +1,21 @@ +# Windows machine images + +Hypeman accepts Windows desktop disks as OCI images for `windows/amd64`. Ordinary Windows container images are not bootable and are rejected. + +A machine image uses these OCI config labels: + +| Label | Base | Persona | +|---|---|---| +| `io.hypeman.machine-image.version` | `1` | `1` | +| `io.hypeman.machine-image.kind` | `windows-base` | `windows-persona` | +| `io.hypeman.machine-image.disk-path` | relative path to the source disk | relative path to a qcow2 delta | +| `io.hypeman.machine-image.disk-format` | `raw`, `qcow2`, `vhd`, or `vhdx` | `qcow2` | +| `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` | + +Hypeman materializes the base as immutable sparse raw. It rewrites the persona's qcow2 backing header to the cache-owned base path, ignoring any artifact-supplied backing path. At instance creation, Hypeman reflink-clones the immutable persona into a writable `windows.qcow2`; the clone remains backed directly by the raw base. + +The base must be pulled before its personas. A base cannot be deleted while any cached persona references its digest. + +Windows installation media, activation material, credentials, and generated disks belong in private registries and must not be committed to this repository. diff --git a/lib/images/machine.go b/lib/images/machine.go new file mode 100644 index 000000000..c56a62c5e --- /dev/null +++ b/lib/images/machine.go @@ -0,0 +1,286 @@ +package images + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/kernel/hypeman/lib/forkvm" + "github.com/kernel/hypeman/lib/paths" +) + +const ( + MachineImageVersionLabel = "io.hypeman.machine-image.version" + MachineImageKindLabel = "io.hypeman.machine-image.kind" + MachineImageDiskPathLabel = "io.hypeman.machine-image.disk-path" + MachineImageDiskFormatLabel = "io.hypeman.machine-image.disk-format" + MachineImageBaseLabel = "io.hypeman.machine-image.base" + MachineImageTPMLabel = "io.hypeman.machine-image.tpm" + MachineImageSecureBootLabel = "io.hypeman.machine-image.secure-boot" + + MachineImageVersion = "1" +) + +type MachineImageKind string + +const ( + MachineImageWindowsBase MachineImageKind = "windows-base" + MachineImageWindowsPersona MachineImageKind = "windows-persona" +) + +// MachineImage describes a bootable disk artifact. The OCI manifest remains +// the distribution envelope; this metadata controls materialization. +type MachineImage struct { + Kind MachineImageKind `json:"kind"` + DiskPath string `json:"disk_path"` + DiskFormat string `json:"disk_format"` + Base string `json:"base,omitempty"` + TPM string `json:"tpm"` + SecureBoot string `json:"secure_boot"` + VirtualSize int64 `json:"virtual_size"` +} + +func parseMachineImage(meta *containerMetadata) (*MachineImage, error) { + version := strings.TrimSpace(meta.Labels[MachineImageVersionLabel]) + if version == "" { + if strings.EqualFold(meta.OS, "windows") { + return nil, fmt.Errorf("ordinary Windows container images are not bootable; missing %s", MachineImageVersionLabel) + } + return nil, nil + } + if version != MachineImageVersion { + return nil, fmt.Errorf("unsupported machine image version %q", version) + } + if !strings.EqualFold(meta.OS, "windows") || meta.Architecture != "amd64" { + return nil, fmt.Errorf("machine image requires platform windows/amd64") + } + + machine := &MachineImage{ + Kind: MachineImageKind(strings.TrimSpace(meta.Labels[MachineImageKindLabel])), + DiskPath: strings.TrimSpace(meta.Labels[MachineImageDiskPathLabel]), + DiskFormat: strings.TrimSpace(meta.Labels[MachineImageDiskFormatLabel]), + Base: strings.TrimSpace(meta.Labels[MachineImageBaseLabel]), + TPM: strings.TrimSpace(meta.Labels[MachineImageTPMLabel]), + SecureBoot: strings.TrimSpace(meta.Labels[MachineImageSecureBootLabel]), + } + if machine.DiskPath == "" || filepath.IsAbs(machine.DiskPath) || !filepath.IsLocal(machine.DiskPath) { + return nil, fmt.Errorf("machine image disk path must be a local relative path") + } + if machine.TPM != "2.0" { + return nil, fmt.Errorf("machine image requires TPM 2.0") + } + if machine.SecureBoot != "required" { + return nil, fmt.Errorf("machine image must require Secure Boot") + } + + switch machine.Kind { + case MachineImageWindowsBase: + switch machine.DiskFormat { + case "raw", "qcow2", "vhd", "vhdx": + default: + return nil, fmt.Errorf("unsupported Windows base disk format %q", machine.DiskFormat) + } + if machine.Base != "" { + return nil, fmt.Errorf("Windows base image cannot reference another base") + } + case MachineImageWindowsPersona: + if machine.DiskFormat != "qcow2" { + return nil, fmt.Errorf("Windows persona disk format must be qcow2") + } + base, err := ParseNormalizedRef(machine.Base) + if err != nil || !base.IsDigest() { + return nil, fmt.Errorf("Windows persona base must be a digest-pinned OCI reference") + } + default: + return nil, fmt.Errorf("unsupported machine image kind %q", machine.Kind) + } + return machine, nil +} + +func machineArtifactDisk(root string, machine *MachineImage) (string, error) { + resolvedRoot, err := filepath.EvalSymlinks(root) + if err != nil { + return "", fmt.Errorf("resolve machine artifact root: %w", err) + } + path, err := filepath.EvalSymlinks(filepath.Join(root, filepath.FromSlash(machine.DiskPath))) + if err != nil { + return "", fmt.Errorf("resolve machine image disk: %w", err) + } + rel, err := filepath.Rel(resolvedRoot, path) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("machine image disk path escapes artifact root") + } + info, err := os.Stat(path) + if err != nil { + return "", fmt.Errorf("stat machine image disk: %w", err) + } + if !info.Mode().IsRegular() { + return "", fmt.Errorf("machine image disk is not a regular file") + } + return path, nil +} + +type qemuImageInfo struct { + Format string `json:"format"` + VirtualSize int64 `json:"virtual-size"` + BackingFilename string `json:"backing-filename"` + BackingFileFormat string `json:"backing-filename-format"` +} + +func inspectQEMUImage(path string) (qemuImageInfo, error) { + output, err := exec.Command("qemu-img", "info", "--output=json", path).CombinedOutput() + if err != nil { + return qemuImageInfo{}, fmt.Errorf("inspect machine disk: %w: %s", err, output) + } + var info qemuImageInfo + if err := json.Unmarshal(output, &info); err != nil { + return qemuImageInfo{}, fmt.Errorf("decode qemu-img info: %w", err) + } + return info, nil +} + +func (m *manager) materializeMachineImage(ref *ResolvedRef, root string, machine *MachineImage) (int64, error) { + source, err := machineArtifactDisk(root, machine) + if err != nil { + return 0, err + } + + destination := machineDiskPath(m.paths, ref.Repository(), ref.DigestHex(), machine.Kind) + if err := os.MkdirAll(filepath.Dir(destination), 0755); err != nil { + return 0, fmt.Errorf("create machine image directory: %w", err) + } + _ = os.Remove(destination) + removeOnError := true + defer func() { + if removeOnError { + _ = os.Remove(destination) + } + }() + + switch machine.Kind { + case MachineImageWindowsBase: + if machine.DiskFormat == "raw" { + err = forkvm.CopyRegularFile(source, destination) + } else { + format := machine.DiskFormat + if format == "vhd" { + format = "vpc" + } + output, convertErr := exec.Command("qemu-img", "convert", "-f", format, "-O", "raw", source, destination).CombinedOutput() + if convertErr != nil { + err = fmt.Errorf("convert Windows base to raw: %w: %s", convertErr, output) + } + } + if err != nil { + return 0, fmt.Errorf("materialize Windows base: %w", err) + } + info, err := inspectQEMUImage(destination) + if err != nil { + return 0, err + } + if info.Format != "raw" { + return 0, fmt.Errorf("Windows base disk must be raw, got %s", info.Format) + } + machine.VirtualSize = info.VirtualSize + case MachineImageWindowsPersona: + if err := forkvm.CopyRegularFile(source, destination); err != nil { + return 0, fmt.Errorf("materialize Windows persona: %w", err) + } + if err := os.Chmod(destination, 0600); err != nil { + return 0, fmt.Errorf("make Windows persona writable for validation: %w", err) + } + basePath, err := m.resolveMachineBase(machine.Base) + if err != nil { + return 0, err + } + output, err := exec.Command("qemu-img", "rebase", "-u", "-f", "qcow2", "-F", "raw", "-b", basePath, destination).CombinedOutput() + if err != nil { + return 0, fmt.Errorf("set persona backing file: %w: %s", err, output) + } + info, err := inspectQEMUImage(destination) + if err != nil { + return 0, err + } + if info.Format != "qcow2" || info.BackingFileFormat != "raw" || info.BackingFilename != basePath { + return 0, fmt.Errorf("invalid persona disk backing configuration") + } + baseInfo, err := inspectQEMUImage(basePath) + if err != nil { + return 0, err + } + if info.VirtualSize != baseInfo.VirtualSize { + return 0, fmt.Errorf("persona virtual size %d does not match base %d", info.VirtualSize, baseInfo.VirtualSize) + } + machine.VirtualSize = info.VirtualSize + } + + if err := os.Chmod(destination, 0444); err != nil { + return 0, fmt.Errorf("make machine disk immutable: %w", err) + } + stat, err := os.Stat(destination) + if err != nil { + return 0, fmt.Errorf("stat materialized machine disk: %w", err) + } + removeOnError = false + return stat.Size(), nil +} + +func (m *manager) resolveMachineBase(reference string) (string, error) { + ref, err := ParseNormalizedRef(reference) + if err != nil || !ref.IsDigest() { + return "", fmt.Errorf("parse machine base reference") + } + meta, err := readMetadata(m.paths, ref.Repository(), ref.DigestHex()) + if err != nil { + return "", fmt.Errorf("get machine base %s: %w", reference, err) + } + if meta.Status != StatusReady || meta.Machine == nil || meta.Machine.Kind != MachineImageWindowsBase { + return "", fmt.Errorf("machine base %s is not a ready Windows base", reference) + } + return machineDiskPath(m.paths, ref.Repository(), ref.DigestHex(), MachineImageWindowsBase), nil +} + +func machineDiskPath(p *paths.Paths, repository, digestHex string, kind MachineImageKind) string { + name := "base.raw" + if kind == MachineImageWindowsPersona { + name = "persona.qcow2" + } + return filepath.Join(p.ImageDigestDir(repository, digestHex), name) +} + +func (m *manager) ensureNoMachineDependents(repository, digestHex string) error { + metas, err := listAllMetadata(m.paths) + if err != nil { + return err + } + for _, meta := range metas { + if meta.Machine == nil || meta.Machine.Kind != MachineImageWindowsPersona { + continue + } + base, err := ParseNormalizedRef(meta.Machine.Base) + if err == nil && base.Repository() == repository && base.DigestHex() == digestHex { + return fmt.Errorf("cannot delete Windows base while persona %s depends on it", meta.Name) + } + } + return nil +} + +// GetMachineDiskPath returns the materialized disk for a machine image. +func GetMachineDiskPath(p *paths.Paths, imageName, digest string, machine *MachineImage) (string, error) { + if machine == nil { + return "", fmt.Errorf("image is not a machine image") + } + ref, err := ParseNormalizedRef(imageName) + if err != nil { + return "", fmt.Errorf("parse image name: %w", err) + } + return machineDiskPath(p, ref.Repository(), strings.TrimPrefix(digest, "sha256:"), machine.Kind), nil +} + +// IsWindowsPersona reports whether an image is directly launchable as a Windows desktop. +func IsWindowsPersona(image *Image) bool { + return image != nil && image.Machine != nil && image.Machine.Kind == MachineImageWindowsPersona +} diff --git a/lib/images/machine_oci_integration_test.go b/lib/images/machine_oci_integration_test.go new file mode 100644 index 000000000..c1f12d0b6 --- /dev/null +++ b/lib/images/machine_oci_integration_test.go @@ -0,0 +1,113 @@ +package images + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "io" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/google/go-containerregistry/pkg/name" + gcrregistry "github.com/google/go-containerregistry/pkg/registry" + gcr "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/tarball" + "github.com/kernel/hypeman/lib/paths" + "github.com/stretchr/testify/require" +) + +func machineArtifactOCIImage(t *testing.T, diskName string, disk []byte, labels map[string]string) gcr.Image { + t.Helper() + var layerData bytes.Buffer + gz := gzip.NewWriter(&layerData) + tw := tar.NewWriter(gz) + require.NoError(t, tw.WriteHeader(&tar.Header{Name: diskName, Mode: 0644, Size: int64(len(disk))})) + _, err := tw.Write(disk) + require.NoError(t, err) + require.NoError(t, tw.Close()) + require.NoError(t, gz.Close()) + + layer, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(layerData.Bytes())), nil + }) + require.NoError(t, err) + image, err := mutate.AppendLayers(empty.Image, layer) + require.NoError(t, err) + config, err := image.ConfigFile() + require.NoError(t, err) + config.OS = "windows" + config.Architecture = "amd64" + config.Config.Labels = labels + image, err = mutate.ConfigFile(image, config) + require.NoError(t, err) + return image +} + +func waitForMachineImage(t *testing.T, manager Manager, name string) *Image { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + require.NoError(t, manager.WaitForReady(ctx, name)) + image, err := manager.GetImage(ctx, name) + require.NoError(t, err) + require.Equal(t, StatusReady, image.Status) + return image +} + +func TestMachineArtifactsPullFromOCI(t *testing.T) { + if _, err := exec.LookPath("qemu-img"); err != nil { + if os.Getenv("CI") == "true" { + t.Fatal("qemu-img is required in CI") + } + t.Skip("qemu-img is unavailable") + } + + registry := httptest.NewServer(gcrregistry.New()) + defer registry.Close() + manager, err := NewManager(paths.New(t.TempDir()), 1, nil) + require.NoError(t, err) + + baseFile := filepath.Join(t.TempDir(), "base.raw") + file, err := os.Create(baseFile) + require.NoError(t, err) + require.NoError(t, file.Truncate(4<<20)) + require.NoError(t, file.Close()) + baseBytes, err := os.ReadFile(baseFile) + require.NoError(t, err) + baseLabels := windowsMachineMetadata(MachineImageWindowsBase, "hypeman/base.raw", "").Labels + baseImage := machineArtifactOCIImage(t, "hypeman/base.raw", baseBytes, baseLabels) + baseTag, err := name.NewTag(registry.Listener.Addr().String()+"/windows/base:test", name.Insecure) + require.NoError(t, err) + require.NoError(t, remote.Write(baseTag, baseImage)) + + createdBase, err := manager.CreateImage(context.Background(), CreateImageRequest{Name: baseTag.String(), Platform: "windows/amd64"}) + require.NoError(t, err) + readyBase := waitForMachineImage(t, manager, createdBase.Name) + require.Equal(t, MachineImageWindowsBase, readyBase.Machine.Kind) + + personaFile := filepath.Join(t.TempDir(), "persona.qcow2") + output, err := exec.Command("qemu-img", "create", "-f", "qcow2", personaFile, "4M").CombinedOutput() + require.NoError(t, err, "%s", output) + personaBytes, err := os.ReadFile(personaFile) + require.NoError(t, err) + baseReference := baseTag.Context().Name() + "@" + readyBase.Digest + personaLabels := windowsMachineMetadata(MachineImageWindowsPersona, "hypeman/persona.qcow2", baseReference).Labels + personaImage := machineArtifactOCIImage(t, "hypeman/persona.qcow2", personaBytes, personaLabels) + personaTag, err := name.NewTag(registry.Listener.Addr().String()+"/windows/persona:test", name.Insecure) + require.NoError(t, err) + require.NoError(t, remote.Write(personaTag, personaImage)) + + createdPersona, err := manager.CreateImage(context.Background(), CreateImageRequest{Name: personaTag.String(), Platform: "windows/amd64"}) + require.NoError(t, err) + readyPersona := waitForMachineImage(t, manager, createdPersona.Name) + require.Equal(t, MachineImageWindowsPersona, readyPersona.Machine.Kind) + require.Equal(t, readyBase.Machine.VirtualSize, readyPersona.Machine.VirtualSize) +} diff --git a/lib/images/machine_test.go b/lib/images/machine_test.go new file mode 100644 index 000000000..2051ac795 --- /dev/null +++ b/lib/images/machine_test.go @@ -0,0 +1,188 @@ +package images + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/kernel/hypeman/lib/paths" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func windowsMachineMetadata(kind MachineImageKind, diskPath, base string) *containerMetadata { + format := "raw" + if kind == MachineImageWindowsPersona { + format = "qcow2" + } + return &containerMetadata{ + OS: "windows", + Architecture: "amd64", + Labels: map[string]string{ + MachineImageVersionLabel: MachineImageVersion, + MachineImageKindLabel: string(kind), + MachineImageDiskPathLabel: diskPath, + MachineImageDiskFormatLabel: format, + MachineImageBaseLabel: base, + MachineImageTPMLabel: "2.0", + MachineImageSecureBootLabel: "required", + }, + } +} + +func TestParseMachineImage(t *testing.T) { + base, err := parseMachineImage(windowsMachineMetadata(MachineImageWindowsBase, "hypeman/disk.raw", "")) + require.NoError(t, err) + assert.Equal(t, MachineImageWindowsBase, base.Kind) + + persona, err := parseMachineImage(windowsMachineMetadata( + MachineImageWindowsPersona, + "hypeman/disk.qcow2", + "registry.example/base@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + )) + require.NoError(t, err) + assert.Equal(t, MachineImageWindowsPersona, persona.Kind) + + _, err = parseMachineImage(&containerMetadata{OS: "windows", Architecture: "amd64", Labels: map[string]string{}}) + assert.ErrorContains(t, err, "ordinary Windows container images are not bootable") + + invalidPath := windowsMachineMetadata(MachineImageWindowsBase, "../disk.raw", "") + _, err = parseMachineImage(invalidPath) + assert.ErrorContains(t, err, "local relative path") +} + +func TestMachineArtifactDiskRejectsSymlinkEscape(t *testing.T) { + root := t.TempDir() + outside := filepath.Join(t.TempDir(), "disk.raw") + require.NoError(t, os.WriteFile(outside, []byte("disk"), 0644)) + require.NoError(t, os.Symlink(filepath.Dir(outside), filepath.Join(root, "hypeman"))) + + _, err := machineArtifactDisk(root, &MachineImage{DiskPath: "hypeman/disk.raw"}) + assert.ErrorContains(t, err, "escapes artifact root") +} + +func TestMaterializeWindowsBaseFormats(t *testing.T) { + if _, err := exec.LookPath("qemu-img"); err != nil { + if os.Getenv("CI") == "true" { + t.Fatal("qemu-img is required in CI") + } + t.Skip("qemu-img is unavailable") + } + + formats := []struct { + label string + qemu string + char string + }{ + {label: "raw", qemu: "raw", char: "1"}, + {label: "qcow2", qemu: "qcow2", char: "2"}, + {label: "vhd", qemu: "vpc", char: "3"}, + {label: "vhdx", qemu: "vhdx", char: "4"}, + } + for _, format := range formats { + t.Run(format.label, func(t *testing.T) { + p := paths.New(t.TempDir()) + m := &manager{paths: p} + digest := strings.Repeat(format.char, 64) + ref, err := ParseNormalizedRef("registry.example/windows/base@sha256:" + digest) + require.NoError(t, err) + resolved := NewResolvedRef(ref, "sha256:"+digest) + root := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(root, "hypeman"), 0755)) + source := filepath.Join(root, "hypeman", "disk") + output, err := exec.Command("qemu-img", "create", "-f", format.qemu, source, "4M").CombinedOutput() + require.NoError(t, err, "%s", output) + + meta := windowsMachineMetadata(MachineImageWindowsBase, "hypeman/disk", "") + meta.Labels[MachineImageDiskFormatLabel] = format.label + machine, err := parseMachineImage(meta) + require.NoError(t, err) + _, err = m.materializeMachineImage(resolved, root, machine) + require.NoError(t, err) + info, err := inspectQEMUImage(machineDiskPath(p, ref.Repository(), digest, MachineImageWindowsBase)) + require.NoError(t, err) + assert.Equal(t, "raw", info.Format) + }) + } +} + +func TestMaterializeWindowsBaseAndPersona(t *testing.T) { + if _, err := exec.LookPath("qemu-img"); err != nil { + if os.Getenv("CI") == "true" { + t.Fatal("qemu-img is required in CI") + } + t.Skip("qemu-img is unavailable") + } + + p := paths.New(t.TempDir()) + m := &manager{paths: p} + baseDigest := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + baseRef, err := ParseNormalizedRef("registry.example/windows/base@sha256:" + baseDigest) + require.NoError(t, err) + resolvedBase := NewResolvedRef(baseRef, "sha256:"+baseDigest) + + baseRoot := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(baseRoot, "hypeman"), 0755)) + baseSource := filepath.Join(baseRoot, "hypeman", "disk.raw") + baseFile, err := os.Create(baseSource) + require.NoError(t, err) + require.NoError(t, baseFile.Truncate(4<<20)) + require.NoError(t, baseFile.Close()) + + baseMachine, err := parseMachineImage(windowsMachineMetadata(MachineImageWindowsBase, "hypeman/disk.raw", "")) + require.NoError(t, err) + _, err = m.materializeMachineImage(resolvedBase, baseRoot, baseMachine) + require.NoError(t, err) + require.NoError(t, writeMetadata(p, baseRef.Repository(), baseDigest, &imageMetadata{ + Name: baseRef.String(), + Digest: "sha256:" + baseDigest, + Platform: "windows/amd64", + Status: StatusReady, + Machine: baseMachine, + SizeBytes: 4 << 20, + })) + + personaDigest := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + personaRef, err := ParseNormalizedRef("registry.example/windows/persona@sha256:" + personaDigest) + require.NoError(t, err) + resolvedPersona := NewResolvedRef(personaRef, "sha256:"+personaDigest) + personaRoot := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(personaRoot, "hypeman"), 0755)) + personaSource := filepath.Join(personaRoot, "hypeman", "disk.qcow2") + output, err := exec.Command("qemu-img", "create", "-f", "qcow2", personaSource, "4M").CombinedOutput() + require.NoError(t, err, "%s", output) + + personaMachine, err := parseMachineImage(windowsMachineMetadata( + MachineImageWindowsPersona, + "hypeman/disk.qcow2", + baseRef.String(), + )) + require.NoError(t, err) + _, err = m.materializeMachineImage(resolvedPersona, personaRoot, personaMachine) + require.NoError(t, err) + + personaPath := machineDiskPath(p, personaRef.Repository(), personaDigest, MachineImageWindowsPersona) + info, err := inspectQEMUImage(personaPath) + require.NoError(t, err) + assert.Equal(t, "qcow2", info.Format) + assert.Equal(t, "raw", info.BackingFileFormat) + assert.Equal(t, machineDiskPath(p, baseRef.Repository(), baseDigest, MachineImageWindowsBase), info.BackingFilename) + + baseMeta, err := readMetadata(p, baseRef.Repository(), baseDigest) + require.NoError(t, err) + personaMeta := &imageMetadata{ + Name: personaRef.String(), + Digest: "sha256:" + personaDigest, + Platform: "windows/amd64", + Status: StatusReady, + Machine: personaMachine, + SizeBytes: info.VirtualSize, + } + require.NoError(t, writeMetadata(p, personaRef.Repository(), personaDigest, personaMeta)) + assert.ErrorContains(t, m.ensureNoMachineDependents(baseRef.Repository(), baseDigest), "depends on it") + assert.ErrorContains(t, m.DeleteImage(t.Context(), baseRef.String()), "depends on it") + assert.DirExists(t, p.ImageDigestDir(baseRef.Repository(), baseDigest)) + assert.Equal(t, MachineImageWindowsBase, baseMeta.toImage().Machine.Kind) +} diff --git a/lib/images/manager.go b/lib/images/manager.go index c1a0da0ac..9d0845913 100644 --- a/lib/images/manager.go +++ b/lib/images/manager.go @@ -465,18 +465,28 @@ func (m *manager) buildImage(ctx context.Context, ref *ResolvedRef, credentials m.updateStatusByDigest(ref, StatusConverting, nil, buildID) - diskPath := digestPath(m.paths, ref.Repository(), ref.DigestHex()) - // Use default image format (erofs on Linux, ext4 on Darwin) + machine, err := parseMachineImage(result.Metadata) + if err != nil { + m.updateStatusByDigest(ref, StatusFailed, err, buildID) + return + } + convertStart := time.Now() - diskSize, err := ExportRootfs(tempDir, diskPath, DefaultImageFormat) + var diskSize int64 + if machine != nil { + diskSize, err = m.materializeMachineImage(ref, tempDir, machine) + } else { + diskPath := digestPath(m.paths, ref.Repository(), ref.DigestHex()) + diskSize, err = ExportRootfs(tempDir, diskPath, DefaultImageFormat) + } m.recordImageBuildPhase(ctx, ref.Digest(), "filesystem_export", time.Since(convertStart), phaseStatus(err), "not_applicable") if err != nil { - m.updateStatusByDigest(ref, StatusFailed, fmt.Errorf("convert to %s: %w", DefaultImageFormat, err), buildID) + m.updateStatusByDigest(ref, StatusFailed, fmt.Errorf("materialize image: %w", err), buildID) return } finalizeStart := time.Now() - err = m.finalizeImage(ref, result, diskSize, buildID) + err = m.finalizeImage(ref, result, diskSize, machine, buildID) m.recordImageBuildPhase(ctx, ref.Digest(), "finalize", time.Since(finalizeStart), phaseStatus(err), "not_applicable") if err != nil { if errors.Is(err, errStaleBuild) { @@ -489,7 +499,7 @@ func (m *manager) buildImage(ctx context.Context, ref *ResolvedRef, credentials buildStatus = "success" } -func (m *manager) finalizeImage(ref *ResolvedRef, result *pullResult, diskSize int64, buildID string) error { +func (m *manager) finalizeImage(ref *ResolvedRef, result *pullResult, diskSize int64, machine *MachineImage, buildID string) error { m.createMu.Lock() defer m.createMu.Unlock() @@ -518,6 +528,7 @@ func (m *manager) finalizeImage(ref *ResolvedRef, result *pullResult, diskSize i meta.Env = result.Metadata.Env meta.Labels = result.Metadata.Labels meta.WorkingDir = result.Metadata.WorkingDir + meta.Machine = machine if err := writeMetadata(m.paths, ref.Repository(), ref.DigestHex(), meta); err != nil { return fmt.Errorf("write final metadata: %w", err) @@ -687,6 +698,9 @@ func (m *manager) DeleteImage(ctx context.Context, name string) error { if _, err := readMetadata(m.paths, repository, digestHex); err != nil { return err } + if err := m.ensureNoMachineDependents(repository, digestHex); err != nil { + return err + } if err := deleteTagsForDigest(m.paths, repository, digestHex); err != nil { return err } @@ -705,20 +719,20 @@ func (m *manager) DeleteImage(ctx context.Context, name string) error { return err } - // Delete the tag symlink - if err := deleteTag(m.paths, repository, tag); err != nil { - return err - } - - // Check if the digest is now orphaned (no other tags reference it) count, err := countTagsForDigest(m.paths, repository, digestHex) if err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to count tags for digest %s: %v\n", digestHex, err) - return nil + return fmt.Errorf("count tags for digest %s: %w", digestHex, err) + } + if count == 1 { + if err := m.ensureNoMachineDependents(repository, digestHex); err != nil { + return err + } } - if count == 0 { - // Digest is orphaned, delete it + if err := deleteTag(m.paths, repository, tag); err != nil { + return err + } + if count == 1 { if err := deleteDigest(m.paths, repository, digestHex); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to delete orphaned digest %s: %v\n", digestHex, err) return nil diff --git a/lib/images/manager_test.go b/lib/images/manager_test.go index 89d3fb4fe..4e8a26ba5 100644 --- a/lib/images/manager_test.go +++ b/lib/images/manager_test.go @@ -698,7 +698,7 @@ func TestDeleteAndRecreateDuringBuildTail(t *testing.T) { m.updateStatusByDigest(staleRef, StatusFailed, errors.New("stale build"), firstMeta.BuildID) staleResult, _, _, err := m.ociClient.extractOCIImageDetails(digestHex) require.NoError(t, err) - require.ErrorIs(t, m.finalizeImage(staleRef, &pullResult{Metadata: staleResult}, 1, firstMeta.BuildID), errStaleBuild) + require.ErrorIs(t, m.finalizeImage(staleRef, &pullResult{Metadata: staleResult}, 1, nil, firstMeta.BuildID), errStaleBuild) currentMeta, err = readMetadata(p, repo, digestHex) require.NoError(t, err) require.Equal(t, StatusPending, currentMeta.Status) diff --git a/lib/images/platform.go b/lib/images/platform.go index f5c40b3d8..56d7fdcdf 100644 --- a/lib/images/platform.go +++ b/lib/images/platform.go @@ -79,18 +79,24 @@ func (p Platform) Normalize() Platform { } } -// validate enforces the platforms hypeman can actually boot today: Linux -// guests on amd64 or arm64. Other operating systems and architectures are -// rejected with an actionable error. +// validate enforces the platforms hypeman can boot. Windows machine images +// currently target the QEMU amd64 path only. func (p Platform) validate() error { - if p.OS != "linux" { - return fmt.Errorf("%w: unsupported os %q: only linux guests are supported", ErrInvalidPlatform, p.OS) - } - switch p.Architecture { - case "amd64", "arm64": - return nil + switch p.OS { + case "linux": + switch p.Architecture { + case "amd64", "arm64": + return nil + default: + return fmt.Errorf("%w: unsupported Linux architecture %q: must be amd64 or arm64", ErrInvalidPlatform, p.Architecture) + } + case "windows": + if p.Architecture == "amd64" && p.Variant == "" { + return nil + } + return fmt.Errorf("%w: Windows machine images require amd64 without a variant", ErrInvalidPlatform) default: - return fmt.Errorf("%w: unsupported architecture %q: must be amd64 or arm64", ErrInvalidPlatform, p.Architecture) + return fmt.Errorf("%w: unsupported os %q: must be linux or windows", ErrInvalidPlatform, p.OS) } } diff --git a/lib/images/platform_test.go b/lib/images/platform_test.go index 82b9ab97f..ce4b29eb7 100644 --- a/lib/images/platform_test.go +++ b/lib/images/platform_test.go @@ -19,7 +19,8 @@ func TestParsePlatform(t *testing.T) { {name: "x86_64 alias", in: "x86_64", want: Platform{OS: "linux", Architecture: "amd64"}}, {name: "aarch64 alias", in: "linux/aarch64", want: Platform{OS: "linux", Architecture: "arm64"}}, {name: "uppercase normalized", in: "LINUX/AMD64", want: Platform{OS: "linux", Architecture: "amd64"}}, - {name: "non-linux os rejected", in: "windows/amd64", wantErr: true}, + {name: "windows amd64 machine platform", in: "windows/amd64", want: Platform{OS: "windows", Architecture: "amd64"}}, + {name: "windows arm64 rejected", in: "windows/arm64", wantErr: true}, {name: "unknown arch rejected", in: "linux/riscv64", wantErr: true}, {name: "empty rejected", in: "", wantErr: true}, {name: "too many parts", in: "a/b/c/d", wantErr: true}, @@ -145,9 +146,10 @@ func TestResolveManifestPlatform(t *testing.T) { t.Fatalf("expected ErrInvalidPlatform for mismatch, got %v", err) } - // An unsupported manifest os fails validation. - if _, err := resolveManifestPlatform(&containerMetadata{OS: "windows", Architecture: "amd64"}, ""); err == nil { - t.Fatal("expected error for non-linux manifest") + // A Windows machine manifest records its platform. + got, err = resolveManifestPlatform(&containerMetadata{OS: "windows", Architecture: "amd64"}, "") + if err != nil || got.String() != "windows/amd64" { + t.Fatalf("Windows manifest = %s, %v", got, err) } // A manifest with no declared architecture (locally built/synthetic image) diff --git a/lib/images/storage.go b/lib/images/storage.go index 8764400c1..24ce44378 100644 --- a/lib/images/storage.go +++ b/lib/images/storage.go @@ -27,6 +27,7 @@ type imageMetadata struct { Labels map[string]string `json:"labels,omitempty"` Tags tags.Tags `json:"tags,omitempty"` WorkingDir string `json:"working_dir,omitempty"` + Machine *MachineImage `json:"machine,omitempty"` CreatedAt time.Time `json:"created_at"` BorrowedAuth bool `json:"borrowed_auth,omitempty"` BuildID string `json:"build_id,omitempty"` @@ -73,6 +74,10 @@ func (m *imageMetadata) toImage() *Image { if m.WorkingDir != "" { img.WorkingDir = m.WorkingDir } + if m.Machine != nil { + machine := *m.Machine + img.Machine = &machine + } return img } @@ -158,6 +163,9 @@ func readMetadata(p *paths.Paths, repository, digestHex string) (*imageMetadata, if meta.Status == StatusReady { diskPath := digestPath(p, repository, digestHex) + if meta.Machine != nil { + diskPath = machineDiskPath(p, repository, digestHex, meta.Machine.Kind) + } if _, err := os.Stat(diskPath); err != nil { if os.IsNotExist(err) { return nil, fmt.Errorf("disk image missing: %s", diskPath) diff --git a/lib/images/types.go b/lib/images/types.go index a7c498193..24a39b6c5 100644 --- a/lib/images/types.go +++ b/lib/images/types.go @@ -22,6 +22,7 @@ type Image struct { Labels map[string]string Tags tags.Tags WorkingDir string + Machine *MachineImage CreatedAt time.Time } diff --git a/lib/instances/create.go b/lib/instances/create.go index 4e2245b73..55ec459b4 100644 --- a/lib/instances/create.go +++ b/lib/instances/create.go @@ -128,6 +128,12 @@ func (m *manager) createInstance( log.ErrorContext(ctx, "image not ready", "image", req.Image, "status", imageInfo.Status) return nil, fmt.Errorf("%w: image status is %s", ErrImageNotReady, imageInfo.Status) } + windows := isWindowsPlatform(imageInfo.Platform) + if windows { + if err := validateWindowsCreate(req, imageInfo, hvType); err != nil { + return nil, err + } + } // A guest whose architecture differs from the host kernel can only boot via // emulation. On Apple silicon that is Rosetta, enabled automatically; on any @@ -147,17 +153,20 @@ func (m *manager) createInstance( return nil, fmt.Errorf("get vm starter for %s: %w", hvType, starterErr) } - defaultKernel := m.systemManager.GetDefaultKernelVersion() - kernelVer, err := resolveCreateKernelVersion(imageInfo, defaultKernel) - if err != nil { - log.ErrorContext(ctx, "invalid image kernel label", "image", req.Image, "error", err) - return nil, err - } - if kernelVer != defaultKernel { - log.InfoContext(ctx, "using image-declared kernel version", - "image", req.Image, - "kernel", kernelVer, - "label", system.ImageKernelVersionLabel) + var kernelVer system.KernelVersion + if !windows { + defaultKernel := m.systemManager.GetDefaultKernelVersion() + kernelVer, err = resolveCreateKernelVersion(imageInfo, defaultKernel) + if err != nil { + log.ErrorContext(ctx, "invalid image kernel label", "image", req.Image, "error", err) + return nil, err + } + if kernelVer != defaultKernel { + log.InfoContext(ctx, "using image-declared kernel version", + "image", req.Image, + "kernel", kernelVer, + "label", system.ImageKernelVersionLabel) + } } // resolvedImageRef is the digest-pinned reference used for boot/start/restore // (stable across mutable tags). The caller-facing Image field keeps the @@ -185,11 +194,17 @@ func (m *manager) createInstance( // 6. Apply defaults size := req.Size if size == 0 { - size = 1 * 1024 * 1024 * 1024 // 1GB default + if windows { + size = 8 * 1024 * 1024 * 1024 + } else { + size = 1 * 1024 * 1024 * 1024 // 1GB default + } } hotplugSize := req.HotplugSize overlaySize := req.OverlaySize - if overlaySize == 0 { + if windows { + overlaySize = imageInfo.Machine.VirtualSize + } else if overlaySize == 0 { overlaySize = 10 * 1024 * 1024 * 1024 // 10GB default } // Validate overlay size against max @@ -198,7 +213,11 @@ func (m *manager) createInstance( } vcpus := req.Vcpus if vcpus == 0 { - vcpus = 2 + if windows { + vcpus = 4 + } else { + vcpus = 2 + } } // Validate per-instance resource limits @@ -365,7 +384,7 @@ func (m *manager) createInstance( Entrypoint: req.Entrypoint, Cmd: req.Cmd, SkipKernelHeaders: req.SkipKernelHeaders, - SkipGuestAgent: req.SkipGuestAgent, + SkipGuestAgent: req.SkipGuestAgent || windows, EnableRosetta: enableRosetta, SnapshotPolicy: cloneSnapshotPolicy(req.SnapshotPolicy), AutoStandby: cloneAutoStandbyPolicy(req.AutoStandby), @@ -380,11 +399,17 @@ func (m *manager) createInstance( return nil, fmt.Errorf("ensure directories: %w", err) } - // 13. Create overlay disk with specified size - log.DebugContext(ctx, "creating overlay disk", "instance_id", id, "size_bytes", stored.OverlaySize) - if err := m.createOverlayDisk(id, stored.OverlaySize); err != nil { - log.ErrorContext(ctx, "failed to create overlay disk", "instance_id", id, "error", err) - return nil, fmt.Errorf("create overlay disk: %w", err) + // 13. Create the guest's writable disk. + if windows { + if err := m.prepareWindowsInstance(stored, imageInfo); err != nil { + return nil, fmt.Errorf("prepare Windows instance: %w", err) + } + } else { + log.DebugContext(ctx, "creating overlay disk", "instance_id", id, "size_bytes", stored.OverlaySize) + if err := m.createOverlayDisk(id, stored.OverlaySize); err != nil { + log.ErrorContext(ctx, "failed to create overlay disk", "instance_id", id, "error", err) + return nil, fmt.Errorf("create overlay disk: %w", err) + } } // 14. Allocate network (if network enabled) @@ -482,18 +507,20 @@ func (m *manager) createInstance( m.unregisterEgressProxyInstance(ctx, id) }) } - log.DebugContext(ctx, "creating config disk", "instance_id", id) - configDiskCtx, configDiskSpanEnd := m.startLifecycleStep(ctx, "create_config_disk", - attribute.String("instance_id", id), - attribute.String("hypervisor", string(stored.HypervisorType)), - attribute.String("operation", "create_config_disk"), - ) - if err := m.createConfigDisk(configDiskCtx, inst, imageInfo, netConfig, proxyGuestConfig); err != nil { - configDiskSpanEnd(err) - log.ErrorContext(ctx, "failed to create config disk", "instance_id", id, "error", err) - return nil, fmt.Errorf("create config disk: %w", err) + if !windows { + log.DebugContext(ctx, "creating config disk", "instance_id", id) + configDiskCtx, configDiskSpanEnd := m.startLifecycleStep(ctx, "create_config_disk", + attribute.String("instance_id", id), + attribute.String("hypervisor", string(stored.HypervisorType)), + attribute.String("operation", "create_config_disk"), + ) + if err := m.createConfigDisk(configDiskCtx, inst, imageInfo, netConfig, proxyGuestConfig); err != nil { + configDiskSpanEnd(err) + log.ErrorContext(ctx, "failed to create config disk", "instance_id", id, "error", err) + return nil, fmt.Errorf("create config disk: %w", err) + } + configDiskSpanEnd(nil) } - configDiskSpanEnd(nil) // 17. Record boot start time before launching the VM so marker hydration // can safely ignore stale sentinels from prior runs. @@ -809,6 +836,10 @@ func resolveRuntimeHypervisorPID(log *slog.Logger, socketPath string, fallbackPI // buildHypervisorConfig creates a hypervisor-agnostic VM configuration func (m *manager) buildHypervisorConfig(ctx context.Context, inst *Instance, imageInfo *images.Image, netConfig *network.NetworkConfig) (hypervisor.VMConfig, error) { + if isWindowsPlatform(inst.Platform) { + return m.buildWindowsHypervisorConfig(inst, imageInfo, netConfig) + } + // Get system file paths kernelPath, _ := m.systemManager.GetKernelPath(system.KernelVersion(inst.KernelVersion)) initrdPath, _ := m.systemManager.GetInitrdPath() diff --git a/lib/instances/create_image.go b/lib/instances/create_image.go index 238747452..fdfd97d9f 100644 --- a/lib/instances/create_image.go +++ b/lib/instances/create_image.go @@ -28,7 +28,7 @@ func resolveImageForCreate(ctx context.Context, imageManager createImageResolver // host-native; an empty/unknown platform (e.g. a legacy record) is not // assumed to be the host and falls through to host-pinned resolution. if img, err := imageManager.GetImage(ctx, imageName); err == nil { - if p := strings.TrimSpace(img.Platform); p != "" && !images.ImageNeedsHostEmulation(p) { + if strings.TrimSpace(img.Platform) == images.HostPlatformString() { return img, nil } } else if !errors.Is(err, images.ErrNotFound) { diff --git a/lib/instances/create_image_test.go b/lib/instances/create_image_test.go index d964bafe2..ed7185f65 100644 --- a/lib/instances/create_image_test.go +++ b/lib/instances/create_image_test.go @@ -214,6 +214,29 @@ func TestResolveImageForCreateWithoutPlatformLegacyEmptyForcesHostResolve(t *tes } } +func TestResolveImageForCreateWithoutPlatformIgnoresCachedWindowsImage(t *testing.T) { + t.Parallel() + + createPlatform := "" + resolver := createImageResolverFake{ + getImage: func(context.Context, string) (*images.Image, error) { + return &images.Image{Platform: "windows/amd64", Status: images.StatusReady}, nil + }, + createImage: func(_ context.Context, req images.CreateImageRequest) (*images.Image, error) { + createPlatform = req.Platform + return &images.Image{Name: req.Name, Digest: "sha256:linux", Platform: images.HostPlatformString(), Status: images.StatusReady}, nil + }, + } + + _, err := resolveImageForCreate(context.Background(), resolver, "registry.example/desktop:test", "", slog.Default()) + if err != nil { + t.Fatalf("resolve image: %v", err) + } + if createPlatform != images.HostPlatformString() { + t.Fatalf("cached Windows image must not satisfy an implicit host-platform create; got %q", createPlatform) + } +} + // A no-platform create must NOT trust a tag pointer that resolves to a non-host // arch (last-pull-wins can point the tag at an emulated variant). It must // re-resolve the host variant explicitly and never silently emulate. diff --git a/lib/instances/start.go b/lib/instances/start.go index 032b71076..cc02476ec 100644 --- a/lib/instances/start.go +++ b/lib/instances/start.go @@ -164,20 +164,22 @@ func (m *manager) startInstance( }) } - // 5. Regenerate config disk with new network configuration - instForConfig := &Instance{StoredMetadata: *stored} - log.DebugContext(ctx, "regenerating config disk", "instance_id", id) - configDiskCtx, configDiskSpanEnd := m.startLifecycleStep(ctx, "create_config_disk", - attribute.String("instance_id", id), - attribute.String("hypervisor", string(stored.HypervisorType)), - attribute.String("operation", "create_config_disk"), - ) - if err := m.createConfigDisk(configDiskCtx, instForConfig, imageInfo, netConfig, proxyGuestConfig); err != nil { - configDiskSpanEnd(err) - log.ErrorContext(ctx, "failed to create config disk", "instance_id", id, "error", err) - return nil, fmt.Errorf("create config disk: %w", err) + // 5. Regenerate the Linux config disk with new network configuration. + if !isWindowsPlatform(stored.Platform) { + instForConfig := &Instance{StoredMetadata: *stored} + log.DebugContext(ctx, "regenerating config disk", "instance_id", id) + configDiskCtx, configDiskSpanEnd := m.startLifecycleStep(ctx, "create_config_disk", + attribute.String("instance_id", id), + attribute.String("hypervisor", string(stored.HypervisorType)), + attribute.String("operation", "create_config_disk"), + ) + if err := m.createConfigDisk(configDiskCtx, instForConfig, imageInfo, netConfig, proxyGuestConfig); err != nil { + configDiskSpanEnd(err) + log.ErrorContext(ctx, "failed to create config disk", "instance_id", id, "error", err) + return nil, fmt.Errorf("create config disk: %w", err) + } + configDiskSpanEnd(nil) } - configDiskSpanEnd(nil) if err := m.archiveAppLogForBoot(id); err != nil { log.WarnContext(ctx, "failed to archive app log before start", "instance_id", id, "error", err) diff --git a/lib/instances/windows.go b/lib/instances/windows.go new file mode 100644 index 000000000..13fc8991e --- /dev/null +++ b/lib/instances/windows.go @@ -0,0 +1,157 @@ +package instances + +import ( + "fmt" + "os" + "strings" + + "github.com/kernel/hypeman/lib/forkvm" + "github.com/kernel/hypeman/lib/hypervisor" + "github.com/kernel/hypeman/lib/images" + "github.com/kernel/hypeman/lib/network" +) + +func isWindowsPlatform(platform string) bool { + return strings.EqualFold(strings.TrimSpace(platform), "windows/amd64") +} + +func validateWindowsCreate(req CreateInstanceRequest, image *images.Image, hvType hypervisor.Type) error { + if !images.IsWindowsPersona(image) { + return fmt.Errorf("%w: Windows instances require a persona image", ErrInvalidRequest) + } + if hvType != hypervisor.TypeQEMU { + return fmt.Errorf("%w: Windows instances require the qemu hypervisor", ErrInvalidRequest) + } + if image.Machine.VirtualSize <= 0 { + return fmt.Errorf("%w: Windows persona is missing its virtual disk size", ErrInvalidRequest) + } + if req.HotplugSize != 0 { + return fmt.Errorf("%w: Windows instances do not yet support hotplug memory", ErrInvalidRequest) + } + if req.Size != 0 && req.Size < 4<<30 { + return fmt.Errorf("%w: Windows 11 requires at least 4 GiB of memory", ErrInvalidRequest) + } + if req.Vcpus != 0 && req.Vcpus < 2 { + return fmt.Errorf("%w: Windows 11 requires at least 2 vCPUs", ErrInvalidRequest) + } + if req.NetworkEnabled { + return fmt.Errorf("%w: Windows networking is added in the networking phase", ErrInvalidRequest) + } + if len(req.Volumes) != 0 || len(req.Devices) != 0 || req.GPU != nil { + return fmt.Errorf("%w: Windows instances do not yet support volumes or device passthrough", ErrInvalidRequest) + } + if req.OverlaySize != 0 && req.OverlaySize != image.Machine.VirtualSize { + return fmt.Errorf("%w: Windows instance disk size is fixed at %d bytes", ErrInvalidRequest, image.Machine.VirtualSize) + } + if len(req.Entrypoint) != 0 || len(req.Cmd) != 0 { + return fmt.Errorf("%w: Windows machine images do not support entrypoint or command overrides", ErrInvalidRequest) + } + if len(req.Env) != 0 || req.HealthCheck != nil { + return fmt.Errorf("%w: Windows instances do not yet support environment injection or health checks", ErrInvalidRequest) + } + if req.NetworkEgress != nil || len(req.Credentials) != 0 { + return fmt.Errorf("%w: Windows instances do not yet support managed egress or credentials", ErrInvalidRequest) + } + return nil +} + +func windowsFirmwareTemplates() (string, string, error) { + code := os.Getenv("HYPEMAN_WINDOWS_OVMF_CODE") + if code == "" { + code = "/usr/share/OVMF/OVMF_CODE_4M.secboot.fd" + } + vars := os.Getenv("HYPEMAN_WINDOWS_OVMF_VARS") + if vars == "" { + vars = "/usr/share/OVMF/OVMF_VARS_4M.ms.fd" + } + for _, path := range []string{code, vars} { + info, err := os.Stat(path) + if err != nil { + return "", "", fmt.Errorf("Windows firmware %s: %w", path, err) + } + if !info.Mode().IsRegular() { + return "", "", fmt.Errorf("Windows firmware %s is not a regular file", path) + } + } + return code, vars, nil +} + +func (m *manager) prepareWindowsInstance(inst *StoredMetadata, image *images.Image) error { + persona, err := images.GetMachineDiskPath(m.paths, image.Name, image.Digest, image.Machine) + if err != nil { + return err + } + if err := forkvm.CopyRegularFile(persona, m.paths.InstanceWindowsDisk(inst.Id)); err != nil { + return fmt.Errorf("clone Windows persona: %w", err) + } + if err := os.Chmod(m.paths.InstanceWindowsDisk(inst.Id), 0600); err != nil { + return fmt.Errorf("make Windows instance disk writable: %w", err) + } + + code, vars, err := windowsFirmwareTemplates() + if err != nil { + return err + } + if err := forkvm.CopyRegularFile(code, m.paths.InstanceOVMFCode(inst.Id)); err != nil { + return fmt.Errorf("copy OVMF code: %w", err) + } + if err := os.Chmod(m.paths.InstanceOVMFCode(inst.Id), 0444); err != nil { + return fmt.Errorf("make OVMF code immutable: %w", err) + } + if err := forkvm.CopyRegularFile(vars, m.paths.InstanceOVMFVars(inst.Id)); err != nil { + return fmt.Errorf("copy OVMF variables: %w", err) + } + if err := os.Chmod(m.paths.InstanceOVMFVars(inst.Id), 0600); err != nil { + return fmt.Errorf("make OVMF variables writable: %w", err) + } + if err := os.MkdirAll(m.paths.InstanceTPMDir(inst.Id), 0700); err != nil { + return fmt.Errorf("create TPM state directory: %w", err) + } + return nil +} + +func (m *manager) buildWindowsHypervisorConfig(inst *Instance, image *images.Image, netConfig *network.NetworkConfig) (hypervisor.VMConfig, error) { + if !images.IsWindowsPersona(image) { + return hypervisor.VMConfig{}, fmt.Errorf("image is not a Windows persona") + } + if _, err := os.Stat(m.paths.InstanceWindowsDisk(inst.Id)); err != nil { + return hypervisor.VMConfig{}, fmt.Errorf("stat Windows instance disk: %w", err) + } + + var networks []hypervisor.NetworkConfig + if netConfig != nil { + networks = []hypervisor.NetworkConfig{{ + TAPDevice: netConfig.TAPDevice, + IP: netConfig.IP, + MAC: netConfig.MAC, + Netmask: netConfig.Netmask, + DownloadBps: inst.NetworkBandwidthDownload, + UploadBps: inst.NetworkBandwidthUpload, + }} + } + + ioBps := inst.DiskIOBps + burstBps := ioBps * 4 + if ioBps <= 0 { + burstBps = 0 + } + return hypervisor.VMConfig{ + VCPUs: inst.Vcpus, + MemoryBytes: inst.Size, + Disks: []hypervisor.DiskConfig{{Path: m.paths.InstanceWindowsDisk(inst.Id), Format: hypervisor.DiskFormatQCOW2, IOBps: ioBps, IOBurstBps: burstBps}}, + Networks: networks, + SerialLogPath: m.paths.InstanceAppLog(inst.Id), + VsockCID: inst.VsockCID, + VsockSocket: inst.VsockSocket, + BootMode: hypervisor.BootModeUEFI, + Firmware: &hypervisor.FirmwareConfig{ + CodePath: m.paths.InstanceOVMFCode(inst.Id), + VarsPath: m.paths.InstanceOVMFVars(inst.Id), + SecureBoot: true, + }, + TPM: &hypervisor.TPMConfig{ + SocketPath: m.paths.InstanceTPMSocket(inst.Id), + StateDir: m.paths.InstanceTPMDir(inst.Id), + }, + }, nil +} diff --git a/lib/instances/windows_images_integration_linux_test.go b/lib/instances/windows_images_integration_linux_test.go new file mode 100644 index 000000000..5eb869f91 --- /dev/null +++ b/lib/instances/windows_images_integration_linux_test.go @@ -0,0 +1,128 @@ +//go:build linux && amd64 + +package instances + +import ( + "context" + "crypto/sha256" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/kernel/hypeman/lib/forkvm" + "github.com/kernel/hypeman/lib/hypervisor" + "github.com/kernel/hypeman/lib/images" + "github.com/kernel/hypeman/lib/paths" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type windowsFixtureImageManager struct { + images.Manager + image *images.Image +} + +func (m windowsFixtureImageManager) CreateImage(context.Context, images.CreateImageRequest) (*images.Image, error) { + copy := *m.image + return ©, nil +} + +func (m windowsFixtureImageManager) GetImage(context.Context, string) (*images.Image, error) { + copy := *m.image + return ©, nil +} + +func (m windowsFixtureImageManager) WaitForReady(context.Context, string) error { return nil } + +func requireWindowsFixture(t *testing.T) string { + t.Helper() + path := os.Getenv("HYPEMAN_WINDOWS_TEST_PERSONA") + if path == "" { + path = "/ci/windows/persona.qcow2" + } + if _, err := os.Stat(path); err == nil { + return path + } + if os.Getenv("CI") == "true" { + t.Fatalf("required Windows persona fixture is missing: %s", path) + } + t.Skipf("Windows persona fixture is unavailable: %s", path) + return "" +} + +func TestWindowsImagesIntegration(t *testing.T) { + fixture := requireWindowsFixture(t) + acquireHeavyIO(t) + + manager, dataDir := setupTestManagerForQEMU(t) + p := paths.New(dataDir) + const digestHex = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + image := &images.Image{ + Name: "registry.example/windows/persona:integration", + Digest: "sha256:" + digestHex, + Platform: "windows/amd64", + Status: images.StatusReady, + Machine: &images.MachineImage{ + Kind: images.MachineImageWindowsPersona, + Base: "registry.example/windows/base@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + TPM: "2.0", + SecureBoot: "required", + VirtualSize: 80 << 30, + }, + } + manager.imageManager = windowsFixtureImageManager{image: image} + + personaPath, err := images.GetMachineDiskPath(p, image.Name, image.Digest, image.Machine) + require.NoError(t, err) + require.NoError(t, forkvm.CopyRegularFile(fixture, personaPath)) + require.NoError(t, os.Chmod(personaPath, 0444)) + personaBytes, err := os.ReadFile(personaPath) + require.NoError(t, err) + personaHash := sha256.Sum256(personaBytes) + personaInfo, err := os.Stat(personaPath) + require.NoError(t, err) + + ctx := context.Background() + instance, err := manager.CreateInstance(ctx, CreateInstanceRequest{ + Name: "windows-images-integration", + Image: image.Name, + Platform: "windows/amd64", + Size: 8 << 30, + Vcpus: 4, + Hypervisor: hypervisor.TypeQEMU, + }) + require.NoError(t, err) + instanceID := instance.Id + t.Cleanup(func() { + if instanceID != "" { + _ = deleteTestInstanceNow(context.Background(), manager, instanceID) + } + }) + require.Equal(t, StateInitializing, instance.State) + require.FileExists(t, p.InstanceWindowsDisk(instance.Id)) + require.FileExists(t, p.InstanceOVMFVars(instance.Id)) + require.DirExists(t, p.InstanceTPMDir(instance.Id)) + assert.NoFileExists(t, p.InstanceOverlay(instance.Id)) + assert.NoFileExists(t, p.InstanceConfigDisk(instance.Id)) + + require.Eventually(t, func() bool { + info, err := os.Stat(p.InstanceWindowsDisk(instance.Id)) + return err == nil && info.Size() > personaInfo.Size() + }, 60*time.Second, 500*time.Millisecond, "Windows boot must write to the instance qcow2") + + sourceAfter, err := os.ReadFile(personaPath) + require.NoError(t, err) + assert.Equal(t, personaHash, sha256.Sum256(sourceAfter), "immutable persona changed during guest boot") + + stopped, err := manager.StopInstance(ctx, instance.Id) + require.NoError(t, err) + require.Equal(t, StateStopped, stopped.State) + output, err := exec.Command("qemu-img", "check", p.InstanceWindowsDisk(instance.Id)).CombinedOutput() + require.NoError(t, err, "%s", output) + + require.NoError(t, manager.DeleteInstance(ctx, instance.Id)) + instanceID = "" + assert.NoDirExists(t, filepath.Dir(p.InstanceWindowsDisk(instance.Id))) +} diff --git a/lib/instances/windows_test.go b/lib/instances/windows_test.go new file mode 100644 index 000000000..112f654ed --- /dev/null +++ b/lib/instances/windows_test.go @@ -0,0 +1,70 @@ +package instances + +import ( + "os" + "testing" + + "github.com/kernel/hypeman/lib/hypervisor" + "github.com/kernel/hypeman/lib/images" + "github.com/kernel/hypeman/lib/paths" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func windowsPersonaFixture() *images.Image { + return &images.Image{ + Name: "registry.example/windows/persona:test", + Digest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + Platform: "windows/amd64", + Status: images.StatusReady, + Machine: &images.MachineImage{ + Kind: images.MachineImageWindowsPersona, + Base: "registry.example/windows/base@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + TPM: "2.0", + SecureBoot: "required", + VirtualSize: 80 << 30, + }, + } +} + +func TestValidateWindowsCreate(t *testing.T) { + image := windowsPersonaFixture() + require.NoError(t, validateWindowsCreate(CreateInstanceRequest{}, image, hypervisor.TypeQEMU)) + + tests := []struct { + name string + req CreateInstanceRequest + hv hypervisor.Type + }{ + {name: "wrong hypervisor", hv: hypervisor.TypeCloudHypervisor}, + {name: "networking", hv: hypervisor.TypeQEMU, req: CreateInstanceRequest{NetworkEnabled: true}}, + {name: "small memory", hv: hypervisor.TypeQEMU, req: CreateInstanceRequest{Size: 2 << 30}}, + {name: "one CPU", hv: hypervisor.TypeQEMU, req: CreateInstanceRequest{Vcpus: 1}}, + {name: "command", hv: hypervisor.TypeQEMU, req: CreateInstanceRequest{Cmd: []string{"cmd.exe"}}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Error(t, validateWindowsCreate(tt.req, image, tt.hv)) + }) + } +} + +func TestBuildWindowsHypervisorConfig(t *testing.T) { + p := paths.New(t.TempDir()) + m := &manager{paths: p} + stored := StoredMetadata{Id: "instance", Platform: "windows/amd64", Size: 8 << 30, Vcpus: 4, VsockCID: 42} + require.NoError(t, os.MkdirAll(p.InstanceDir(stored.Id), 0755)) + for _, path := range []string{p.InstanceWindowsDisk(stored.Id), p.InstanceOVMFCode(stored.Id), p.InstanceOVMFVars(stored.Id)} { + require.NoError(t, os.WriteFile(path, []byte("fixture"), 0600)) + } + + config, err := m.buildWindowsHypervisorConfig(&Instance{StoredMetadata: stored}, windowsPersonaFixture(), nil) + require.NoError(t, err) + assert.Equal(t, hypervisor.BootModeUEFI, config.BootMode) + assert.True(t, config.Firmware.SecureBoot) + assert.Equal(t, p.InstanceTPMDir(stored.Id), config.TPM.StateDir) + require.Len(t, config.Disks, 1) + assert.Equal(t, hypervisor.DiskFormatQCOW2, config.Disks[0].Format) + assert.Empty(t, config.KernelPath) + assert.Empty(t, config.InitrdPath) +} diff --git a/lib/paths/paths.go b/lib/paths/paths.go index 7e20cba38..7fc0870fd 100644 --- a/lib/paths/paths.go +++ b/lib/paths/paths.go @@ -199,6 +199,26 @@ func (p *Paths) InstanceConfigDisk(id string) string { return filepath.Join(p.InstanceDir(id), "config.ext4") } +func (p *Paths) InstanceWindowsDisk(id string) string { + return filepath.Join(p.InstanceDir(id), "windows.qcow2") +} + +func (p *Paths) InstanceOVMFCode(id string) string { + return filepath.Join(p.InstanceDir(id), "OVMF_CODE.fd") +} + +func (p *Paths) InstanceOVMFVars(id string) string { + return filepath.Join(p.InstanceDir(id), "OVMF_VARS.fd") +} + +func (p *Paths) InstanceTPMDir(id string) string { + return filepath.Join(p.InstanceDir(id), "tpm") +} + +func (p *Paths) InstanceTPMSocket(id string) string { + return filepath.Join(p.InstanceDir(id), "swtpm.sock") +} + // InstanceVolumeOverlay returns the path to a volume's overlay disk for an instance. func (p *Paths) InstanceVolumeOverlay(instanceID, volumeID string) string { return filepath.Join(p.InstanceDir(instanceID), "vol-overlays", volumeID+".raw") From 6584b317f973a16097a5b31730c1ee667ad935ff Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:08:01 +0000 Subject: [PATCH 2/7] Harden Windows image materialization --- docs/windows-images.md | 2 +- lib/images/machine.go | 53 ++++++++++++++++++++++++++++------- lib/images/machine_test.go | 47 +++++++++++++++++++++++++++++-- lib/instances/fork.go | 3 ++ lib/instances/restore.go | 3 ++ lib/instances/snapshot.go | 9 ++++++ lib/instances/standby.go | 3 ++ lib/instances/windows.go | 10 +++++++ lib/instances/windows_test.go | 8 ++++++ 9 files changed, 125 insertions(+), 13 deletions(-) diff --git a/docs/windows-images.md b/docs/windows-images.md index 98ba2b527..36537d947 100644 --- a/docs/windows-images.md +++ b/docs/windows-images.md @@ -16,6 +16,6 @@ A machine image uses these OCI config labels: Hypeman materializes the base as immutable sparse raw. It rewrites the persona's qcow2 backing header to the cache-owned base path, ignoring any artifact-supplied backing path. At instance creation, Hypeman reflink-clones the immutable persona into a writable `windows.qcow2`; the clone remains backed directly by the raw base. -The base must be pulled before its personas. A base cannot be deleted while any cached persona references its digest. +The base must be pulled before its personas. A base cannot be deleted while any cached persona references its digest. Instance references are not tracked by the image cache, matching existing Linux behavior: do not delete a base while any Windows instance cloned from one of its personas exists. Windows installation media, activation material, credentials, and generated disks belong in private registries and must not be committed to this repository. diff --git a/lib/images/machine.go b/lib/images/machine.go index c56a62c5e..e102cc976 100644 --- a/lib/images/machine.go +++ b/lib/images/machine.go @@ -128,10 +128,14 @@ type qemuImageInfo struct { VirtualSize int64 `json:"virtual-size"` BackingFilename string `json:"backing-filename"` BackingFileFormat string `json:"backing-filename-format"` + FormatSpecific struct { + Type string `json:"type"` + Data map[string]json.RawMessage `json:"data"` + } `json:"format-specific"` } -func inspectQEMUImage(path string) (qemuImageInfo, error) { - output, err := exec.Command("qemu-img", "info", "--output=json", path).CombinedOutput() +func inspectQEMUImage(path, format string) (qemuImageInfo, error) { + output, err := exec.Command("qemu-img", "info", "--output=json", "-f", format, path).CombinedOutput() if err != nil { return qemuImageInfo{}, fmt.Errorf("inspect machine disk: %w: %s", err, output) } @@ -142,11 +146,41 @@ func inspectQEMUImage(path string) (qemuImageInfo, error) { return info, nil } +func validateMachineSource(info qemuImageInfo, allowBacking bool) error { + if !allowBacking && info.BackingFilename != "" { + return fmt.Errorf("machine image source must not reference a backing file") + } + for _, feature := range []string{"data-file", "data-file-raw", "encrypt", "encryption", "encrypt-format"} { + if _, ok := info.FormatSpecific.Data[feature]; ok { + return fmt.Errorf("machine image source uses unsupported %s feature", feature) + } + } + return nil +} + +func qemuDiskFormat(format string) string { + if format == "vhd" { + return "vpc" + } + return format +} + func (m *manager) materializeMachineImage(ref *ResolvedRef, root string, machine *MachineImage) (int64, error) { source, err := machineArtifactDisk(root, machine) if err != nil { return 0, err } + sourceFormat := qemuDiskFormat(machine.DiskFormat) + sourceInfo, err := inspectQEMUImage(source, sourceFormat) + if err != nil { + return 0, err + } + if sourceInfo.Format != sourceFormat { + return 0, fmt.Errorf("machine image source format is %s, expected %s", sourceInfo.Format, sourceFormat) + } + if err := validateMachineSource(sourceInfo, machine.Kind == MachineImageWindowsPersona); err != nil { + return 0, err + } destination := machineDiskPath(m.paths, ref.Repository(), ref.DigestHex(), machine.Kind) if err := os.MkdirAll(filepath.Dir(destination), 0755); err != nil { @@ -165,11 +199,7 @@ func (m *manager) materializeMachineImage(ref *ResolvedRef, root string, machine if machine.DiskFormat == "raw" { err = forkvm.CopyRegularFile(source, destination) } else { - format := machine.DiskFormat - if format == "vhd" { - format = "vpc" - } - output, convertErr := exec.Command("qemu-img", "convert", "-f", format, "-O", "raw", source, destination).CombinedOutput() + output, convertErr := exec.Command("qemu-img", "convert", "-f", sourceFormat, "-O", "raw", source, destination).CombinedOutput() if convertErr != nil { err = fmt.Errorf("convert Windows base to raw: %w: %s", convertErr, output) } @@ -177,7 +207,7 @@ func (m *manager) materializeMachineImage(ref *ResolvedRef, root string, machine if err != nil { return 0, fmt.Errorf("materialize Windows base: %w", err) } - info, err := inspectQEMUImage(destination) + info, err := inspectQEMUImage(destination, "raw") if err != nil { return 0, err } @@ -200,14 +230,17 @@ func (m *manager) materializeMachineImage(ref *ResolvedRef, root string, machine if err != nil { return 0, fmt.Errorf("set persona backing file: %w: %s", err, output) } - info, err := inspectQEMUImage(destination) + info, err := inspectQEMUImage(destination, "qcow2") if err != nil { return 0, err } + if err := validateMachineSource(info, true); err != nil { + return 0, err + } if info.Format != "qcow2" || info.BackingFileFormat != "raw" || info.BackingFilename != basePath { return 0, fmt.Errorf("invalid persona disk backing configuration") } - baseInfo, err := inspectQEMUImage(basePath) + baseInfo, err := inspectQEMUImage(basePath, "raw") if err != nil { return 0, err } diff --git a/lib/images/machine_test.go b/lib/images/machine_test.go index 2051ac795..3dd96430e 100644 --- a/lib/images/machine_test.go +++ b/lib/images/machine_test.go @@ -63,6 +63,49 @@ func TestMachineArtifactDiskRejectsSymlinkEscape(t *testing.T) { assert.ErrorContains(t, err, "escapes artifact root") } +func TestMaterializeRejectsExternalDiskReferences(t *testing.T) { + if _, err := exec.LookPath("qemu-img"); err != nil { + if os.Getenv("CI") == "true" { + t.Fatal("qemu-img is required in CI") + } + t.Skip("qemu-img is unavailable") + } + + p := paths.New(t.TempDir()) + m := &manager{paths: p} + root := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(root, "hypeman"), 0755)) + + backed := filepath.Join(root, "hypeman", "backed.qcow2") + output, err := exec.Command("qemu-img", "create", "-f", "qcow2", "-F", "raw", "-b", "/etc/passwd", backed, "4M").CombinedOutput() + require.NoError(t, err, "%s", output) + meta := windowsMachineMetadata(MachineImageWindowsBase, "hypeman/backed.qcow2", "") + meta.Labels[MachineImageDiskFormatLabel] = "qcow2" + machine, err := parseMachineImage(meta) + require.NoError(t, err) + ref, err := ParseNormalizedRef("registry.example/windows/base@sha256:" + strings.Repeat("5", 64)) + require.NoError(t, err) + _, err = m.materializeMachineImage(NewResolvedRef(ref, ref.Digest()), root, machine) + assert.ErrorContains(t, err, "must not reference a backing file") + + dataFile := filepath.Join(t.TempDir(), "external.raw") + require.NoError(t, os.WriteFile(dataFile, make([]byte, 4<<20), 0644)) + external := filepath.Join(root, "hypeman", "external.qcow2") + output, err = exec.Command("qemu-img", "create", "-f", "qcow2", "-o", "data_file="+dataFile+",data_file_raw=on", external, "4M").CombinedOutput() + require.NoError(t, err, "%s", output) + persona := windowsMachineMetadata( + MachineImageWindowsPersona, + "hypeman/external.qcow2", + "registry.example/windows/base@sha256:"+strings.Repeat("6", 64), + ) + machine, err = parseMachineImage(persona) + require.NoError(t, err) + personaRef, err := ParseNormalizedRef("registry.example/windows/persona@sha256:" + strings.Repeat("7", 64)) + require.NoError(t, err) + _, err = m.materializeMachineImage(NewResolvedRef(personaRef, personaRef.Digest()), root, machine) + assert.ErrorContains(t, err, "unsupported data-file feature") +} + func TestMaterializeWindowsBaseFormats(t *testing.T) { if _, err := exec.LookPath("qemu-img"); err != nil { if os.Getenv("CI") == "true" { @@ -101,7 +144,7 @@ func TestMaterializeWindowsBaseFormats(t *testing.T) { require.NoError(t, err) _, err = m.materializeMachineImage(resolved, root, machine) require.NoError(t, err) - info, err := inspectQEMUImage(machineDiskPath(p, ref.Repository(), digest, MachineImageWindowsBase)) + info, err := inspectQEMUImage(machineDiskPath(p, ref.Repository(), digest, MachineImageWindowsBase), "raw") require.NoError(t, err) assert.Equal(t, "raw", info.Format) }) @@ -164,7 +207,7 @@ func TestMaterializeWindowsBaseAndPersona(t *testing.T) { require.NoError(t, err) personaPath := machineDiskPath(p, personaRef.Repository(), personaDigest, MachineImageWindowsPersona) - info, err := inspectQEMUImage(personaPath) + info, err := inspectQEMUImage(personaPath, "qcow2") require.NoError(t, err) assert.Equal(t, "qcow2", info.Format) assert.Equal(t, "raw", info.BackingFileFormat) diff --git a/lib/instances/fork.go b/lib/instances/fork.go index ea6d3a4c5..260b123e5 100644 --- a/lib/instances/fork.go +++ b/lib/instances/fork.go @@ -42,6 +42,9 @@ func (m *manager) forkInstance(ctx context.Context, id string, req ForkInstanceR if err != nil { return nil, "", false, err } + if err := rejectWindowsSnapshotLifecycle(meta.Platform, "fork"); err != nil { + return nil, "", false, err + } source := m.toInstance(ctx, meta) targetState, err := resolveForkTargetState(req.TargetState, source.State) if err != nil { diff --git a/lib/instances/restore.go b/lib/instances/restore.go index c209274e2..85d7c01bd 100644 --- a/lib/instances/restore.go +++ b/lib/instances/restore.go @@ -43,6 +43,9 @@ func (m *manager) restoreInstance( return nil, err } + if err := rejectWindowsSnapshotLifecycle(meta.Platform, "restore from standby"); err != nil { + return nil, err + } inst := m.toInstance(ctx, meta) stored := &meta.StoredMetadata ctx = enrichInstancesTrace(ctx, attribute.String("hypervisor", string(stored.HypervisorType))) diff --git a/lib/instances/snapshot.go b/lib/instances/snapshot.go index 669d087f0..41709f7b2 100644 --- a/lib/instances/snapshot.go +++ b/lib/instances/snapshot.go @@ -63,6 +63,9 @@ func (m *manager) createSnapshot(ctx context.Context, id string, req CreateSnaps if err != nil { return nil, err } + if err := rejectWindowsSnapshotLifecycle(meta.Platform, "snapshot creation"); err != nil { + return nil, err + } inst := m.toInstance(ctx, meta) stored := &meta.StoredMetadata @@ -251,6 +254,9 @@ func (m *manager) restoreSnapshot(ctx context.Context, id string, snapshotID str if err != nil { return nil, err } + if err := rejectWindowsSnapshotLifecycle(rec.StoredMetadata.Platform, "snapshot restore"); err != nil { + return nil, err + } if rec.Snapshot.SourceInstanceID != id { return nil, fmt.Errorf("%w: snapshot %s belongs to instance %s", ErrInvalidRequest, snapshotID, rec.Snapshot.SourceInstanceID) } @@ -371,6 +377,9 @@ func (m *manager) forkSnapshot(ctx context.Context, snapshotID string, req ForkS if err != nil { return nil, err } + if err := rejectWindowsSnapshotLifecycle(rec.StoredMetadata.Platform, "snapshot fork"); err != nil { + return nil, err + } if err := validateForkVolumeSafety(rec.StoredMetadata.Volumes); err != nil { return nil, fmt.Errorf("%w: snapshot requires readonly volume attachments: %v", ErrNotSupported, err) } diff --git a/lib/instances/standby.go b/lib/instances/standby.go index 6913a9895..ae2a728bb 100644 --- a/lib/instances/standby.go +++ b/lib/instances/standby.go @@ -44,6 +44,9 @@ func (m *manager) standbyInstance( return nil, err } + if err := rejectWindowsSnapshotLifecycle(meta.Platform, "standby"); err != nil { + return nil, err + } inst := m.toInstance(ctx, meta) stored := &meta.StoredMetadata ctx = enrichInstancesTrace(ctx, attribute.String("hypervisor", string(stored.HypervisorType))) diff --git a/lib/instances/windows.go b/lib/instances/windows.go index 13fc8991e..824aabb93 100644 --- a/lib/instances/windows.go +++ b/lib/instances/windows.go @@ -52,6 +52,16 @@ func validateWindowsCreate(req CreateInstanceRequest, image *images.Image, hvTyp if req.NetworkEgress != nil || len(req.Credentials) != 0 { return fmt.Errorf("%w: Windows instances do not yet support managed egress or credentials", ErrInvalidRequest) } + if req.SnapshotPolicy != nil || req.AutoStandby != nil { + return fmt.Errorf("%w: Windows snapshot policies are added in the snapshots phase", ErrInvalidRequest) + } + return nil +} + +func rejectWindowsSnapshotLifecycle(platform, operation string) error { + if isWindowsPlatform(platform) { + return fmt.Errorf("%w: %s is not supported for Windows until the snapshots phase", ErrNotSupported, operation) + } return nil } diff --git a/lib/instances/windows_test.go b/lib/instances/windows_test.go index 112f654ed..e1365897d 100644 --- a/lib/instances/windows_test.go +++ b/lib/instances/windows_test.go @@ -4,6 +4,7 @@ import ( "os" "testing" + "github.com/kernel/hypeman/lib/autostandby" "github.com/kernel/hypeman/lib/hypervisor" "github.com/kernel/hypeman/lib/images" "github.com/kernel/hypeman/lib/paths" @@ -41,6 +42,8 @@ func TestValidateWindowsCreate(t *testing.T) { {name: "small memory", hv: hypervisor.TypeQEMU, req: CreateInstanceRequest{Size: 2 << 30}}, {name: "one CPU", hv: hypervisor.TypeQEMU, req: CreateInstanceRequest{Vcpus: 1}}, {name: "command", hv: hypervisor.TypeQEMU, req: CreateInstanceRequest{Cmd: []string{"cmd.exe"}}}, + {name: "snapshot policy", hv: hypervisor.TypeQEMU, req: CreateInstanceRequest{SnapshotPolicy: &SnapshotPolicy{}}}, + {name: "auto standby", hv: hypervisor.TypeQEMU, req: CreateInstanceRequest{AutoStandby: &autostandby.Policy{}}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -49,6 +52,11 @@ func TestValidateWindowsCreate(t *testing.T) { } } +func TestRejectWindowsSnapshotLifecycle(t *testing.T) { + assert.ErrorIs(t, rejectWindowsSnapshotLifecycle("windows/amd64", "fork"), ErrNotSupported) + assert.NoError(t, rejectWindowsSnapshotLifecycle("linux/amd64", "fork")) +} + func TestBuildWindowsHypervisorConfig(t *testing.T) { p := paths.New(t.TempDir()) m := &manager{paths: p} From 1d87481387f52cc0e21a77b9045d6666eb018478 Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Thu, 20 Aug 2026 02:07:07 +0000 Subject: [PATCH 3/7] Keep machine image tests portable --- lib/images/machine_oci_integration_test.go | 7 +---- lib/images/machine_test.go | 32 ++++++++++------------ 2 files changed, 15 insertions(+), 24 deletions(-) diff --git a/lib/images/machine_oci_integration_test.go b/lib/images/machine_oci_integration_test.go index c1f12d0b6..de6bd097c 100644 --- a/lib/images/machine_oci_integration_test.go +++ b/lib/images/machine_oci_integration_test.go @@ -63,12 +63,7 @@ func waitForMachineImage(t *testing.T, manager Manager, name string) *Image { } func TestMachineArtifactsPullFromOCI(t *testing.T) { - if _, err := exec.LookPath("qemu-img"); err != nil { - if os.Getenv("CI") == "true" { - t.Fatal("qemu-img is required in CI") - } - t.Skip("qemu-img is unavailable") - } + requireQEMUImg(t) registry := httptest.NewServer(gcrregistry.New()) defer registry.Close() diff --git a/lib/images/machine_test.go b/lib/images/machine_test.go index 3dd96430e..5f97681df 100644 --- a/lib/images/machine_test.go +++ b/lib/images/machine_test.go @@ -4,6 +4,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "testing" @@ -12,6 +13,16 @@ import ( "github.com/stretchr/testify/require" ) +func requireQEMUImg(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("qemu-img"); err != nil { + if os.Getenv("CI") == "true" && runtime.GOOS == "linux" { + t.Fatal("qemu-img is required in Linux CI") + } + t.Skip("qemu-img is unavailable") + } +} + func windowsMachineMetadata(kind MachineImageKind, diskPath, base string) *containerMetadata { format := "raw" if kind == MachineImageWindowsPersona { @@ -64,12 +75,7 @@ func TestMachineArtifactDiskRejectsSymlinkEscape(t *testing.T) { } func TestMaterializeRejectsExternalDiskReferences(t *testing.T) { - if _, err := exec.LookPath("qemu-img"); err != nil { - if os.Getenv("CI") == "true" { - t.Fatal("qemu-img is required in CI") - } - t.Skip("qemu-img is unavailable") - } + requireQEMUImg(t) p := paths.New(t.TempDir()) m := &manager{paths: p} @@ -107,12 +113,7 @@ func TestMaterializeRejectsExternalDiskReferences(t *testing.T) { } func TestMaterializeWindowsBaseFormats(t *testing.T) { - if _, err := exec.LookPath("qemu-img"); err != nil { - if os.Getenv("CI") == "true" { - t.Fatal("qemu-img is required in CI") - } - t.Skip("qemu-img is unavailable") - } + requireQEMUImg(t) formats := []struct { label string @@ -152,12 +153,7 @@ func TestMaterializeWindowsBaseFormats(t *testing.T) { } func TestMaterializeWindowsBaseAndPersona(t *testing.T) { - if _, err := exec.LookPath("qemu-img"); err != nil { - if os.Getenv("CI") == "true" { - t.Fatal("qemu-img is required in CI") - } - t.Skip("qemu-img is unavailable") - } + requireQEMUImg(t) p := paths.New(t.TempDir()) m := &manager{paths: p} From 1fd0c5f48a626f5b95e9842da9e05d38d9f23516 Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:07:36 +0000 Subject: [PATCH 4/7] Isolate the Windows images CI gate --- .github/workflows/test.yml | 17 +++++++++++++++++ .../windows_images_integration_linux_test.go | 3 +++ 2 files changed, 20 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4e5988a68..9025bb130 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -135,6 +135,23 @@ jobs: done exit 1 + - name: Test Windows machine images + 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_IMAGES_INTEGRATION=1" \ + "HYPEMAN_WINDOWS_OVMF_CODE=$HYPEMAN_WINDOWS_OVMF_CODE" \ + "HYPEMAN_WINDOWS_OVMF_VARS=$HYPEMAN_WINDOWS_OVMF_VARS" \ + go test -count=1 -run '^TestWindowsImagesIntegration$' -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 diff --git a/lib/instances/windows_images_integration_linux_test.go b/lib/instances/windows_images_integration_linux_test.go index 5eb869f91..db772bef4 100644 --- a/lib/instances/windows_images_integration_linux_test.go +++ b/lib/instances/windows_images_integration_linux_test.go @@ -53,6 +53,9 @@ func requireWindowsFixture(t *testing.T) string { } func TestWindowsImagesIntegration(t *testing.T) { + if os.Getenv("HYPEMAN_RUN_WINDOWS_IMAGES_INTEGRATION") != "1" { + t.Skip("run by the dedicated Windows images CI gate") + } fixture := requireWindowsFixture(t) acquireHeavyIO(t) From dd0654d80f7bc6f36f3ec211fc92244e06900eeb Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:17:57 +0000 Subject: [PATCH 5/7] Build embedded agent before Windows gates --- .github/workflows/test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9025bb130..051788311 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -137,6 +137,7 @@ jobs: - name: Test Windows machine images 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 \ From 31b5a61691d6c4b9276a722805b1085b43af5f19 Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:14:48 +0000 Subject: [PATCH 6/7] Use image naming for Windows artifacts --- .github/workflows/test.yml | 22 --- docs/windows-images.md | 8 +- lib/images/README.md | 8 ++ lib/images/machine.go | 38 ++--- lib/images/machine_oci_integration_test.go | 22 +-- lib/images/machine_test.go | 54 ++++---- lib/instances/README.md | 8 ++ lib/instances/create.go | 3 +- lib/instances/windows.go | 22 +-- .../windows_images_integration_linux_test.go | 131 ------------------ lib/instances/windows_test.go | 31 +++-- 11 files changed, 105 insertions(+), 242 deletions(-) delete mode 100644 lib/instances/windows_images_integration_linux_test.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 051788311..caf47c339 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -113,10 +113,6 @@ jobs: done test -f "$HYPEMAN_WINDOWS_OVMF_CODE" test -f "$HYPEMAN_WINDOWS_OVMF_VARS" - test -r /ci/windows/base.raw - test -r /ci/windows/persona.qcow2 - qemu-img info --output=json /ci/windows/persona.qcow2 \ - | jq -e '.format == "qcow2" and .["backing-filename-format"] == "raw"' >/dev/null - name: Test Windows hypervisor primitives run: | @@ -135,24 +131,6 @@ jobs: done exit 1 - - name: Test Windows machine images - 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_IMAGES_INTEGRATION=1" \ - "HYPEMAN_WINDOWS_OVMF_CODE=$HYPEMAN_WINDOWS_OVMF_CODE" \ - "HYPEMAN_WINDOWS_OVMF_VARS=$HYPEMAN_WINDOWS_OVMF_VARS" \ - go test -count=1 -run '^TestWindowsImagesIntegration$' -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 diff --git a/docs/windows-images.md b/docs/windows-images.md index 36537d947..0b73a1478 100644 --- a/docs/windows-images.md +++ b/docs/windows-images.md @@ -4,18 +4,16 @@ Hypeman accepts Windows desktop disks as OCI images for `windows/amd64`. Ordinar A machine image uses these OCI config labels: -| Label | Base | Persona | +| Label | Base | Image | |---|---|---| | `io.hypeman.machine-image.version` | `1` | `1` | -| `io.hypeman.machine-image.kind` | `windows-base` | `windows-persona` | +| `io.hypeman.machine-image.kind` | `windows-base` | `windows-image` | | `io.hypeman.machine-image.disk-path` | relative path to the source disk | relative path to a qcow2 delta | | `io.hypeman.machine-image.disk-format` | `raw`, `qcow2`, `vhd`, or `vhdx` | `qcow2` | | `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` | -Hypeman materializes the base as immutable sparse raw. It rewrites the persona's qcow2 backing header to the cache-owned base path, ignoring any artifact-supplied backing path. At instance creation, Hypeman reflink-clones the immutable persona into a writable `windows.qcow2`; the clone remains backed directly by the raw base. - -The base must be pulled before its personas. A base cannot be deleted while any cached persona references its digest. Instance references are not tracked by the image cache, matching existing Linux behavior: do not delete a base while any Windows instance cloned from one of its personas exists. +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. Windows installation media, activation material, credentials, and generated disks belong in private registries and must not be committed to this repository. diff --git a/lib/images/README.md b/lib/images/README.md index eb2d422be..1d948554a 100644 --- a/lib/images/README.md +++ b/lib/images/README.md @@ -49,6 +49,14 @@ OCI Registry → go-containerregistry → OCI Layout → umoci → rootfs/ → m **Alternative:** ext4 without journal works but erofs is optimized for this exact use case +## Windows machine images + +Windows uses the same image-manager contract as Linux: callers pull a named image and create instances from it. The launchable artifact is therefore called a Windows image. A `windows-base` artifact is separate because it is an immutable storage dependency rather than a launchable image. + +Base disks may arrive as raw, qcow2, VHD, or VHDX. Materialization normalizes them to immutable sparse raw so every dependent image has one stable backing format. A `windows-image` artifact is a qcow2 delta with a digest-pinned base reference. Hypeman ignores its supplied backing path and rewrites the header to the cache-owned base, preventing an artifact from retaining an external host path. The image and base must have identical virtual sizes. + +Instance creation reflink-clones the cached Windows image into a private writable qcow2 disk while leaving the cached source immutable. Its virtual size becomes the instance disk size; unlike Linux's separate overlay, this disk is not resized because Windows online partition and filesystem growth are not part of the launch contract. + ## Filesystem Layout (storage.go, oci.go) Content-addressable storage with tag symlinks (similar to Docker/Unikraft): diff --git a/lib/images/machine.go b/lib/images/machine.go index e102cc976..74a511e99 100644 --- a/lib/images/machine.go +++ b/lib/images/machine.go @@ -27,8 +27,8 @@ const ( type MachineImageKind string const ( - MachineImageWindowsBase MachineImageKind = "windows-base" - MachineImageWindowsPersona MachineImageKind = "windows-persona" + MachineImageWindowsBase MachineImageKind = "windows-base" + MachineImageWindowsImage MachineImageKind = "windows-image" ) // MachineImage describes a bootable disk artifact. The OCI manifest remains @@ -86,13 +86,13 @@ func parseMachineImage(meta *containerMetadata) (*MachineImage, error) { if machine.Base != "" { return nil, fmt.Errorf("Windows base image cannot reference another base") } - case MachineImageWindowsPersona: + case MachineImageWindowsImage: if machine.DiskFormat != "qcow2" { - return nil, fmt.Errorf("Windows persona disk format must be qcow2") + return nil, fmt.Errorf("Windows image disk format must be qcow2") } base, err := ParseNormalizedRef(machine.Base) if err != nil || !base.IsDigest() { - return nil, fmt.Errorf("Windows persona base must be a digest-pinned OCI reference") + return nil, fmt.Errorf("Windows image base must be a digest-pinned OCI reference") } default: return nil, fmt.Errorf("unsupported machine image kind %q", machine.Kind) @@ -178,7 +178,7 @@ func (m *manager) materializeMachineImage(ref *ResolvedRef, root string, machine if sourceInfo.Format != sourceFormat { return 0, fmt.Errorf("machine image source format is %s, expected %s", sourceInfo.Format, sourceFormat) } - if err := validateMachineSource(sourceInfo, machine.Kind == MachineImageWindowsPersona); err != nil { + if err := validateMachineSource(sourceInfo, machine.Kind == MachineImageWindowsImage); err != nil { return 0, err } @@ -215,12 +215,12 @@ func (m *manager) materializeMachineImage(ref *ResolvedRef, root string, machine return 0, fmt.Errorf("Windows base disk must be raw, got %s", info.Format) } machine.VirtualSize = info.VirtualSize - case MachineImageWindowsPersona: + case MachineImageWindowsImage: if err := forkvm.CopyRegularFile(source, destination); err != nil { - return 0, fmt.Errorf("materialize Windows persona: %w", err) + return 0, fmt.Errorf("materialize Windows image: %w", err) } if err := os.Chmod(destination, 0600); err != nil { - return 0, fmt.Errorf("make Windows persona writable for validation: %w", err) + return 0, fmt.Errorf("make Windows image writable for validation: %w", err) } basePath, err := m.resolveMachineBase(machine.Base) if err != nil { @@ -228,7 +228,7 @@ func (m *manager) materializeMachineImage(ref *ResolvedRef, root string, machine } output, err := exec.Command("qemu-img", "rebase", "-u", "-f", "qcow2", "-F", "raw", "-b", basePath, destination).CombinedOutput() if err != nil { - return 0, fmt.Errorf("set persona backing file: %w: %s", err, output) + return 0, fmt.Errorf("set image backing file: %w: %s", err, output) } info, err := inspectQEMUImage(destination, "qcow2") if err != nil { @@ -238,14 +238,14 @@ func (m *manager) materializeMachineImage(ref *ResolvedRef, root string, machine return 0, err } if info.Format != "qcow2" || info.BackingFileFormat != "raw" || info.BackingFilename != basePath { - return 0, fmt.Errorf("invalid persona disk backing configuration") + return 0, fmt.Errorf("invalid Windows image disk backing configuration") } baseInfo, err := inspectQEMUImage(basePath, "raw") if err != nil { return 0, err } if info.VirtualSize != baseInfo.VirtualSize { - return 0, fmt.Errorf("persona virtual size %d does not match base %d", info.VirtualSize, baseInfo.VirtualSize) + return 0, fmt.Errorf("image virtual size %d does not match base %d", info.VirtualSize, baseInfo.VirtualSize) } machine.VirtualSize = info.VirtualSize } @@ -278,8 +278,8 @@ func (m *manager) resolveMachineBase(reference string) (string, error) { func machineDiskPath(p *paths.Paths, repository, digestHex string, kind MachineImageKind) string { name := "base.raw" - if kind == MachineImageWindowsPersona { - name = "persona.qcow2" + if kind == MachineImageWindowsImage { + name = "image.qcow2" } return filepath.Join(p.ImageDigestDir(repository, digestHex), name) } @@ -290,12 +290,12 @@ func (m *manager) ensureNoMachineDependents(repository, digestHex string) error return err } for _, meta := range metas { - if meta.Machine == nil || meta.Machine.Kind != MachineImageWindowsPersona { + if meta.Machine == nil || meta.Machine.Kind != MachineImageWindowsImage { continue } base, err := ParseNormalizedRef(meta.Machine.Base) if err == nil && base.Repository() == repository && base.DigestHex() == digestHex { - return fmt.Errorf("cannot delete Windows base while persona %s depends on it", meta.Name) + return fmt.Errorf("cannot delete Windows base while image %s depends on it", meta.Name) } } return nil @@ -313,7 +313,7 @@ func GetMachineDiskPath(p *paths.Paths, imageName, digest string, machine *Machi return machineDiskPath(p, ref.Repository(), strings.TrimPrefix(digest, "sha256:"), machine.Kind), nil } -// IsWindowsPersona reports whether an image is directly launchable as a Windows desktop. -func IsWindowsPersona(image *Image) bool { - return image != nil && image.Machine != nil && image.Machine.Kind == MachineImageWindowsPersona +// IsWindowsImage reports whether an image is directly launchable as a Windows desktop. +func IsWindowsImage(image *Image) bool { + return image != nil && image.Machine != nil && image.Machine.Kind == MachineImageWindowsImage } diff --git a/lib/images/machine_oci_integration_test.go b/lib/images/machine_oci_integration_test.go index de6bd097c..aa2c20742 100644 --- a/lib/images/machine_oci_integration_test.go +++ b/lib/images/machine_oci_integration_test.go @@ -88,21 +88,21 @@ func TestMachineArtifactsPullFromOCI(t *testing.T) { readyBase := waitForMachineImage(t, manager, createdBase.Name) require.Equal(t, MachineImageWindowsBase, readyBase.Machine.Kind) - personaFile := filepath.Join(t.TempDir(), "persona.qcow2") - output, err := exec.Command("qemu-img", "create", "-f", "qcow2", personaFile, "4M").CombinedOutput() + imageFile := filepath.Join(t.TempDir(), "image.qcow2") + output, err := exec.Command("qemu-img", "create", "-f", "qcow2", imageFile, "4M").CombinedOutput() require.NoError(t, err, "%s", output) - personaBytes, err := os.ReadFile(personaFile) + imageBytes, err := os.ReadFile(imageFile) require.NoError(t, err) baseReference := baseTag.Context().Name() + "@" + readyBase.Digest - personaLabels := windowsMachineMetadata(MachineImageWindowsPersona, "hypeman/persona.qcow2", baseReference).Labels - personaImage := machineArtifactOCIImage(t, "hypeman/persona.qcow2", personaBytes, personaLabels) - personaTag, err := name.NewTag(registry.Listener.Addr().String()+"/windows/persona:test", name.Insecure) + imageLabels := windowsMachineMetadata(MachineImageWindowsImage, "hypeman/image.qcow2", baseReference).Labels + ociImage := machineArtifactOCIImage(t, "hypeman/image.qcow2", imageBytes, imageLabels) + imageTag, err := name.NewTag(registry.Listener.Addr().String()+"/windows/image:test", name.Insecure) require.NoError(t, err) - require.NoError(t, remote.Write(personaTag, personaImage)) + require.NoError(t, remote.Write(imageTag, ociImage)) - createdPersona, err := manager.CreateImage(context.Background(), CreateImageRequest{Name: personaTag.String(), Platform: "windows/amd64"}) + createdImage, err := manager.CreateImage(context.Background(), CreateImageRequest{Name: imageTag.String(), Platform: "windows/amd64"}) require.NoError(t, err) - readyPersona := waitForMachineImage(t, manager, createdPersona.Name) - require.Equal(t, MachineImageWindowsPersona, readyPersona.Machine.Kind) - require.Equal(t, readyBase.Machine.VirtualSize, readyPersona.Machine.VirtualSize) + readyImage := waitForMachineImage(t, manager, createdImage.Name) + require.Equal(t, MachineImageWindowsImage, readyImage.Machine.Kind) + require.Equal(t, readyBase.Machine.VirtualSize, readyImage.Machine.VirtualSize) } diff --git a/lib/images/machine_test.go b/lib/images/machine_test.go index 5f97681df..ee2801084 100644 --- a/lib/images/machine_test.go +++ b/lib/images/machine_test.go @@ -25,7 +25,7 @@ func requireQEMUImg(t *testing.T) { func windowsMachineMetadata(kind MachineImageKind, diskPath, base string) *containerMetadata { format := "raw" - if kind == MachineImageWindowsPersona { + if kind == MachineImageWindowsImage { format = "qcow2" } return &containerMetadata{ @@ -48,13 +48,13 @@ func TestParseMachineImage(t *testing.T) { require.NoError(t, err) assert.Equal(t, MachineImageWindowsBase, base.Kind) - persona, err := parseMachineImage(windowsMachineMetadata( - MachineImageWindowsPersona, + image, err := parseMachineImage(windowsMachineMetadata( + MachineImageWindowsImage, "hypeman/disk.qcow2", "registry.example/base@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", )) require.NoError(t, err) - assert.Equal(t, MachineImageWindowsPersona, persona.Kind) + assert.Equal(t, MachineImageWindowsImage, image.Kind) _, err = parseMachineImage(&containerMetadata{OS: "windows", Architecture: "amd64", Labels: map[string]string{}}) assert.ErrorContains(t, err, "ordinary Windows container images are not bootable") @@ -99,16 +99,16 @@ func TestMaterializeRejectsExternalDiskReferences(t *testing.T) { external := filepath.Join(root, "hypeman", "external.qcow2") output, err = exec.Command("qemu-img", "create", "-f", "qcow2", "-o", "data_file="+dataFile+",data_file_raw=on", external, "4M").CombinedOutput() require.NoError(t, err, "%s", output) - persona := windowsMachineMetadata( - MachineImageWindowsPersona, + image := windowsMachineMetadata( + MachineImageWindowsImage, "hypeman/external.qcow2", "registry.example/windows/base@sha256:"+strings.Repeat("6", 64), ) - machine, err = parseMachineImage(persona) + machine, err = parseMachineImage(image) require.NoError(t, err) - personaRef, err := ParseNormalizedRef("registry.example/windows/persona@sha256:" + strings.Repeat("7", 64)) + imageRef, err := ParseNormalizedRef("registry.example/windows/image@sha256:" + strings.Repeat("7", 64)) require.NoError(t, err) - _, err = m.materializeMachineImage(NewResolvedRef(personaRef, personaRef.Digest()), root, machine) + _, err = m.materializeMachineImage(NewResolvedRef(imageRef, imageRef.Digest()), root, machine) assert.ErrorContains(t, err, "unsupported data-file feature") } @@ -152,7 +152,7 @@ func TestMaterializeWindowsBaseFormats(t *testing.T) { } } -func TestMaterializeWindowsBaseAndPersona(t *testing.T) { +func TestMaterializeWindowsBaseAndImage(t *testing.T) { requireQEMUImg(t) p := paths.New(t.TempDir()) @@ -183,27 +183,27 @@ func TestMaterializeWindowsBaseAndPersona(t *testing.T) { SizeBytes: 4 << 20, })) - personaDigest := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - personaRef, err := ParseNormalizedRef("registry.example/windows/persona@sha256:" + personaDigest) + imageDigest := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + imageRef, err := ParseNormalizedRef("registry.example/windows/image@sha256:" + imageDigest) require.NoError(t, err) - resolvedPersona := NewResolvedRef(personaRef, "sha256:"+personaDigest) - personaRoot := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(personaRoot, "hypeman"), 0755)) - personaSource := filepath.Join(personaRoot, "hypeman", "disk.qcow2") - output, err := exec.Command("qemu-img", "create", "-f", "qcow2", personaSource, "4M").CombinedOutput() + resolvedImage := NewResolvedRef(imageRef, "sha256:"+imageDigest) + imageRoot := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(imageRoot, "hypeman"), 0755)) + imageSource := filepath.Join(imageRoot, "hypeman", "disk.qcow2") + output, err := exec.Command("qemu-img", "create", "-f", "qcow2", imageSource, "4M").CombinedOutput() require.NoError(t, err, "%s", output) - personaMachine, err := parseMachineImage(windowsMachineMetadata( - MachineImageWindowsPersona, + imageMachine, err := parseMachineImage(windowsMachineMetadata( + MachineImageWindowsImage, "hypeman/disk.qcow2", baseRef.String(), )) require.NoError(t, err) - _, err = m.materializeMachineImage(resolvedPersona, personaRoot, personaMachine) + _, err = m.materializeMachineImage(resolvedImage, imageRoot, imageMachine) require.NoError(t, err) - personaPath := machineDiskPath(p, personaRef.Repository(), personaDigest, MachineImageWindowsPersona) - info, err := inspectQEMUImage(personaPath, "qcow2") + imagePath := machineDiskPath(p, imageRef.Repository(), imageDigest, MachineImageWindowsImage) + info, err := inspectQEMUImage(imagePath, "qcow2") require.NoError(t, err) assert.Equal(t, "qcow2", info.Format) assert.Equal(t, "raw", info.BackingFileFormat) @@ -211,15 +211,15 @@ func TestMaterializeWindowsBaseAndPersona(t *testing.T) { baseMeta, err := readMetadata(p, baseRef.Repository(), baseDigest) require.NoError(t, err) - personaMeta := &imageMetadata{ - Name: personaRef.String(), - Digest: "sha256:" + personaDigest, + imageMeta := &imageMetadata{ + Name: imageRef.String(), + Digest: "sha256:" + imageDigest, Platform: "windows/amd64", Status: StatusReady, - Machine: personaMachine, + Machine: imageMachine, SizeBytes: info.VirtualSize, } - require.NoError(t, writeMetadata(p, personaRef.Repository(), personaDigest, personaMeta)) + require.NoError(t, writeMetadata(p, imageRef.Repository(), imageDigest, imageMeta)) assert.ErrorContains(t, m.ensureNoMachineDependents(baseRef.Repository(), baseDigest), "depends on it") assert.ErrorContains(t, m.DeleteImage(t.Context(), baseRef.String()), "depends on it") assert.DirExists(t, p.ImageDigestDir(baseRef.Repository(), baseDigest)) diff --git a/lib/instances/README.md b/lib/instances/README.md index 13ada0565..d4015ec0c 100644 --- a/lib/instances/README.md +++ b/lib/instances/README.md @@ -22,6 +22,14 @@ Manages VM instance lifecycle across multiple hypervisors (Cloud Hypervisor, QEM - `Shutdown` - VM shutdown, VMM exists (CH native) - `Standby` - No VMM, snapshot exists (can restore) +### Windows launch defaults + +Windows machine images boot through UEFI with Secure Boot and TPM 2.0. The default 8 GiB memory and 4 vCPUs provide headroom for Windows 11 startup and the guest service; the lower admission limits of 4 GiB and 2 vCPUs permit explicitly sized, constrained workloads without making that minimum the default. + +The launchable Windows image already defines its virtual disk size, so instance creation clones that size exactly. Windows disk, partition, and filesystem growth are not implemented, and an `overlay_size` that differs from the image is rejected rather than silently presenting inconsistent capacity. + +A Windows VM remains `Initializing` until its guest agent answers over VioSock. This avoids treating firmware completion as application readiness. + ### Why Config Disk? (configdisk.go) **What:** Read-only erofs disk with instance configuration diff --git a/lib/instances/create.go b/lib/instances/create.go index 55ec459b4..d4ff30db4 100644 --- a/lib/instances/create.go +++ b/lib/instances/create.go @@ -130,7 +130,8 @@ func (m *manager) createInstance( } windows := isWindowsPlatform(imageInfo.Platform) if windows { - if err := validateWindowsCreate(req, imageInfo, hvType); err != nil { + caps, _ := hypervisor.CapabilitiesForType(hvType) + if err := validateWindowsCreate(req, imageInfo, caps); err != nil { return nil, err } } diff --git a/lib/instances/windows.go b/lib/instances/windows.go index 824aabb93..6a37c6c30 100644 --- a/lib/instances/windows.go +++ b/lib/instances/windows.go @@ -15,15 +15,15 @@ func isWindowsPlatform(platform string) bool { return strings.EqualFold(strings.TrimSpace(platform), "windows/amd64") } -func validateWindowsCreate(req CreateInstanceRequest, image *images.Image, hvType hypervisor.Type) error { - if !images.IsWindowsPersona(image) { - return fmt.Errorf("%w: Windows instances require a persona image", ErrInvalidRequest) +func validateWindowsCreate(req CreateInstanceRequest, image *images.Image, caps hypervisor.Capabilities) error { + if !images.IsWindowsImage(image) { + return fmt.Errorf("%w: Windows instances require a Windows machine image", ErrInvalidRequest) } - if hvType != hypervisor.TypeQEMU { - return fmt.Errorf("%w: Windows instances require the qemu hypervisor", ErrInvalidRequest) + if !caps.SupportsUEFIBoot || !caps.SupportsTPM { + return fmt.Errorf("%w: selected hypervisor must support UEFI boot and TPM devices", ErrInvalidRequest) } if image.Machine.VirtualSize <= 0 { - return fmt.Errorf("%w: Windows persona is missing its virtual disk size", ErrInvalidRequest) + return fmt.Errorf("%w: Windows image is missing its virtual disk size", ErrInvalidRequest) } if req.HotplugSize != 0 { return fmt.Errorf("%w: Windows instances do not yet support hotplug memory", ErrInvalidRequest) @@ -87,12 +87,12 @@ func windowsFirmwareTemplates() (string, string, error) { } func (m *manager) prepareWindowsInstance(inst *StoredMetadata, image *images.Image) error { - persona, err := images.GetMachineDiskPath(m.paths, image.Name, image.Digest, image.Machine) + source, err := images.GetMachineDiskPath(m.paths, image.Name, image.Digest, image.Machine) if err != nil { return err } - if err := forkvm.CopyRegularFile(persona, m.paths.InstanceWindowsDisk(inst.Id)); err != nil { - return fmt.Errorf("clone Windows persona: %w", err) + if err := forkvm.CopyRegularFile(source, m.paths.InstanceWindowsDisk(inst.Id)); err != nil { + return fmt.Errorf("clone Windows image: %w", err) } if err := os.Chmod(m.paths.InstanceWindowsDisk(inst.Id), 0600); err != nil { return fmt.Errorf("make Windows instance disk writable: %w", err) @@ -121,8 +121,8 @@ func (m *manager) prepareWindowsInstance(inst *StoredMetadata, image *images.Ima } func (m *manager) buildWindowsHypervisorConfig(inst *Instance, image *images.Image, netConfig *network.NetworkConfig) (hypervisor.VMConfig, error) { - if !images.IsWindowsPersona(image) { - return hypervisor.VMConfig{}, fmt.Errorf("image is not a Windows persona") + if !images.IsWindowsImage(image) { + return hypervisor.VMConfig{}, fmt.Errorf("image is not a Windows machine image") } if _, err := os.Stat(m.paths.InstanceWindowsDisk(inst.Id)); err != nil { return hypervisor.VMConfig{}, fmt.Errorf("stat Windows instance disk: %w", err) diff --git a/lib/instances/windows_images_integration_linux_test.go b/lib/instances/windows_images_integration_linux_test.go deleted file mode 100644 index db772bef4..000000000 --- a/lib/instances/windows_images_integration_linux_test.go +++ /dev/null @@ -1,131 +0,0 @@ -//go:build linux && amd64 - -package instances - -import ( - "context" - "crypto/sha256" - "os" - "os/exec" - "path/filepath" - "testing" - "time" - - "github.com/kernel/hypeman/lib/forkvm" - "github.com/kernel/hypeman/lib/hypervisor" - "github.com/kernel/hypeman/lib/images" - "github.com/kernel/hypeman/lib/paths" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -type windowsFixtureImageManager struct { - images.Manager - image *images.Image -} - -func (m windowsFixtureImageManager) CreateImage(context.Context, images.CreateImageRequest) (*images.Image, error) { - copy := *m.image - return ©, nil -} - -func (m windowsFixtureImageManager) GetImage(context.Context, string) (*images.Image, error) { - copy := *m.image - return ©, nil -} - -func (m windowsFixtureImageManager) WaitForReady(context.Context, string) error { return nil } - -func requireWindowsFixture(t *testing.T) string { - t.Helper() - path := os.Getenv("HYPEMAN_WINDOWS_TEST_PERSONA") - if path == "" { - path = "/ci/windows/persona.qcow2" - } - if _, err := os.Stat(path); err == nil { - return path - } - if os.Getenv("CI") == "true" { - t.Fatalf("required Windows persona fixture is missing: %s", path) - } - t.Skipf("Windows persona fixture is unavailable: %s", path) - return "" -} - -func TestWindowsImagesIntegration(t *testing.T) { - if os.Getenv("HYPEMAN_RUN_WINDOWS_IMAGES_INTEGRATION") != "1" { - t.Skip("run by the dedicated Windows images CI gate") - } - fixture := requireWindowsFixture(t) - acquireHeavyIO(t) - - manager, dataDir := setupTestManagerForQEMU(t) - p := paths.New(dataDir) - const digestHex = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" - image := &images.Image{ - Name: "registry.example/windows/persona:integration", - Digest: "sha256:" + digestHex, - Platform: "windows/amd64", - Status: images.StatusReady, - Machine: &images.MachineImage{ - Kind: images.MachineImageWindowsPersona, - Base: "registry.example/windows/base@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", - TPM: "2.0", - SecureBoot: "required", - VirtualSize: 80 << 30, - }, - } - manager.imageManager = windowsFixtureImageManager{image: image} - - personaPath, err := images.GetMachineDiskPath(p, image.Name, image.Digest, image.Machine) - require.NoError(t, err) - require.NoError(t, forkvm.CopyRegularFile(fixture, personaPath)) - require.NoError(t, os.Chmod(personaPath, 0444)) - personaBytes, err := os.ReadFile(personaPath) - require.NoError(t, err) - personaHash := sha256.Sum256(personaBytes) - personaInfo, err := os.Stat(personaPath) - require.NoError(t, err) - - ctx := context.Background() - instance, err := manager.CreateInstance(ctx, CreateInstanceRequest{ - Name: "windows-images-integration", - Image: image.Name, - Platform: "windows/amd64", - Size: 8 << 30, - Vcpus: 4, - Hypervisor: hypervisor.TypeQEMU, - }) - require.NoError(t, err) - instanceID := instance.Id - t.Cleanup(func() { - if instanceID != "" { - _ = deleteTestInstanceNow(context.Background(), manager, instanceID) - } - }) - require.Equal(t, StateInitializing, instance.State) - require.FileExists(t, p.InstanceWindowsDisk(instance.Id)) - require.FileExists(t, p.InstanceOVMFVars(instance.Id)) - require.DirExists(t, p.InstanceTPMDir(instance.Id)) - assert.NoFileExists(t, p.InstanceOverlay(instance.Id)) - assert.NoFileExists(t, p.InstanceConfigDisk(instance.Id)) - - require.Eventually(t, func() bool { - info, err := os.Stat(p.InstanceWindowsDisk(instance.Id)) - return err == nil && info.Size() > personaInfo.Size() - }, 60*time.Second, 500*time.Millisecond, "Windows boot must write to the instance qcow2") - - sourceAfter, err := os.ReadFile(personaPath) - require.NoError(t, err) - assert.Equal(t, personaHash, sha256.Sum256(sourceAfter), "immutable persona changed during guest boot") - - stopped, err := manager.StopInstance(ctx, instance.Id) - require.NoError(t, err) - require.Equal(t, StateStopped, stopped.State) - output, err := exec.Command("qemu-img", "check", p.InstanceWindowsDisk(instance.Id)).CombinedOutput() - require.NoError(t, err, "%s", output) - - require.NoError(t, manager.DeleteInstance(ctx, instance.Id)) - instanceID = "" - assert.NoDirExists(t, filepath.Dir(p.InstanceWindowsDisk(instance.Id))) -} diff --git a/lib/instances/windows_test.go b/lib/instances/windows_test.go index e1365897d..130d7b165 100644 --- a/lib/instances/windows_test.go +++ b/lib/instances/windows_test.go @@ -12,14 +12,14 @@ import ( "github.com/stretchr/testify/require" ) -func windowsPersonaFixture() *images.Image { +func windowsImageFixture() *images.Image { return &images.Image{ - Name: "registry.example/windows/persona:test", + Name: "registry.example/windows/image:test", Digest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Platform: "windows/amd64", Status: images.StatusReady, Machine: &images.MachineImage{ - Kind: images.MachineImageWindowsPersona, + Kind: images.MachineImageWindowsImage, Base: "registry.example/windows/base@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", TPM: "2.0", SecureBoot: "required", @@ -29,25 +29,26 @@ func windowsPersonaFixture() *images.Image { } func TestValidateWindowsCreate(t *testing.T) { - image := windowsPersonaFixture() - require.NoError(t, validateWindowsCreate(CreateInstanceRequest{}, image, hypervisor.TypeQEMU)) + image := windowsImageFixture() + windowsCaps := hypervisor.Capabilities{SupportsUEFIBoot: true, SupportsTPM: true} + require.NoError(t, validateWindowsCreate(CreateInstanceRequest{}, image, windowsCaps)) tests := []struct { name string req CreateInstanceRequest - hv hypervisor.Type + caps hypervisor.Capabilities }{ - {name: "wrong hypervisor", hv: hypervisor.TypeCloudHypervisor}, - {name: "networking", hv: hypervisor.TypeQEMU, req: CreateInstanceRequest{NetworkEnabled: true}}, - {name: "small memory", hv: hypervisor.TypeQEMU, req: CreateInstanceRequest{Size: 2 << 30}}, - {name: "one CPU", hv: hypervisor.TypeQEMU, req: CreateInstanceRequest{Vcpus: 1}}, - {name: "command", hv: hypervisor.TypeQEMU, req: CreateInstanceRequest{Cmd: []string{"cmd.exe"}}}, - {name: "snapshot policy", hv: hypervisor.TypeQEMU, req: CreateInstanceRequest{SnapshotPolicy: &SnapshotPolicy{}}}, - {name: "auto standby", hv: hypervisor.TypeQEMU, req: CreateInstanceRequest{AutoStandby: &autostandby.Policy{}}}, + {name: "missing boot capabilities"}, + {name: "networking", caps: windowsCaps, req: CreateInstanceRequest{NetworkEnabled: true}}, + {name: "small memory", caps: windowsCaps, req: CreateInstanceRequest{Size: 2 << 30}}, + {name: "one CPU", caps: windowsCaps, req: CreateInstanceRequest{Vcpus: 1}}, + {name: "command", caps: windowsCaps, req: CreateInstanceRequest{Cmd: []string{"cmd.exe"}}}, + {name: "snapshot policy", caps: windowsCaps, req: CreateInstanceRequest{SnapshotPolicy: &SnapshotPolicy{}}}, + {name: "auto standby", caps: windowsCaps, req: CreateInstanceRequest{AutoStandby: &autostandby.Policy{}}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Error(t, validateWindowsCreate(tt.req, image, tt.hv)) + assert.Error(t, validateWindowsCreate(tt.req, image, tt.caps)) }) } } @@ -66,7 +67,7 @@ func TestBuildWindowsHypervisorConfig(t *testing.T) { require.NoError(t, os.WriteFile(path, []byte("fixture"), 0600)) } - config, err := m.buildWindowsHypervisorConfig(&Instance{StoredMetadata: stored}, windowsPersonaFixture(), nil) + config, err := m.buildWindowsHypervisorConfig(&Instance{StoredMetadata: stored}, windowsImageFixture(), nil) require.NoError(t, err) assert.Equal(t, hypervisor.BootModeUEFI, config.BootMode) assert.True(t, config.Firmware.SecureBoot) From 0dc80f4ac71f7448e8f89e227557824ba89e39ba Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Fri, 21 Aug 2026 00:13:10 +0000 Subject: [PATCH 7/7] Protect pending Windows image dependencies --- lib/images/machine.go | 17 ++++++++++++++++- lib/images/machine_test.go | 36 ++++++++++++++++++++++++++++++++++++ lib/images/manager.go | 8 ++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/lib/images/machine.go b/lib/images/machine.go index 74a511e99..66fccd361 100644 --- a/lib/images/machine.go +++ b/lib/images/machine.go @@ -284,13 +284,28 @@ func machineDiskPath(p *paths.Paths, repository, digestHex string, kind MachineI return filepath.Join(p.ImageDigestDir(repository, digestHex), name) } +func (m *manager) recordMachineDependency(ref *ResolvedRef, machine *MachineImage, buildID string) error { + m.createMu.Lock() + defer m.createMu.Unlock() + + meta, err := readMetadata(m.paths, ref.Repository(), ref.DigestHex()) + if err != nil || meta.BuildID != buildID { + return errStaleBuild + } + meta.Machine = machine + if err := writeMetadata(m.paths, ref.Repository(), ref.DigestHex(), meta); err != nil { + return fmt.Errorf("record machine image dependency: %w", err) + } + return nil +} + func (m *manager) ensureNoMachineDependents(repository, digestHex string) error { metas, err := listAllMetadata(m.paths) if err != nil { return err } for _, meta := range metas { - if meta.Machine == nil || meta.Machine.Kind != MachineImageWindowsImage { + if meta.Status == StatusFailed || meta.Machine == nil || meta.Machine.Kind != MachineImageWindowsImage { continue } base, err := ParseNormalizedRef(meta.Machine.Base) diff --git a/lib/images/machine_test.go b/lib/images/machine_test.go index ee2801084..c01495be0 100644 --- a/lib/images/machine_test.go +++ b/lib/images/machine_test.go @@ -152,6 +152,42 @@ func TestMaterializeWindowsBaseFormats(t *testing.T) { } } +func TestPendingWindowsImageBlocksBaseDeletion(t *testing.T) { + p := paths.New(t.TempDir()) + m := &manager{paths: p} + baseDigest := strings.Repeat("a", 64) + imageDigest := strings.Repeat("b", 64) + baseName := "registry.example/windows/base@sha256:" + baseDigest + imageName := "registry.example/windows/image@sha256:" + imageDigest + + require.NoError(t, writeMetadata(p, "registry.example/windows/base", baseDigest, &imageMetadata{ + Name: baseName, + Digest: "sha256:" + baseDigest, + Platform: "windows/amd64", + Status: StatusReady, + Machine: &MachineImage{Kind: MachineImageWindowsBase}, + })) + require.NoError(t, os.WriteFile(machineDiskPath(p, "registry.example/windows/base", baseDigest, MachineImageWindowsBase), []byte("base"), 0444)) + const buildID = "pending-build" + require.NoError(t, writeMetadata(p, "registry.example/windows/image", imageDigest, &imageMetadata{ + Name: imageName, + Digest: "sha256:" + imageDigest, + Platform: "windows/amd64", + Status: StatusPending, + BuildID: buildID, + })) + ref, err := ParseNormalizedRef(imageName) + require.NoError(t, err) + require.NoError(t, m.recordMachineDependency(NewResolvedRef(ref, ref.Digest()), &MachineImage{ + Kind: MachineImageWindowsImage, + Base: baseName, + }, buildID)) + + err = m.DeleteImage(t.Context(), baseName) + assert.ErrorContains(t, err, "depends on it") + assert.DirExists(t, p.ImageDigestDir("registry.example/windows/base", baseDigest)) +} + func TestMaterializeWindowsBaseAndImage(t *testing.T) { requireQEMUImg(t) diff --git a/lib/images/manager.go b/lib/images/manager.go index 9d0845913..035198e9b 100644 --- a/lib/images/manager.go +++ b/lib/images/manager.go @@ -470,6 +470,14 @@ func (m *manager) buildImage(ctx context.Context, ref *ResolvedRef, credentials m.updateStatusByDigest(ref, StatusFailed, err, buildID) return } + if machine != nil { + if err := m.recordMachineDependency(ref, machine, buildID); err != nil { + if !errors.Is(err, errStaleBuild) { + m.updateStatusByDigest(ref, StatusFailed, err, buildID) + } + return + } + } convertStart := time.Now() var diskSize int64