Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +116 to +119

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

double check for potential concurrency in test running issues


- name: Test Windows hypervisor primitives
run: |
Expand All @@ -131,6 +135,24 @@ 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
Expand Down
21 changes: 21 additions & 0 deletions docs/windows-images.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Windows machine images

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

user facing style docs can go where this is, and internal "how it works but not the structure of the code" goes in lib/**/README.md, that's more like the context for why things work as they do


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. 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.
319 changes: 319 additions & 0 deletions lib/images/machine.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,319 @@
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"

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm unsure about codifying the "persona" word. is there naming here that might make more sense...? let's discuss

)

// 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"`
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) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I need the right lib/**/README.md to explain the processing and when it happens and why etc

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 {
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 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, "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, "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)
}
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
}
Loading
Loading