diff --git a/docs/windows-images.md b/docs/windows-images.md new file mode 100644 index 000000000..0b73a1478 --- /dev/null +++ b/docs/windows-images.md @@ -0,0 +1,19 @@ +# 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 | Image | +|---|---|---| +| `io.hypeman.machine-image.version` | `1` | `1` | +| `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` | + +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 new file mode 100644 index 000000000..66fccd361 --- /dev/null +++ b/lib/images/machine.go @@ -0,0 +1,334 @@ +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" + MachineImageWindowsImage MachineImageKind = "windows-image" +) + +// 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 MachineImageWindowsImage: + if machine.DiskFormat != "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 image 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"` + FormatSpecific struct { + Type string `json:"type"` + Data map[string]json.RawMessage `json:"data"` + } `json:"format-specific"` +} + +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) + } + 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 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 == MachineImageWindowsImage); 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 { + 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) + } + } + if err != nil { + return 0, fmt.Errorf("materialize Windows base: %w", err) + } + info, err := inspectQEMUImage(destination, "raw") + 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 MachineImageWindowsImage: + if err := forkvm.CopyRegularFile(source, destination); err != nil { + return 0, fmt.Errorf("materialize Windows image: %w", err) + } + if err := os.Chmod(destination, 0600); err != nil { + return 0, fmt.Errorf("make Windows image 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 image backing file: %w: %s", err, output) + } + 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 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("image 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 == MachineImageWindowsImage { + name = "image.qcow2" + } + 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.Status == StatusFailed || 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 image %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 +} + +// 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 new file mode 100644 index 000000000..aa2c20742 --- /dev/null +++ b/lib/images/machine_oci_integration_test.go @@ -0,0 +1,108 @@ +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) { + requireQEMUImg(t) + + 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) + + 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) + imageBytes, err := os.ReadFile(imageFile) + require.NoError(t, err) + baseReference := baseTag.Context().Name() + "@" + readyBase.Digest + 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(imageTag, ociImage)) + + createdImage, err := manager.CreateImage(context.Background(), CreateImageRequest{Name: imageTag.String(), Platform: "windows/amd64"}) + require.NoError(t, err) + 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 new file mode 100644 index 000000000..c01495be0 --- /dev/null +++ b/lib/images/machine_test.go @@ -0,0 +1,263 @@ +package images + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/kernel/hypeman/lib/paths" + "github.com/stretchr/testify/assert" + "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 == MachineImageWindowsImage { + 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) + + image, err := parseMachineImage(windowsMachineMetadata( + MachineImageWindowsImage, + "hypeman/disk.qcow2", + "registry.example/base@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + )) + require.NoError(t, err) + 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") + + 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 TestMaterializeRejectsExternalDiskReferences(t *testing.T) { + requireQEMUImg(t) + + 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) + image := windowsMachineMetadata( + MachineImageWindowsImage, + "hypeman/external.qcow2", + "registry.example/windows/base@sha256:"+strings.Repeat("6", 64), + ) + machine, err = parseMachineImage(image) + require.NoError(t, err) + imageRef, err := ParseNormalizedRef("registry.example/windows/image@sha256:" + strings.Repeat("7", 64)) + require.NoError(t, err) + _, err = m.materializeMachineImage(NewResolvedRef(imageRef, imageRef.Digest()), root, machine) + assert.ErrorContains(t, err, "unsupported data-file feature") +} + +func TestMaterializeWindowsBaseFormats(t *testing.T) { + requireQEMUImg(t) + + 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), "raw") + require.NoError(t, err) + assert.Equal(t, "raw", info.Format) + }) + } +} + +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) + + 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, + })) + + imageDigest := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + imageRef, err := ParseNormalizedRef("registry.example/windows/image@sha256:" + imageDigest) + require.NoError(t, err) + 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) + + imageMachine, err := parseMachineImage(windowsMachineMetadata( + MachineImageWindowsImage, + "hypeman/disk.qcow2", + baseRef.String(), + )) + require.NoError(t, err) + _, err = m.materializeMachineImage(resolvedImage, imageRoot, imageMachine) + require.NoError(t, err) + + 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) + assert.Equal(t, machineDiskPath(p, baseRef.Repository(), baseDigest, MachineImageWindowsBase), info.BackingFilename) + + baseMeta, err := readMetadata(p, baseRef.Repository(), baseDigest) + require.NoError(t, err) + imageMeta := &imageMetadata{ + Name: imageRef.String(), + Digest: "sha256:" + imageDigest, + Platform: "windows/amd64", + Status: StatusReady, + Machine: imageMachine, + SizeBytes: info.VirtualSize, + } + 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)) + assert.Equal(t, MachineImageWindowsBase, baseMeta.toImage().Machine.Kind) +} diff --git a/lib/images/manager.go b/lib/images/manager.go index c1a0da0ac..035198e9b 100644 --- a/lib/images/manager.go +++ b/lib/images/manager.go @@ -465,18 +465,36 @@ 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 + } + 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() - 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 +507,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 +536,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 +706,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 +727,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/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 4e2245b73..d4ff30db4 100644 --- a/lib/instances/create.go +++ b/lib/instances/create.go @@ -128,6 +128,13 @@ 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 { + caps, _ := hypervisor.CapabilitiesForType(hvType) + if err := validateWindowsCreate(req, imageInfo, caps); 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 +154,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 +195,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 +214,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 +385,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 +400,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 +508,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 +837,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/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/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..6a37c6c30 --- /dev/null +++ b/lib/instances/windows.go @@ -0,0 +1,167 @@ +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, caps hypervisor.Capabilities) error { + if !images.IsWindowsImage(image) { + return fmt.Errorf("%w: Windows instances require a Windows machine image", 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 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) + } + 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) + } + 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 +} + +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 { + source, err := images.GetMachineDiskPath(m.paths, image.Name, image.Digest, image.Machine) + if err != nil { + return 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) + } + + 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.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) + } + + 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_test.go b/lib/instances/windows_test.go new file mode 100644 index 000000000..130d7b165 --- /dev/null +++ b/lib/instances/windows_test.go @@ -0,0 +1,79 @@ +package instances + +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" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func windowsImageFixture() *images.Image { + return &images.Image{ + Name: "registry.example/windows/image:test", + Digest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + Platform: "windows/amd64", + Status: images.StatusReady, + Machine: &images.MachineImage{ + Kind: images.MachineImageWindowsImage, + Base: "registry.example/windows/base@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + TPM: "2.0", + SecureBoot: "required", + VirtualSize: 80 << 30, + }, + } +} + +func TestValidateWindowsCreate(t *testing.T) { + image := windowsImageFixture() + windowsCaps := hypervisor.Capabilities{SupportsUEFIBoot: true, SupportsTPM: true} + require.NoError(t, validateWindowsCreate(CreateInstanceRequest{}, image, windowsCaps)) + + tests := []struct { + name string + req CreateInstanceRequest + caps hypervisor.Capabilities + }{ + {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.caps)) + }) + } +} + +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} + 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}, windowsImageFixture(), 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")