diff --git a/docs/user/reference/cli/azldev_image_boot.md b/docs/user/reference/cli/azldev_image_boot.md index 68ba797e..0681e07c 100644 --- a/docs/user/reference/cli/azldev_image_boot.md +++ b/docs/user/reference/cli/azldev_image_boot.md @@ -8,15 +8,26 @@ Boot an Azure Linux image in a QEMU VM Boot an Azure Linux image in a QEMU virtual machine. -This command starts a QEMU VM with the specified disk image, setting up a test user -via cloud-init for access. SSH is forwarded to the host on the specified port (default 8888). - -The image can be specified either by name (positional argument) which will look up the -built image in the output directory, or by explicit path using --image-path. +This command starts a QEMU VM with the specified disk image and/or bootable ISO. +SSH is forwarded to the host on the specified port (default 8888). If cloud-init +credentials are provided, a NoCloud seed ISO is generated and attached; the guest +will consume it only if cloud-init is installed and enabled. + +Image sources (at least one is required): + - IMAGE_NAME (positional): Look up a built image in the project output directory. + - '--image-path': Explicit path to a disk image (may also be combined with + IMAGE_NAME to override the default location). + - '--iso': Bootable ISO (livecd, installer, rescue). May be combined + with a disk image, or used alone to boot an empty disk. + +When '--iso' is used without a disk image, an empty qcow2 disk is created (size set +via '--disk-size') for the live/installer ISO to install onto. The VM console is +serial-only (-nographic), so the ISO must support serial console interaction. Requirements: - qemu-system-x86_64/qemu-system-aarch64 (QEMU emulator) - - genisoimage (for creating cloud-init ISO) + - genisoimage (only when cloud-init credentials are provided) + - qemu-img (only when creating an empty disk for '--iso') - sudo (for running QEMU with KVM) - OVMF firmware (for UEFI boot) @@ -35,6 +46,16 @@ azldev image boot [IMAGE_NAME] [flags] # Boot with SSH on a custom port and extra memory azldev image boot my-image --test-password-file ~/.azl-test-pw --ssh-port 2222 --memory 8G + + # Boot from an ISO (livecd / installer) onto a new empty 20G disk + azldev image boot --iso ~/Downloads/azurelinux.iso --disk-size 20G + + # Boot an existing disk image with a rescue ISO attached + azldev image boot --image-path ./out/my-image.qcow2 --iso ~/Downloads/rescue.iso + + # Boot from a live ISO with cloud-init credentials (consumed if the live image + # has cloud-init installed; otherwise harmlessly ignored) + azldev image boot --iso ~/Downloads/livecd.iso --test-password secret ``` ### Options @@ -43,9 +64,11 @@ azldev image boot [IMAGE_NAME] [flags] --arch arch Target architecture (x86_64, aarch64). Defaults to host arch. --authorized-public-key string Path to public key authorized for SSH to test user account --cpus int Number of CPU cores for the VM (default 8) + --disk-size string Size of the empty virtual disk created for ISO boot (e.g., 10G, 20G, 512M) (default "10G") -f, --format format Image format to boot (raw, qcow2, vhdx, vhd). Auto-detected if not specified. -h, --help help for boot -i, --image-path string Path to the disk image file (overrides positional image name) + --iso string Path to an ISO file to boot from (livecd, installer, or rescue media) --memory string Amount of memory for the VM (e.g., 4G, 8192M) (default "4G") --rwdisk Allow writes to persist to the disk image --secure-boot Enable secure boot for the VM diff --git a/internal/app/azldev/cmds/image/boot.go b/internal/app/azldev/cmds/image/boot.go index 243dbfc0..8becd475 100644 --- a/internal/app/azldev/cmds/image/boot.go +++ b/internal/app/azldev/cmds/image/boot.go @@ -27,8 +27,9 @@ const ( tempDirPrefixBoot = "azlboot" // Default values for VM configuration. - defaultSSHPort = 8888 - defaultCPUs = 8 + defaultSSHPort = 8888 + defaultCPUs = 8 + defaultDiskSize = "10G" // Default hostname for cloud-init metadata. defaultHostname = "azurelinux-vm" @@ -98,19 +99,34 @@ func QEMUDriver(format string) string { // ImageBootOptions contains options for the boot command. type ImageBootOptions struct { - Arch qemu.Arch - AuthorizedPublicKeyPath string - UseDiskRW bool - ImageName string - ImagePath string - Format ImageFormat - SecureBoot bool + // VM configuration (applies to all boot modes). + Arch qemu.Arch + SecureBoot bool + SSHPort uint16 + CPUs int + Memory string + + // Image source: at least one must be provided (unless '--iso' is used, in which case + // an empty disk is created if no image is specified). + ImageName string // Positional arg: look up image in project config to find its path. + ImagePath string // --image-path: explicit path to a disk image (overrides lookup). + Format ImageFormat // --format: explicit format hint for image-name lookup. + + // --iso: bootable ISO (e.g., livecd, installer, rescue media). Attached as a + // bootable CD-ROM alongside the disk. Optional; independent of cloud-init. + ISOPath string + DiskSize string // --disk-size: size of the empty qcow2 created when '--iso' is used without a disk. + + // Disk behavior. + UseDiskRW bool // --rwdisk: persist writes to the source disk image. + + // Cloud-init configuration. If any credential is provided, a seed ISO is generated + // and attached. Whether the booted system consumes it depends on cloud-init being + // installed and enabled in the guest (same caveat applies to disk and ISO images). TestUserName string TestUserPassword string TestUserPasswordFile string - SSHPort uint16 - CPUs int - Memory string + AuthorizedPublicKeyPath string } func bootOnAppInit(_ *azldev.App, parentCmd *cobra.Command) { @@ -126,15 +142,26 @@ func NewImageBootCmd() *cobra.Command { Short: "Boot an Azure Linux image in a QEMU VM", Long: `Boot an Azure Linux image in a QEMU virtual machine. -This command starts a QEMU VM with the specified disk image, setting up a test user -via cloud-init for access. SSH is forwarded to the host on the specified port (default 8888). +This command starts a QEMU VM with the specified disk image and/or bootable ISO. +SSH is forwarded to the host on the specified port (default 8888). If cloud-init +credentials are provided, a NoCloud seed ISO is generated and attached; the guest +will consume it only if cloud-init is installed and enabled. + +Image sources (at least one is required): + - IMAGE_NAME (positional): Look up a built image in the project output directory. + - '--image-path': Explicit path to a disk image (may also be combined with + IMAGE_NAME to override the default location). + - '--iso': Bootable ISO (livecd, installer, rescue). May be combined + with a disk image, or used alone to boot an empty disk. -The image can be specified either by name (positional argument) which will look up the -built image in the output directory, or by explicit path using --image-path. +When '--iso' is used without a disk image, an empty qcow2 disk is created (size set +via '--disk-size') for the live/installer ISO to install onto. The VM console is +serial-only (-nographic), so the ISO must support serial console interaction. Requirements: - qemu-system-x86_64/qemu-system-aarch64 (QEMU emulator) - - genisoimage (for creating cloud-init ISO) + - genisoimage (only when cloud-init credentials are provided) + - qemu-img (only when creating an empty disk for '--iso') - sudo (for running QEMU with KVM) - OVMF firmware (for UEFI boot)`, Example: ` # Boot an image by name @@ -144,26 +171,45 @@ Requirements: azldev image boot --image-path ./out/my-image.qcow2 --test-password secret # Boot with SSH on a custom port and extra memory - azldev image boot my-image --test-password-file ~/.azl-test-pw --ssh-port 2222 --memory 8G`, - Args: cobra.MaximumNArgs(1), - RunE: azldev.RunFuncWithExtraArgs(func(env *azldev.Env, args []string) (interface{}, error) { - if env.WorkDir() == "" { - return nil, errors.New("work dir must be specified in config") - } + azldev image boot my-image --test-password-file ~/.azl-test-pw --ssh-port 2222 --memory 8G - if len(args) > 0 { - options.ImageName = args[0] - } + # Boot from an ISO (livecd / installer) onto a new empty 20G disk + azldev image boot --iso ~/Downloads/azurelinux.iso --disk-size 20G + + # Boot an existing disk image with a rescue ISO attached + azldev image boot --image-path ./out/my-image.qcow2 --iso ~/Downloads/rescue.iso - return nil, bootImage(env, options) - }), + # Boot from a live ISO with cloud-init credentials (consumed if the live image + # has cloud-init installed; otherwise harmlessly ignored) + azldev image boot --iso ~/Downloads/livecd.iso --test-password secret`, + Args: cobra.MaximumNArgs(1), ValidArgsFunction: generateImageNameCompletions, } + cmd.RunE = azldev.RunFuncWithoutRequiredConfigWithExtraArgs(func(env *azldev.Env, args []string) (interface{}, error) { + if len(args) > 0 { + options.ImageName = args[0] + } + + explicit := bootFlagsExplicit{ + DiskSize: cmd.Flags().Lookup("disk-size").Changed, + TestUser: cmd.Flags().Lookup("test-user").Changed, + } + + return nil, bootImage(env, options, explicit) + }) + cmd.Flags().StringVarP(&options.ImagePath, "image-path", "i", "", "Path to the disk image file (overrides positional image name)") _ = cmd.MarkFlagFilename("image-path") + cmd.Flags().StringVar(&options.ISOPath, "iso", "", + "Path to an ISO file to boot from (livecd, installer, or rescue media)") + _ = cmd.MarkFlagFilename("iso") + + cmd.Flags().StringVar(&options.DiskSize, "disk-size", defaultDiskSize, + "Size of the empty virtual disk created for ISO boot (e.g., 10G, 20G, 512M)") + addBootFlags(cmd, options) return cmd @@ -180,7 +226,6 @@ func addBootFlags(cmd *cobra.Command, options *ImageBootOptions) { "Path to file containing the password for the test account") _ = cmd.MarkFlagFilename("test-password-file") cmd.MarkFlagsMutuallyExclusive("test-password", "test-password-file") - cmd.MarkFlagsOneRequired("test-password", "test-password-file") cmd.Flags().StringVar(&options.AuthorizedPublicKeyPath, "authorized-public-key", "", "Path to public key authorized for SSH to test user account") @@ -204,50 +249,176 @@ func addBootFlags(cmd *cobra.Command, options *ImageBootOptions) { return qemu.SupportedArchitectures(), cobra.ShellCompDirectiveNoFileComp }) + // '--format' only affects image-name lookup; it conflicts with an explicit path. cmd.MarkFlagsMutuallyExclusive("image-path", "format") } -func bootImage(env *azldev.Env, options *ImageBootOptions) error { - // Resolve password from file if specified (do this before validation since - // file reading is not validation). - if options.TestUserPasswordFile != "" { - passwordBytes, err := fileutils.ReadFile(env.FS(), options.TestUserPasswordFile) - if err != nil { - return fmt.Errorf("failed to read password file %#q:\n%w", options.TestUserPasswordFile, err) - } +// bootFlagsExplicit tracks which flags were explicitly set on the command line +// (vs. left at their default). Used to distinguish "user said nothing" from +// "user said the default". +type bootFlagsExplicit struct { + DiskSize bool + TestUser bool +} - options.TestUserPassword = strings.TrimSpace(string(passwordBytes)) +func bootImage(env *azldev.Env, options *ImageBootOptions, explicit bootFlagsExplicit) error { + needEmptyDisk := options.ISOPath != "" && options.ImagePath == "" && options.ImageName == "" + + if err := validateBootOptions(options, explicit.DiskSize, needEmptyDisk); err != nil { + return err + } + + if err := resolveTestPassword(env, options); err != nil { + return err + } + + // Cloud-init is needed whenever the user asked for any user-provisioning behavior: + // credentials (password / SSH key) OR an explicit '--test-user' (which signals + // they want the named account created even if no auth is supplied — otherwise + // '--test-user' would be silently ignored). + needCloudInit := shouldBuildCloudInit(options, explicit.TestUser) + + hasLoginCredential := options.TestUserPassword != "" || options.AuthorizedPublicKeyPath != "" + if !hasLoginCredential { + slog.Warn("No test password ('--test-password'/'--test-password-file') or SSH key " + + "('--authorized-public-key') supplied; you may have no way to log in to the booted OS") + } + + if err := verifyISOExists(env, options.ISOPath); err != nil { + return err } - // Default to host architecture if not specified. arch := string(options.Arch) if arch == "" { arch = qemu.GoArchToQEMUArch(runtime.GOARCH) } - // Warn about persistent disk writes. - if options.UseDiskRW { - slog.Warn("--rwdisk enabled: changes will persist to the source disk image") + warnRWDisk(options, needEmptyDisk) + + if err := checkBootPrerequisites(env, arch, needEmptyDisk, needCloudInit); err != nil { + return err } - if err := checkBootPrerequisites(env, arch); err != nil { + imagePath, imageFormat, err := resolveDiskSource(env, options) + if err != nil { return err } - // Resolve image path: explicit --image-path takes precedence, otherwise resolve from image name. - imagePath := options.ImagePath - imageFormat := string(options.Format) + if env.DryRun() { + slog.Info("Dry-run: would boot VM", + slog.String("iso", options.ISOPath), + slog.String("disk", imagePath), + slog.String("disk-format", imageFormat), + slog.String("arch", arch), + slog.Bool("empty-disk", needEmptyDisk), + slog.Bool("cloud-init", needCloudInit), + ) + + return nil + } + + return runQEMUBoot(env, options, arch, imagePath, imageFormat, needEmptyDisk, needCloudInit) +} + +func validateBootOptions(options *ImageBootOptions, diskSizeExplicit, needEmptyDisk bool) error { + if options.ISOPath == "" && options.ImagePath == "" && options.ImageName == "" { + return errors.New("must specify at least one of: IMAGE_NAME, '--image-path', or '--iso'") + } + + if diskSizeExplicit && !needEmptyDisk { + return errors.New("'--disk-size' only applies when '--iso' is used without a disk image " + + "(IMAGE_NAME or '--image-path')") + } + + if needEmptyDisk && strings.TrimSpace(options.DiskSize) == "" { + return errors.New("'--disk-size' must be non-empty when '--iso' is used without a disk image") + } + + return nil +} + +// shouldBuildCloudInit reports whether a cloud-init NoCloud seed ISO should be +// generated and attached. It returns true when the user supplied any +// user-provisioning intent: credentials (password or SSH key) OR an explicit +// '--test-user' (otherwise '--test-user' would be silently ignored). +func shouldBuildCloudInit(options *ImageBootOptions, testUserExplicit bool) bool { + return options.TestUserPassword != "" || + options.AuthorizedPublicKeyPath != "" || + testUserExplicit +} + +func resolveTestPassword(env *azldev.Env, options *ImageBootOptions) error { + if options.TestUserPasswordFile == "" { + return nil + } - if imagePath == "" { - if options.ImageName == "" { - return errors.New("either IMAGE_NAME argument or --image-path must be specified") + passwordBytes, err := fileutils.ReadFile(env.FS(), options.TestUserPasswordFile) + if err != nil { + return fmt.Errorf("failed to read password file %#q:\n%w", options.TestUserPasswordFile, err) + } + + options.TestUserPassword = strings.TrimSpace(string(passwordBytes)) + + return nil +} + +func verifyISOExists(env *azldev.Env, isoPath string) error { + if isoPath == "" { + return nil + } + + exists, err := fileutils.Exists(env.FS(), isoPath) + if err != nil { + return fmt.Errorf("failed to check ISO file %#q:\n%w", isoPath, err) + } + + if !exists { + return fmt.Errorf("ISO file %#q not found", isoPath) + } + + return nil +} + +func warnRWDisk(options *ImageBootOptions, needEmptyDisk bool) { + if !options.UseDiskRW { + return + } + + if needEmptyDisk { + slog.Warn("'--rwdisk' has no effect with '--iso' alone; the empty disk is ephemeral") + } else { + slog.Warn("'--rwdisk' enabled: changes will persist to the source disk image") + } +} + +// resolveDiskSource returns the source disk image path and format (empty strings if +// no source disk was requested — i.e., '--iso' is used alone). If IMAGE_NAME is +// supplied, its presence in project config is validated regardless of whether +// '--image-path' overrides the file location. +func resolveDiskSource(env *azldev.Env, options *ImageBootOptions) (imagePath, imageFormat string, err error) { + if options.ImageName != "" { + if _, err := ResolveImageByName(env, options.ImageName); err != nil { + return "", "", err + } + } + + switch { + case options.ImagePath != "": + imageFormat, err = InferImageFormat(options.ImagePath) + if err != nil { + return "", "", err } - var err error + imagePath = options.ImagePath - imagePath, imageFormat, err = findBootableImageArtifact(env, options.ImageName, imageFormat) + slog.Info("Using disk image", + slog.String("path", imagePath), + slog.String("format", imageFormat), + ) + case options.ImageName != "": + imagePath, imageFormat, err = findBootableImageArtifact(env, options.ImageName, string(options.Format)) if err != nil { - return err + return "", "", err } slog.Info("Resolved image artifact", @@ -255,33 +426,111 @@ func bootImage(env *azldev.Env, options *ImageBootOptions) error { slog.String("path", imagePath), slog.String("format", imageFormat), ) - } else { - // Infer format from the file extension when --image-path is used. - var err error + } + + return imagePath, imageFormat, nil +} + +// runQEMUBoot performs the actual QEMU boot: prepares the temp dir, creates any +// transient artifacts (empty disk, ephemeral disk copy, cloud-init seed ISO), and +// invokes QEMU. All boot modes converge here. +func runQEMUBoot( + env *azldev.Env, + options *ImageBootOptions, + arch, imagePath, imageFormat string, + needEmptyDisk, needCloudInit bool, +) (err error) { + bootEnv, err := prepareQEMUBootEnv(env, arch, options.SecureBoot) + if err != nil { + return err + } + + defer fileutils.RemoveAllAndUpdateErrorIfNil(env.FS(), bootEnv.tempDir, &err) - imageFormat, err = InferImageFormat(imagePath) + // Prepare the disk: either create an empty qcow2, or use/copy the source disk. + var diskPath, diskFormat string + + switch { + case needEmptyDisk: + diskPath = filepath.Join(bootEnv.tempDir, "disk.qcow2") + diskFormat = string(ImageFormatQcow2) + + slog.Info("Creating empty qcow2 disk", + slog.String("path", diskPath), + slog.String("size", options.DiskSize), + ) + + err = bootEnv.runner.CreateEmptyQcow2(env, diskPath, options.DiskSize) + if err != nil { + return fmt.Errorf("creating empty disk:\n%w", err) + } + default: + diskFormat = imageFormat + + diskPath, err = prepareDiskForBoot(env, options, bootEnv.tempDir, imagePath, imageFormat) if err != nil { return err } + } - slog.Info("Inferred image format from file extension", - slog.String("path", imagePath), - slog.String("format", imageFormat), - ) + // Build the cloud-init seed ISO if any cloud-init credentials were provided. + cloudInitISOPath := "" + + if needCloudInit { + cloudInitISOPath = filepath.Join(bootEnv.tempDir, "cloud-init.iso") + + err = buildCloudInitMetadataIso(env, options, cloudInitISOPath) + if err != nil { + return err + } } - // Dry-run mode: log what would be executed and return early. - if env.DryRun() { - slog.Info("Dry-run: would boot image", - slog.String("path", imagePath), - slog.String("format", imageFormat), - slog.String("arch", arch), - ) + slog.Info("Booting VM", + slog.String("iso", options.ISOPath), + slog.String("disk", diskPath), + slog.String("disk-format", diskFormat), + slog.String("arch", arch), + ) - return nil + err = bootEnv.runner.Run(env, qemu.RunOptions{ + Arch: arch, + FirmwarePath: bootEnv.firmwarePath, + NVRAMPath: bootEnv.nvramPath, + DiskPath: diskPath, + DiskType: QEMUDriver(diskFormat), + CloudInitISOPath: cloudInitISOPath, + InstallISOPath: options.ISOPath, + SecureBoot: options.SecureBoot, + SSHPort: int(options.SSHPort), + CPUs: options.CPUs, + Memory: options.Memory, + }) + if err != nil { + return fmt.Errorf("running QEMU:\n%w", err) } - return bootImageUsingDiskFile(env, options, arch, imagePath, imageFormat) + return nil +} + +// createBootTempDir creates a temporary directory for boot artifacts. It uses the project +// work directory if available, otherwise falls back to the OS temp directory (resolved +// via the injected filesystem). +func createBootTempDir(env *azldev.Env) (string, error) { + if env.WorkDir() != "" { + tempDir, err := fileutils.MkdirTemp(env.FS(), env.WorkDir(), tempDirPrefixBoot) + if err != nil { + return "", fmt.Errorf("failed to create boot temp dir:\n%w", err) + } + + return tempDir, nil + } + + tempDir, err := fileutils.MkdirTempInTempDir(env.FS(), tempDirPrefixBoot) + if err != nil { + return "", fmt.Errorf("failed to create boot temp dir:\n%w", err) + } + + return tempDir, nil } // fileExtensionsForFormat returns the file extensions to search for the given format. @@ -450,71 +699,65 @@ func ResolveImageByName(env *azldev.Env, imageName string) (*projectconfig.Image ) } -func checkBootPrerequisites(env *azldev.Env, arch string) error { +func checkBootPrerequisites(env *azldev.Env, arch string, needQEMUImg, needGenisoimage bool) error { if err := qemu.CheckPrerequisites(env, arch); err != nil { return fmt.Errorf("checking QEMU prerequisites:\n%w", err) } - if err := iso.CheckPrerequisites(env); err != nil { - return fmt.Errorf("checking genisoimage prerequisites:\n%w", err) + if needQEMUImg { + if err := qemu.CheckQEMUImgPrerequisite(env); err != nil { + return fmt.Errorf("checking qemu-img prerequisites:\n%w", err) + } + } + + if needGenisoimage { + if err := iso.CheckPrerequisites(env); err != nil { + return fmt.Errorf("checking genisoimage prerequisites:\n%w", err) + } } return nil } -func bootImageUsingDiskFile( - env *azldev.Env, options *ImageBootOptions, arch, imagePath, imageFormat string, -) (err error) { - qemuRunner := qemu.NewRunner(env) +// qemuBootEnv holds the common QEMU boot environment shared by all boot modes. +type qemuBootEnv struct { + runner *qemu.Runner + tempDir string + firmwarePath string + nvramPath string +} + +// prepareQEMUBootEnv sets up the common QEMU boot environment: runner, temp dir, +// firmware, and NVRAM. The caller must clean up tempDir (e.g., via defer). +func prepareQEMUBootEnv(env *azldev.Env, arch string, secureBoot bool) (*qemuBootEnv, error) { + runner := qemu.NewRunner(env) - fwPath, nvramTemplatePath, err := qemuRunner.FindFirmware(arch, options.SecureBoot) + fwPath, nvramTemplatePath, err := runner.FindFirmware(arch, secureBoot) if err != nil { - return fmt.Errorf("finding VM firmware:\n%w", err) + return nil, fmt.Errorf("finding VM firmware:\n%w", err) } - tempDir, err := fileutils.MkdirTemp(env.FS(), env.WorkDir(), tempDirPrefixBoot) + tempDir, err := createBootTempDir(env) if err != nil { - return fmt.Errorf("failed to create temp dir:\n%w", err) + return nil, fmt.Errorf("failed to create temp dir:\n%w", err) } - defer fileutils.RemoveAllAndUpdateErrorIfNil(env.FS(), tempDir, &err) - nvramPath := filepath.Join(tempDir, "nvram.bin") err = fileutils.CopyFile(env, env.FS(), nvramTemplatePath, nvramPath, fileutils.CopyFileOptions{}) if err != nil { - return fmt.Errorf("failed to copy NVRAM template file:\n%w", err) - } - - cloudInitMetadataIsoPath := filepath.Join(tempDir, "cloud-init.iso") + // Clean up temp dir since the caller won't have a chance to. + fileutils.RemoveAllAndUpdateErrorIfNil(env.FS(), tempDir, &err) - err = buildCloudInitMetadataIso(env, options, cloudInitMetadataIsoPath) - if err != nil { - return err + return nil, fmt.Errorf("failed to copy NVRAM template file:\n%w", err) } - selectedDiskPath, err := prepareDiskForBoot(env, options, tempDir, imagePath, imageFormat) - if err != nil { - return err - } - - err = qemuRunner.Run(env, qemu.RunOptions{ - Arch: arch, - FirmwarePath: fwPath, - NVRAMPath: nvramPath, - DiskPath: selectedDiskPath, - DiskType: QEMUDriver(imageFormat), - CloudInitISOPath: cloudInitMetadataIsoPath, - SecureBoot: options.SecureBoot, - SSHPort: int(options.SSHPort), - CPUs: options.CPUs, - Memory: options.Memory, - }) - if err != nil { - return fmt.Errorf("running QEMU:\n%w", err) - } - - return nil + return &qemuBootEnv{ + runner: runner, + tempDir: tempDir, + firmwarePath: fwPath, + nvramPath: nvramPath, + }, nil } // prepareDiskForBoot prepares the disk image for booting. If UseDiskRW is false, @@ -537,7 +780,7 @@ func prepareDiskForBoot( } func buildCloudInitMetadataIso(env *azldev.Env, options *ImageBootOptions, outputFilePath string) (err error) { - tempDir, err := fileutils.MkdirTemp(env.FS(), env.WorkDir(), tempDirPrefixBoot) + tempDir, err := createBootTempDir(env) if err != nil { return fmt.Errorf("failed to create temp dir:\n%w", err) } @@ -578,15 +821,19 @@ func buildCloudInitMetadataIso(env *azldev.Env, options *ImageBootOptions, outpu // buildCloudInitConfig creates the cloud-init configuration for the test user. func buildCloudInitConfig(env *azldev.Env, options *ImageBootOptions) (*cloudinit.Config, error) { + hasPassword := options.TestUserPassword != "" + testUserConfig := cloudinit.UserConfig{ - Name: options.TestUserName, - Description: "Test User", - EnableSSHPasswordAuth: lo.ToPtr(true), - Shell: "/bin/bash", - Sudo: []string{"ALL=(ALL) NOPASSWD:ALL"}, - LockPassword: lo.ToPtr(false), - PlainTextPassword: options.TestUserPassword, - Groups: []string{"sudo"}, + Name: options.TestUserName, + Description: "Test User", + Shell: "/bin/bash", + Sudo: []string{"ALL=(ALL) NOPASSWD:ALL"}, + // Only unlock the password when one is actually provided. Otherwise the + // account would be unlocked with no defined password, which combined with + // SSH password auth could allow passwordless login. + LockPassword: lo.ToPtr(!hasPassword), + PlainTextPassword: options.TestUserPassword, + Groups: []string{"sudo"}, } if options.AuthorizedPublicKeyPath != "" { @@ -599,7 +846,8 @@ func buildCloudInitConfig(env *azldev.Env, options *ImageBootOptions) (*cloudini } return &cloudinit.Config{ - EnableSSHPasswordAuth: lo.ToPtr(true), + // Only enable SSH password auth when a password is actually provided. + EnableSSHPasswordAuth: lo.ToPtr(hasPassword), DisableRootUser: lo.ToPtr(true), ChangePasswords: &cloudinit.PasswordConfig{ Expire: lo.ToPtr(false), diff --git a/internal/app/azldev/cmds/image/boot_internal_test.go b/internal/app/azldev/cmds/image/boot_internal_test.go new file mode 100644 index 00000000..1aef9b89 --- /dev/null +++ b/internal/app/azldev/cmds/image/boot_internal_test.go @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package image + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestShouldBuildCloudInit(t *testing.T) { + tests := []struct { + name string + options ImageBootOptions + testUserExplicit bool + want bool + }{ + { + name: "no credentials and no explicit test-user => false", + options: ImageBootOptions{}, + testUserExplicit: false, + want: false, + }, + { + name: "explicit '--test-user' alone => true (avoid silent ignore)", + options: ImageBootOptions{TestUserName: "foo"}, + testUserExplicit: true, + want: true, + }, + { + name: "default test-user value but flag not changed => false", + options: ImageBootOptions{TestUserName: "test"}, + testUserExplicit: false, + want: false, + }, + { + name: "password supplied => true", + options: ImageBootOptions{TestUserPassword: "pw"}, + testUserExplicit: false, + want: true, + }, + { + name: "authorized public key supplied => true", + options: ImageBootOptions{AuthorizedPublicKeyPath: "/some/key.pub"}, + testUserExplicit: false, + want: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := shouldBuildCloudInit(&tc.options, tc.testUserExplicit) + assert.Equal(t, tc.want, got) + }) + } +} diff --git a/internal/app/azldev/cmds/image/boot_test.go b/internal/app/azldev/cmds/image/boot_test.go index 0fc7e2af..70fbabfa 100644 --- a/internal/app/azldev/cmds/image/boot_test.go +++ b/internal/app/azldev/cmds/image/boot_test.go @@ -37,6 +37,40 @@ func TestNewImageBootCmd_Flags(t *testing.T) { assert.NotNil(t, cmd.Flags().Lookup("cpus")) assert.NotNil(t, cmd.Flags().Lookup("memory")) assert.NotNil(t, cmd.Flags().Lookup("arch")) + assert.NotNil(t, cmd.Flags().Lookup("iso")) + assert.NotNil(t, cmd.Flags().Lookup("disk-size")) +} + +func TestNewImageBootCmd_DiskSizeDefault(t *testing.T) { + cmd := image.NewImageBootCmd() + diskSizeFlag := cmd.Flags().Lookup("disk-size") + require.NotNil(t, diskSizeFlag) + assert.Equal(t, "10G", diskSizeFlag.DefValue) +} + +func TestNewImageBootCmd_NoImageSourceErrors(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + + cmd := image.NewImageBootCmd() + cmd.SetArgs([]string{}) + + err := cmd.ExecuteContext(testEnv.Env) + require.Error(t, err) + assert.Contains(t, err.Error(), "IMAGE_NAME") + assert.Contains(t, err.Error(), "--image-path") + assert.Contains(t, err.Error(), "--iso") +} + +func TestNewImageBootCmd_DiskSizeWithoutEmptyDiskErrors(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + + cmd := image.NewImageBootCmd() + // '--disk-size' is only meaningful when '--iso' is used without a disk image. + cmd.SetArgs([]string{"--image-path", "/tmp/test.qcow2", "--iso", "/tmp/live.iso", "--disk-size", "20G"}) + + err := cmd.ExecuteContext(testEnv.Env) + require.Error(t, err) + assert.Contains(t, err.Error(), "--disk-size") } func TestImageFormat_Set_InvalidFormat(t *testing.T) { diff --git a/internal/utils/cloudinit/cloudconfig.go b/internal/utils/cloudinit/cloudconfig.go index be140eee..6957fc18 100644 --- a/internal/utils/cloudinit/cloudconfig.go +++ b/internal/utils/cloudinit/cloudconfig.go @@ -37,15 +37,14 @@ type PasswordConfig struct { // //nolint:tagliatelle // We don't control the schema for this struct; it's an external format. type UserConfig struct { - Description string `yaml:"gecos,omitempty"` - EnableSSHPasswordAuth *bool `yaml:"ssh_pwauth,omitempty"` - Groups []string `yaml:"groups,omitempty"` - LockPassword *bool `yaml:"lock_passwd,omitempty"` - Name string `yaml:"name,omitempty"` - PlainTextPassword string `yaml:"plain_text_passwd,omitempty"` - Shell string `yaml:"shell,omitempty"` - SSHAuthorizedKeys []string `yaml:"ssh_authorized_keys,omitempty"` - Sudo []string `yaml:"sudo,omitempty"` + Description string `yaml:"gecos,omitempty"` + Groups []string `yaml:"groups,omitempty"` + LockPassword *bool `yaml:"lock_passwd,omitempty"` + Name string `yaml:"name,omitempty"` + PlainTextPassword string `yaml:"plain_text_passwd,omitempty"` + Shell string `yaml:"shell,omitempty"` + SSHAuthorizedKeys []string `yaml:"ssh_authorized_keys,omitempty"` + Sudo []string `yaml:"sudo,omitempty"` } // MarshalToYAML serializes the given cloud-init Config to YAML format. diff --git a/internal/utils/qemu/qemu.go b/internal/utils/qemu/qemu.go index 85407bf5..06d6c562 100644 --- a/internal/utils/qemu/qemu.go +++ b/internal/utils/qemu/qemu.go @@ -77,16 +77,22 @@ func NewRunner(ctx opctx.Ctx) *Runner { // RunOptions contains configuration for running a QEMU VM. type RunOptions struct { - Arch string - FirmwarePath string - NVRAMPath string - DiskPath string - DiskType string + Arch string + FirmwarePath string + NVRAMPath string + DiskPath string + DiskType string + // CloudInitISOPath is an optional cloud-init NoCloud seed ISO. When set, it is + // attached as a non-bootable IDE CD-ROM, independent of any install ISO. CloudInitISOPath string - SecureBoot bool - SSHPort int - CPUs int - Memory string + // InstallISOPath is an optional ISO to boot from (e.g., a livecd or installer). + // When set, it is attached as a bootable SCSI CD-ROM with bootindex=1 so the VM + // boots from the ISO first; the disk receives bootindex=2. + InstallISOPath string + SecureBoot bool + SSHPort int + CPUs int + Memory string } // Run starts a QEMU VM with the specified options. @@ -120,8 +126,34 @@ func (r *Runner) Run(ctx context.Context, options RunOptions) error { "-drive", "if=pflash,format=raw,unit=1,file="+options.NVRAMPath, "-drive", fmt.Sprintf("if=none,id=hd,file=%s,format=%s", options.DiskPath, options.DiskType), "-device", "virtio-scsi-pci,id=scsi", - "-device", "scsi-hd,drive=hd,bootindex=1", - "-cdrom", options.CloudInitISOPath, + ) + + // Boot order: when an install/live ISO is attached, it boots first (bootindex=1) + // and the disk follows (bootindex=2). Otherwise, the disk boots first. + diskBootIndex := 1 + if options.InstallISOPath != "" { + diskBootIndex = 2 + } + + qemuArgs = append(qemuArgs, + "-device", fmt.Sprintf("scsi-hd,drive=hd,bootindex=%d", diskBootIndex), + ) + + if options.InstallISOPath != "" { + qemuArgs = append(qemuArgs, + "-drive", fmt.Sprintf("if=none,id=installcd,file=%s,media=cdrom,readonly=on", options.InstallISOPath), + "-device", "scsi-cd,drive=installcd,bootindex=1", + ) + } + + // Attach the cloud-init seed ISO (if any) as a separate non-bootable IDE CD-ROM. + // It coexists with an install ISO on a different bus. Whether the booted system + // honors it is a runtime concern (cloud-init must be installed and enabled). + if options.CloudInitISOPath != "" { + qemuArgs = append(qemuArgs, "-cdrom", options.CloudInitISOPath) + } + + qemuArgs = append(qemuArgs, "-netdev", fmt.Sprintf("user,id=n1,hostfwd=tcp::%d-:22", options.SSHPort), "-device", "virtio-net-pci,netdev=n1", "-nographic", "-serial", "mon:stdio", @@ -239,6 +271,36 @@ func CheckPrerequisites(ctx opctx.Ctx, arch string) error { return nil } +// CheckQEMUImgPrerequisite verifies that the 'qemu-img' tool is available. +func CheckQEMUImgPrerequisite(ctx opctx.Ctx) error { + if err := prereqs.RequireExecutable(ctx, "qemu-img", &prereqs.PackagePrereq{ + AzureLinuxPackages: []string{"qemu-img"}, + FedoraPackages: []string{"qemu-img"}, + }); err != nil { + return fmt.Errorf("'qemu-img' prerequisite check failed:\n%w", err) + } + + return nil +} + +// CreateEmptyQcow2 creates an empty qcow2 disk image at the given path with the specified size. +// The size should be a QEMU-compatible size string (e.g., "10G", "512M"). +func (r *Runner) CreateEmptyQcow2(ctx context.Context, path, size string) error { + createCmd := exec.CommandContext(ctx, "qemu-img", "create", "-f", "qcow2", path, size) + + cmd, err := r.cmdFactory.Command(createCmd) + if err != nil { + return fmt.Errorf("failed to create qemu-img command:\n%w", err) + } + + err = cmd.Run(ctx) + if err != nil { + return fmt.Errorf("failed to create empty qcow2 disk image:\n%w", err) + } + + return nil +} + // GoArchToQEMUArch converts Go's GOARCH to QEMU architecture names. func GoArchToQEMUArch(goarch string) string { switch goarch { diff --git a/internal/utils/qemu/qemu_test.go b/internal/utils/qemu/qemu_test.go index 497f8ded..8d4f507e 100644 --- a/internal/utils/qemu/qemu_test.go +++ b/internal/utils/qemu/qemu_test.go @@ -469,6 +469,44 @@ func TestRun(t *testing.T) { wantErr: true, wantErrContain: "failed to run VM in QEMU", }, + { + name: "VM with install ISO boots ISO first", + options: qemu.RunOptions{ + Arch: qemu.ArchX86_64, + FirmwarePath: "/usr/share/OVMF/OVMF_CODE.fd", + NVRAMPath: "/tmp/nvram.fd", + DiskPath: "/images/disk.qcow2", + DiskType: "qcow2", + InstallISOPath: "/tmp/installer.iso", + SecureBoot: false, + SSHPort: 2222, + CPUs: 2, + Memory: "2G", + }, + wantArgsContain: []string{ + "qemu-system-x86_64", + "if=none,id=installcd,file=/tmp/installer.iso,media=cdrom,readonly=on", + "scsi-cd,drive=installcd,bootindex=1", + "scsi-hd,drive=hd,bootindex=2", + }, + }, + { + name: "VM without install ISO boots disk first", + options: qemu.RunOptions{ + Arch: qemu.ArchX86_64, + FirmwarePath: "/usr/share/OVMF/OVMF_CODE.fd", + NVRAMPath: "/tmp/nvram.fd", + DiskPath: "/images/disk.qcow2", + DiskType: "qcow2", + SecureBoot: false, + SSHPort: 2222, + CPUs: 2, + Memory: "2G", + }, + wantArgsContain: []string{ + "scsi-hd,drive=hd,bootindex=1", + }, + }, } for _, testCase := range tests { @@ -650,3 +688,71 @@ func argsToString(args []string) string { return result } + +func TestCreateEmptyQcow2(t *testing.T) { + t.Parallel() + + t.Run("invokes qemu-img with expected args", func(t *testing.T) { + t.Parallel() + + ctx := testctx.NewCtx() + + var capturedArgs []string + + ctx.CmdFactory.RunHandler = func(cmd *exec.Cmd) error { + capturedArgs = cmd.Args + + return nil + } + + runner := qemu.NewRunner(ctx) + err := runner.CreateEmptyQcow2(context.Background(), "/tmp/disk.qcow2", "10G") + require.NoError(t, err) + + require.NotEmpty(t, capturedArgs) + assert.Equal(t, "qemu-img", capturedArgs[0]) + assert.Equal(t, []string{"qemu-img", "create", "-f", "qcow2", "/tmp/disk.qcow2", "10G"}, capturedArgs) + }) + + t.Run("propagates errors", func(t *testing.T) { + t.Parallel() + + ctx := testctx.NewCtx() + ctx.CmdFactory.RunHandler = func(_ *exec.Cmd) error { + return errors.New("disk full") + } + + runner := qemu.NewRunner(ctx) + err := runner.CreateEmptyQcow2(context.Background(), "/tmp/disk.qcow2", "10G") + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to create empty qcow2 disk image") + }) +} + +func TestCheckQEMUImgPrerequisite(t *testing.T) { + t.Parallel() + + t.Run("qemu-img available", func(t *testing.T) { + t.Parallel() + + ctx := testctx.NewCtx() + ctx.CmdFactory.RegisterCommandInSearchPath("qemu-img") + ctx.DryRunValue = true + + err := qemu.CheckQEMUImgPrerequisite(ctx) + require.NoError(t, err) + }) + + t.Run("qemu-img missing", func(t *testing.T) { + t.Parallel() + + ctx := testctx.NewCtx() + ctx.DryRunValue = true + ctx.PromptsAllowedValue = false + ctx.AllPromptsAcceptedValue = false + + err := qemu.CheckQEMUImgPrerequisite(ctx) + require.Error(t, err) + assert.Contains(t, err.Error(), "'qemu-img' prerequisite check failed") + }) +}