Skip to content

Commit 75555a3

Browse files
liunan-msreubeno
authored andcommitted
feat(image): add 'image test' command for running LISA tests on local QEMU VM (#513)
Add a new 'azldev image test' subcommand that runs tests against Azure Linux images using the LISA test runner. Flags: --image-path path to the disk image to test --test-runner test runner to use (currently only 'lisa') --runbook-path path to the LISA runbook file --admin-private-key-path SSH private key passed to LISA
1 parent 14b1f48 commit 75555a3

File tree

6 files changed

+413
-0
lines changed

6 files changed

+413
-0
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
# azldev binary
1515
/azldev
1616

17+
# Runtime output from test runs (e.g. LISA logs)
18+
/runtime/
19+
1720
# Output of the go coverage tool, specifically when used with LiteIDE
1821
*.out
1922

docs/user/reference/cli/azldev_image.md

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/user/reference/cli/azldev_image_test.md

Lines changed: 63 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,5 @@ can be customized using Azure Linux Image Customizer.`,
2626
customizeOnAppInit(app, cmd)
2727
injectFilesOnAppInit(app, cmd)
2828
listOnAppInit(app, cmd)
29+
testOnAppInit(app, cmd)
2930
}
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
package image
5+
6+
import (
7+
"errors"
8+
"fmt"
9+
"log/slog"
10+
"os"
11+
"os/exec"
12+
"path/filepath"
13+
"strings"
14+
15+
"github.com/microsoft/azure-linux-dev-tools/internal/app/azldev"
16+
"github.com/microsoft/azure-linux-dev-tools/internal/global/opctx"
17+
"github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils"
18+
"github.com/spf13/cobra"
19+
)
20+
21+
const (
22+
// testRunnerLisa is the only supported test runner.
23+
testRunnerLisa = "lisa"
24+
25+
// testImagePrefix is the prefix used for qcow2 images created during image testing.
26+
testImagePrefix = "azldevtest"
27+
)
28+
29+
// ImageTestOptions holds the options for the 'image test' command.
30+
type ImageTestOptions struct {
31+
ImagePath string
32+
TestRunner string
33+
RunbookPath string
34+
AdminPrivateKeyPath string
35+
}
36+
37+
func testOnAppInit(_ *azldev.App, parentCmd *cobra.Command) {
38+
parentCmd.AddCommand(NewImageTestCmd())
39+
}
40+
41+
// NewImageTestCmd constructs a [cobra.Command] for the 'image test' command.
42+
func NewImageTestCmd() *cobra.Command {
43+
options := &ImageTestOptions{}
44+
45+
cmd := &cobra.Command{
46+
Use: "test",
47+
Short: "Run tests against an Azure Linux image",
48+
Long: `Run tests against an Azure Linux image using a supported test runner.
49+
50+
Currently only the LISA test runner is supported. The image must be in qcow2,
51+
vhd, or vhdfixed format. If the image is in vhd/vhdfixed format it is
52+
automatically converted to qcow2 before running the tests.
53+
54+
Requirements:
55+
- lisa (Installation instructions: https://github.com/microsoft/lisa/blob/main/INSTALL.md)
56+
- runbook file (YAML format defining the tests to run: https://github.com/microsoft/lisa/blob/main/docs/Runbooks.md)
57+
- qemu-img (for vhd/vhdfixed to qcow2 conversion, if needed)`,
58+
Example: ` # Run LISA tests against a qcow2 image
59+
azldev image test --image-path ./out/image.qcow2 --test-runner lisa --runbook-path ./runbooks/smoke.yml
60+
61+
# Run LISA tests against a vhd image (auto-converted to qcow2)
62+
azldev image test --image-path ./out/image.vhd --test-runner lisa --runbook-path ./runbooks/smoke.yml`,
63+
RunE: azldev.RunFuncWithoutRequiredConfig(func(env *azldev.Env) (interface{}, error) {
64+
return nil, testImage(env, options)
65+
}),
66+
}
67+
68+
cmd.Flags().StringVarP(&options.ImagePath, "image-path", "i", "",
69+
"Path to the disk image file to test")
70+
_ = cmd.MarkFlagRequired("image-path")
71+
_ = cmd.MarkFlagFilename("image-path")
72+
73+
cmd.Flags().StringVar(&options.TestRunner, "test-runner", "",
74+
"Test runner to use (currently only 'lisa' is supported)")
75+
_ = cmd.MarkFlagRequired("test-runner")
76+
77+
cmd.Flags().StringVarP(&options.RunbookPath, "runbook-path", "r", "",
78+
"Path to the test runbook file")
79+
_ = cmd.MarkFlagRequired("runbook-path")
80+
_ = cmd.MarkFlagFilename("runbook-path")
81+
82+
cmd.Flags().StringVarP(&options.AdminPrivateKeyPath, "admin-private-key-path", "k", "",
83+
"Path to the admin SSH private key file passed to LISA")
84+
_ = cmd.MarkFlagRequired("admin-private-key-path")
85+
_ = cmd.MarkFlagFilename("admin-private-key-path")
86+
87+
return cmd
88+
}
89+
90+
// testImage implements the core logic for the 'image test' command.
91+
func testImage(env *azldev.Env, options *ImageTestOptions) error {
92+
// Check 1: validate test runner.
93+
if err := CheckTestRunner(options.TestRunner); err != nil {
94+
return err
95+
}
96+
97+
// Check 2: verify lisa is installed.
98+
if err := checkLisaInstalled(env); err != nil {
99+
return err
100+
}
101+
102+
// Check 3: validate admin private key path.
103+
if err := validateFileExists(env.FS(), options.AdminPrivateKeyPath); err != nil {
104+
return fmt.Errorf("--admin-private-key-path:\n%w", err)
105+
}
106+
107+
// Check 4: resolve the image to a qcow2 path (converting if necessary).
108+
qcow2Path, err := ResolveQcow2Image(env, options.ImagePath)
109+
if err != nil {
110+
return err
111+
}
112+
113+
return runLisa(env, options.RunbookPath, qcow2Path, options.AdminPrivateKeyPath)
114+
}
115+
116+
// CheckTestRunner returns an error if the test runner is not supported.
117+
func CheckTestRunner(runner string) error {
118+
if !strings.EqualFold(runner, testRunnerLisa) {
119+
return fmt.Errorf("test runner %#q is not supported; only %#q is supported at this time", runner, testRunnerLisa)
120+
}
121+
122+
return nil
123+
}
124+
125+
// checkLisaInstalled verifies that the lisa executable is available on the host.
126+
func checkLisaInstalled(env *azldev.Env) error {
127+
if !env.CommandInSearchPath(testRunnerLisa) {
128+
return errors.New("'lisa' is not installed or not found in PATH; " +
129+
"please install LISA before running image tests")
130+
}
131+
132+
return nil
133+
}
134+
135+
// ResolveQcow2Image inspects the image at imagePath and returns a path to a qcow2 image.
136+
// If the image is already qcow2 it is returned as-is. If it is vhd or vhdfixed it is
137+
// converted to qcow2 in a temporary directory. Any other format is an error.
138+
func ResolveQcow2Image(env *azldev.Env, imagePath string) (string, error) {
139+
format, err := InferImageFormat(imagePath)
140+
if err != nil {
141+
return "", err
142+
}
143+
144+
switch format {
145+
case string(ImageFormatQcow2):
146+
slog.Info("Image is already in qcow2 format, using as-is", slog.String("path", imagePath))
147+
148+
return imagePath, nil
149+
150+
case string(ImageFormatVhd):
151+
return convertToQcow2(env, imagePath)
152+
153+
default:
154+
return "", fmt.Errorf(
155+
"image format %#q is not supported for testing; supported formats: qcow2, vhd, vhdfixed",
156+
format,
157+
)
158+
}
159+
}
160+
161+
// convertToQcow2 converts a vhd/vhdfixed disk image to qcow2 format using qemu-img and
162+
// returns the path to the converted file. The converted image is written alongside the
163+
// source file and is the caller's responsibility to clean up (or accept it as a leftover).
164+
func convertToQcow2(env *azldev.Env, srcPath string) (string, error) {
165+
if !env.CommandInSearchPath("qemu-img") {
166+
return "", errors.New("'qemu-img' is not installed or not found in PATH; " +
167+
"it is required to convert vhd/vhdfixed images to qcow2")
168+
}
169+
170+
baseName := strings.TrimSuffix(filepath.Base(srcPath), filepath.Ext(srcPath))
171+
destFileName := baseName + ".qcow2"
172+
173+
// Write the converted image alongside the source file so the path is predictable.
174+
destPath := filepath.Join(filepath.Dir(srcPath), testImagePrefix+"-"+destFileName)
175+
176+
slog.Info("Converting image to qcow2",
177+
slog.String("src", srcPath),
178+
slog.String("dest", destPath),
179+
)
180+
181+
if env.DryRun() {
182+
slog.Info("Dry-run: would convert image to qcow2",
183+
slog.String("src", srcPath),
184+
slog.String("dest", destPath),
185+
)
186+
187+
return destPath, nil
188+
}
189+
190+
convertCmd := exec.CommandContext(
191+
env, "qemu-img", "convert", "-O", "qcow2", srcPath, destPath,
192+
)
193+
convertCmd.Stdout = os.Stdout
194+
convertCmd.Stderr = os.Stderr
195+
196+
cmd, err := env.Command(convertCmd)
197+
if err != nil {
198+
return "", fmt.Errorf("failed to create qemu-img command:\n%w", err)
199+
}
200+
201+
if err = cmd.Run(env); err != nil {
202+
return "", fmt.Errorf("failed to convert image %#q to qcow2:\n%w", srcPath, err)
203+
}
204+
205+
slog.Info("Conversion complete", slog.String("dest", destPath))
206+
207+
return destPath, nil
208+
}
209+
210+
// validateFileExists returns an error if the path does not point to an existing regular file.
211+
func validateFileExists(fs opctx.FS, path string) error {
212+
isDir, err := fileutils.DirExists(fs, path)
213+
if err != nil {
214+
return fmt.Errorf("cannot access %#q:\n%w", path, err)
215+
}
216+
217+
if isDir {
218+
return fmt.Errorf("%#q is a directory, expected a file", path)
219+
}
220+
221+
exists, err := fileutils.Exists(fs, path)
222+
if err != nil {
223+
return fmt.Errorf("cannot access %#q:\n%w", path, err)
224+
}
225+
226+
if !exists {
227+
return fmt.Errorf("file not found: %#q", path)
228+
}
229+
230+
return nil
231+
}
232+
233+
// runLisa executes `lisa -r <runbookPath> -v "qcow2:<imagePath>"` and streams its
234+
// stdout and stderr directly to the terminal.
235+
func runLisa(env *azldev.Env, runbookPath, qcow2ImagePath, adminPrivateKeyPath string) error {
236+
slog.Info("Running LISA tests",
237+
slog.String("runbook", runbookPath),
238+
slog.String("image", qcow2ImagePath),
239+
)
240+
241+
args := []string{
242+
"-r", runbookPath,
243+
"-v", "qcow2:" + qcow2ImagePath,
244+
"-v", "admin_private_key_file:" + adminPrivateKeyPath,
245+
}
246+
247+
lisaCmd := exec.CommandContext(
248+
env,
249+
testRunnerLisa,
250+
args...,
251+
)
252+
lisaCmd.Stdout = os.Stdout
253+
lisaCmd.Stderr = os.Stderr
254+
255+
cmd, err := env.Command(lisaCmd)
256+
if err != nil {
257+
return fmt.Errorf("failed to create lisa command:\n%w", err)
258+
}
259+
260+
if err = cmd.Run(env); err != nil {
261+
return fmt.Errorf("lisa test run failed:\n%w", err)
262+
}
263+
264+
return nil
265+
}

0 commit comments

Comments
 (0)