-
Notifications
You must be signed in to change notification settings - Fork 23
Add OCI Windows machine images #429
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f1164ac
1c19d69
a6c00b9
cd2b077
ab190c7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| # Windows machine images | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||
| 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" | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) { | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| } | ||
There was a problem hiding this comment.
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