Skip to content

Commit f70591d

Browse files
authored
feat(image boot): Add vhdfixed format support (#497)
Extend azldev image boot to accept VHD format images,
1 parent 9385a78 commit f70591d

File tree

2 files changed

+133
-17
lines changed

2 files changed

+133
-17
lines changed

internal/app/azldev/cmds/image/boot.go

Lines changed: 83 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,16 @@ const (
4242
ImageFormatRaw ImageFormat = "raw"
4343
// ImageFormatQcow2 is the QEMU copy-on-write v2 format.
4444
ImageFormatQcow2 ImageFormat = "qcow2"
45+
// ImageFormatVhd is the Hyper-V VHD format (QEMU driver: vpc).
46+
ImageFormatVhd ImageFormat = "vhd"
4547
// ImageFormatVhdx is the Hyper-V virtual hard disk format.
4648
ImageFormatVhdx ImageFormat = "vhdx"
4749
)
4850

4951
// SupportedImageFormats returns the list of supported bootable image formats in priority order.
5052
// When multiple formats exist, the first match in this order is selected.
5153
func SupportedImageFormats() []string {
52-
return []string{string(ImageFormatRaw), string(ImageFormatQcow2), string(ImageFormatVhdx)}
54+
return []string{string(ImageFormatRaw), string(ImageFormatQcow2), string(ImageFormatVhdx), string(ImageFormatVhd)}
5355
}
5456

5557
// Assert that [ImageFormat] implements the [pflag.Value] interface.
@@ -66,6 +68,8 @@ func (f *ImageFormat) Set(value string) error {
6668
*f = ImageFormatRaw
6769
case string(ImageFormatQcow2):
6870
*f = ImageFormatQcow2
71+
case string(ImageFormatVhd):
72+
*f = ImageFormatVhd
6973
case string(ImageFormatVhdx):
7074
*f = ImageFormatVhdx
7175
default:
@@ -80,6 +84,18 @@ func (f *ImageFormat) Type() string {
8084
return "format"
8185
}
8286

87+
// QEMUDriver returns the QEMU block driver name for the image format.
88+
// Most formats use their string value directly, but some (e.g., vhd → vpc)
89+
// require translation.
90+
func QEMUDriver(format string) string {
91+
switch format {
92+
case string(ImageFormatVhd):
93+
return "vpc"
94+
default:
95+
return format
96+
}
97+
}
98+
8399
// ImageBootOptions contains options for the boot command.
84100
type ImageBootOptions struct {
85101
Arch qemu.Arch
@@ -155,7 +171,7 @@ Requirements:
155171

156172
func addBootFlags(cmd *cobra.Command, options *ImageBootOptions) {
157173
cmd.Flags().VarP(&options.Format, "format", "f",
158-
"Image format to boot (raw, qcow2, vhdx). Auto-detected if not specified.")
174+
"Image format to boot (raw, qcow2, vhdx, vhd). Auto-detected if not specified.")
159175

160176
cmd.Flags().StringVar(&options.TestUserName, "test-user", "test", "Name for the test account")
161177
cmd.Flags().StringVar(&options.TestUserPassword, "test-password", "",
@@ -239,6 +255,19 @@ func bootImage(env *azldev.Env, options *ImageBootOptions) error {
239255
slog.String("path", imagePath),
240256
slog.String("format", imageFormat),
241257
)
258+
} else {
259+
// Infer format from the file extension when --image-path is used.
260+
var err error
261+
262+
imageFormat, err = InferImageFormat(imagePath)
263+
if err != nil {
264+
return err
265+
}
266+
267+
slog.Info("Inferred image format from file extension",
268+
slog.String("path", imagePath),
269+
slog.String("format", imageFormat),
270+
)
242271
}
243272

244273
// Dry-run mode: log what would be executed and return early.
@@ -255,9 +284,20 @@ func bootImage(env *azldev.Env, options *ImageBootOptions) error {
255284
return bootImageUsingDiskFile(env, options, arch, imagePath, imageFormat)
256285
}
257286

287+
// fileExtensionsForFormat returns the file extensions to search for the given format.
288+
// Most formats have a single extension matching the format name, but vhd accepts
289+
// both .vhd and .vhdfixed since QEMU treats them identically.
290+
func fileExtensionsForFormat(format string) []string {
291+
if format == string(ImageFormatVhd) {
292+
return []string{"vhd", "vhdfixed"}
293+
}
294+
295+
return []string{format}
296+
}
297+
258298
// findBootableImageArtifact locates a bootable image artifact in the output directory for the
259299
// given image name. If format is specified, only that format is searched. Otherwise, formats
260-
// are searched in priority order (raw, qcow2, vhdx) and the first match is returned.
300+
// are searched in priority order (raw, qcow2, vhdx, vhd) and the first match is returned.
261301
func findBootableImageArtifact(
262302
env *azldev.Env, imageName, format string,
263303
) (imagePath, imageFormat string, err error) {
@@ -290,16 +330,18 @@ func findBootableImageArtifact(
290330

291331
// Search for bootable artifacts in priority order.
292332
for _, currentFormat := range formatsToSearch {
293-
pattern := filepath.Join(imageOutputDir, "*."+currentFormat)
333+
for _, ext := range fileExtensionsForFormat(currentFormat) {
334+
pattern := filepath.Join(imageOutputDir, "*."+ext)
294335

295-
matches, globErr := fileutils.Glob(env.FS(), pattern)
296-
if globErr != nil {
297-
continue
298-
}
336+
matches, globErr := fileutils.Glob(env.FS(), pattern)
337+
if globErr != nil {
338+
continue
339+
}
299340

300-
if len(matches) > 0 {
301-
// Return the first match for the highest-priority format.
302-
return matches[0], currentFormat, nil
341+
if len(matches) > 0 {
342+
// Return the first match for the highest-priority format.
343+
return matches[0], currentFormat, nil
344+
}
303345
}
304346
}
305347

@@ -326,6 +368,35 @@ func findBootableImageArtifact(
326368
)
327369
}
328370

371+
// InferImageFormat determines the image format from the file extension.
372+
// Returns an error if the extension does not match a supported format.
373+
func InferImageFormat(imagePath string) (string, error) {
374+
ext := strings.ToLower(filepath.Ext(imagePath))
375+
if ext == "" {
376+
return "", fmt.Errorf(
377+
"cannot infer image format from %#q: no file extension",
378+
imagePath,
379+
)
380+
}
381+
382+
// Strip the leading dot (e.g., ".vhdfixed" → "vhdfixed").
383+
format := ext[1:]
384+
if format == "vhdfixed" {
385+
format = string(ImageFormatVhd)
386+
}
387+
388+
// Validate the inferred format is supported.
389+
supported := SupportedImageFormats()
390+
if !lo.Contains(supported, format) {
391+
return "", fmt.Errorf(
392+
"unsupported image format %#q inferred from %#q; supported formats: %v",
393+
format, imagePath, supported,
394+
)
395+
}
396+
397+
return format, nil
398+
}
399+
329400
// listImageArtifacts returns a list of image artifact filenames in the given directory.
330401
func listImageArtifacts(env *azldev.Env, dir string) ([]string, error) {
331402
entries, err := fileutils.ReadDir(env.FS(), dir)
@@ -428,7 +499,7 @@ func bootImageUsingDiskFile(
428499
FirmwarePath: fwPath,
429500
NVRAMPath: nvramPath,
430501
DiskPath: selectedDiskPath,
431-
DiskType: imageFormat,
502+
DiskType: QEMUDriver(imageFormat),
432503
CloudInitISOPath: cloudInitMetadataIsoPath,
433504
SecureBoot: options.SecureBoot,
434505
SSHPort: int(options.SSHPort),

internal/app/azldev/cmds/image/boot_test.go

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,23 @@ func TestImageFormat_Set_InvalidFormat(t *testing.T) {
4949
}
5050

5151
func TestImageFormat_Set_ValidFormats(t *testing.T) {
52-
validFormats := []string{"raw", "qcow2", "vhdx"}
53-
for _, formatStr := range validFormats {
54-
t.Run(formatStr, func(t *testing.T) {
52+
tests := []struct {
53+
input string
54+
expected string
55+
}{
56+
{input: "raw", expected: "raw"},
57+
{input: "qcow2", expected: "qcow2"},
58+
{input: "vhdx", expected: "vhdx"},
59+
{input: "vhd", expected: "vhd"},
60+
}
61+
62+
for _, test := range tests {
63+
t.Run(test.input, func(t *testing.T) {
5564
var format image.ImageFormat
5665

57-
err := format.Set(formatStr)
66+
err := format.Set(test.input)
5867
require.NoError(t, err)
59-
assert.Equal(t, formatStr, format.String())
68+
assert.Equal(t, test.expected, format.String())
6069
})
6170
}
6271
}
@@ -145,6 +154,42 @@ func TestSupportedImageFormats(t *testing.T) {
145154
assert.Contains(t, formats, "raw")
146155
assert.Contains(t, formats, "qcow2")
147156
assert.Contains(t, formats, "vhdx")
157+
assert.Contains(t, formats, "vhd")
158+
}
159+
160+
func TestInferImageFormat(t *testing.T) {
161+
tests := []struct {
162+
name string
163+
path string
164+
expected string
165+
}{
166+
{name: "raw", path: "/path/to/image.raw", expected: "raw"},
167+
{name: "qcow2", path: "/path/to/image.qcow2", expected: "qcow2"},
168+
{name: "vhd", path: "/path/to/image.vhd", expected: "vhd"},
169+
{name: "vhdfixed", path: "/path/to/image.vhdfixed", expected: "vhd"},
170+
{name: "vhdx", path: "/path/to/image.vhdx", expected: "vhdx"},
171+
}
172+
173+
for _, test := range tests {
174+
t.Run(test.name, func(t *testing.T) {
175+
format, err := image.InferImageFormat(test.path)
176+
require.NoError(t, err)
177+
assert.Equal(t, test.expected, format)
178+
})
179+
}
180+
}
181+
182+
func TestInferImageFormat_NoExtension(t *testing.T) {
183+
_, err := image.InferImageFormat("/path/to/imagefile")
184+
require.Error(t, err)
185+
assert.Contains(t, err.Error(), "no file extension")
186+
}
187+
188+
func TestInferImageFormat_UnsupportedExtension(t *testing.T) {
189+
_, err := image.InferImageFormat("/path/to/image.iso")
190+
require.Error(t, err)
191+
assert.Contains(t, err.Error(), "unsupported image format")
192+
assert.Contains(t, err.Error(), "iso")
148193
}
149194

150195
func TestSupportedArchitectures(t *testing.T) {

0 commit comments

Comments
 (0)