From e946957190a547b0ec01aef3004ef5ce7d1e5bed Mon Sep 17 00:00:00 2001 From: reuben olinsky Date: Thu, 29 Oct 2026 19:36:40 +0000 Subject: [PATCH] refactor(lisa): extract image test helper package Move the existing LISA definition parsing, criteria, runbook, setup, invocation and tests into internal/lisa. Image commands call the helper directly; shared image-format inference and its tests move to a neutral utility without changing format priority or diagnostics. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/app/azldev/cmds/image/boot.go | 64 +++--------------- internal/app/azldev/cmds/image/boot_test.go | 46 ------------- internal/app/azldev/cmds/image/test.go | 11 +++- .../azldev/cmds/image/test_internal_test.go | 32 --------- internal/app/azldev/cmds/image/test_test.go | 62 +++++++++++++++++ .../lisacriteria.go => lisa/criteria.go} | 2 +- .../criteria_internal_test.go} | 2 +- .../image/lisarunbook.go => lisa/runbook.go} | 2 +- .../runbook_internal_test.go} | 2 +- .../image/lisarunner.go => lisa/runner.go} | 29 +++++--- .../runner_internal_test.go} | 43 ++++++++++-- internal/utils/imageformat/imageformat.go | 66 +++++++++++++++++++ .../utils/imageformat/imageformat_test.go | 58 ++++++++++++++++ 13 files changed, 265 insertions(+), 154 deletions(-) rename internal/{app/azldev/cmds/image/lisacriteria.go => lisa/criteria.go} (99%) rename internal/{app/azldev/cmds/image/lisacriteria_internal_test.go => lisa/criteria_internal_test.go} (99%) rename internal/{app/azldev/cmds/image/lisarunbook.go => lisa/runbook.go} (99%) rename internal/{app/azldev/cmds/image/lisarunbook_internal_test.go => lisa/runbook_internal_test.go} (99%) rename internal/{app/azldev/cmds/image/lisarunner.go => lisa/runner.go} (96%) rename internal/{app/azldev/cmds/image/lisarunner_internal_test.go => lisa/runner_internal_test.go} (66%) create mode 100644 internal/utils/imageformat/imageformat.go create mode 100644 internal/utils/imageformat/imageformat_test.go diff --git a/internal/app/azldev/cmds/image/boot.go b/internal/app/azldev/cmds/image/boot.go index d979d00ba..d371a61b1 100644 --- a/internal/app/azldev/cmds/image/boot.go +++ b/internal/app/azldev/cmds/image/boot.go @@ -15,6 +15,7 @@ import ( "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/imagebuilder" "github.com/microsoft/azure-linux-dev-tools/internal/utils/cloudinit" "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/imageformat" "github.com/microsoft/azure-linux-dev-tools/internal/utils/iso" "github.com/microsoft/azure-linux-dev-tools/internal/utils/qemu" "github.com/samber/lo" @@ -39,26 +40,15 @@ type ImageFormat string const ( // ImageFormatRaw is the raw disk image format. - ImageFormatRaw ImageFormat = "raw" + ImageFormatRaw ImageFormat = imageformat.Raw // ImageFormatQcow2 is the QEMU copy-on-write v2 format. - ImageFormatQcow2 ImageFormat = "qcow2" + ImageFormatQcow2 ImageFormat = imageformat.Qcow2 // ImageFormatVhd is the Hyper-V VHD format (QEMU driver: vpc). - ImageFormatVhd ImageFormat = "vhd" + ImageFormatVhd ImageFormat = imageformat.Vhd // ImageFormatVhdx is the Hyper-V virtual hard disk format. - ImageFormatVhdx ImageFormat = "vhdx" - // ImageFormatOCI is an OCI container image tarball. - ImageFormatOCI ImageFormat = "oci" + ImageFormatVhdx ImageFormat = imageformat.Vhdx ) -// AllImageFormats returns all supported image formats in priority order. -func AllImageFormats() []string { - return []string{ - string(ImageFormatRaw), string(ImageFormatQcow2), - string(ImageFormatVhdx), string(ImageFormatVhd), - string(ImageFormatOCI), - } -} - // BootableImageFormats returns the subset of image formats that can be booted in a VM. func BootableImageFormats() []string { return []string{ @@ -450,13 +440,14 @@ func resolveDiskSource(env *azldev.Env, options *ImageBootOptions) (imagePath, i case options.ImagePath != "": imageFormat = string(options.Format) if imageFormat == "" { - imageFormat, err = InferImageFormat(options.ImagePath) + imageFormat, err = imageformat.Infer(options.ImagePath) if err != nil { + //nolint:wrapcheck // Preserve the existing image-format diagnostic unchanged. return "", "", err } } - // InferImageFormat recognizes all known image formats (including non-bootable + // imageformat.Infer recognizes all known image formats (including non-bootable // ones like OCI tarballs); reject formats that QEMU can't boot here. if !lo.Contains(BootableImageFormats(), imageFormat) { return "", "", fmt.Errorf( @@ -598,7 +589,7 @@ func fileExtensionsForFormat(format string) []string { switch format { case string(ImageFormatVhd): return []string{"vhd", "vhdfixed"} - case string(ImageFormatOCI): + case imageformat.OCI: return []string{"oci.tar.xz", "oci.tar.gz", "oci.tar"} default: return []string{format} @@ -682,43 +673,6 @@ func findImageArtifact( ) } -// InferImageFormat determines the image format from the file extension. -// Returns an error if the extension does not match a supported format. -func InferImageFormat(imagePath string) (string, error) { - lower := strings.ToLower(imagePath) - - // Check multi-part extensions first (e.g., ".oci.tar.xz"). - for _, ext := range []string{".oci.tar.xz", ".oci.tar.gz", ".oci.tar"} { - if strings.HasSuffix(lower, ext) { - return string(ImageFormatOCI), nil - } - } - - ext := strings.ToLower(filepath.Ext(imagePath)) - if ext == "" { - return "", fmt.Errorf( - "cannot infer image format from %#q: no file extension", - imagePath, - ) - } - - // Strip the leading dot (e.g., ".vhdfixed" → "vhdfixed"). - format := ext[1:] - if format == "vhdfixed" { - format = string(ImageFormatVhd) - } - - // Validate the inferred format is supported. - if !lo.Contains(AllImageFormats(), format) { - return "", fmt.Errorf( - "unsupported image format %#q inferred from %#q; supported formats: %v", - format, imagePath, AllImageFormats(), - ) - } - - return format, nil -} - // listImageArtifacts returns a list of image artifact filenames in the given directory. func listImageArtifacts(env *azldev.Env, dir string) ([]string, error) { entries, err := fileutils.ReadDir(env.FS(), dir) diff --git a/internal/app/azldev/cmds/image/boot_test.go b/internal/app/azldev/cmds/image/boot_test.go index 39a80e735..3fa522c1b 100644 --- a/internal/app/azldev/cmds/image/boot_test.go +++ b/internal/app/azldev/cmds/image/boot_test.go @@ -181,52 +181,6 @@ func TestBootableImageFormats(t *testing.T) { assert.NotContains(t, formats, "oci", "OCI is not a bootable format") } -func TestAllImageFormats(t *testing.T) { - formats := image.AllImageFormats() - require.NotEmpty(t, formats) - assert.Contains(t, formats, "raw") - assert.Contains(t, formats, "qcow2") - assert.Contains(t, formats, "oci") -} - -func TestInferImageFormat(t *testing.T) { - tests := []struct { - name string - path string - expected string - }{ - {name: "raw", path: "/path/to/image.raw", expected: "raw"}, - {name: "qcow2", path: "/path/to/image.qcow2", expected: "qcow2"}, - {name: "vhd", path: "/path/to/image.vhd", expected: "vhd"}, - {name: "vhdfixed", path: "/path/to/image.vhdfixed", expected: "vhd"}, - {name: "vhdx", path: "/path/to/image.vhdx", expected: "vhdx"}, - {name: "oci.tar.xz", path: "/path/to/image.oci.tar.xz", expected: "oci"}, - {name: "oci.tar.gz", path: "/path/to/image.oci.tar.gz", expected: "oci"}, - {name: "oci.tar", path: "/path/to/image.oci.tar", expected: "oci"}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - format, err := image.InferImageFormat(test.path) - require.NoError(t, err) - assert.Equal(t, test.expected, format) - }) - } -} - -func TestInferImageFormat_NoExtension(t *testing.T) { - _, err := image.InferImageFormat("/path/to/imagefile") - require.Error(t, err) - assert.Contains(t, err.Error(), "no file extension") -} - -func TestInferImageFormat_UnsupportedExtension(t *testing.T) { - _, err := image.InferImageFormat("/path/to/image.iso") - require.Error(t, err) - assert.Contains(t, err.Error(), "unsupported image format") - assert.Contains(t, err.Error(), "iso") -} - func TestSupportedArchitectures(t *testing.T) { archs := qemu.SupportedArchitectures() require.NotEmpty(t, archs) diff --git a/internal/app/azldev/cmds/image/test.go b/internal/app/azldev/cmds/image/test.go index e91c5ae9b..20d8165b8 100644 --- a/internal/app/azldev/cmds/image/test.go +++ b/internal/app/azldev/cmds/image/test.go @@ -12,9 +12,11 @@ import ( "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev" "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/imagebuilder" + "github.com/microsoft/azure-linux-dev-tools/internal/lisa" "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" "github.com/microsoft/azure-linux-dev-tools/internal/pytest" "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/imageformat" "github.com/spf13/cobra" ) @@ -182,7 +184,7 @@ func resolveImageTestPath(env *azldev.Env, options *ImageTestOptions) (string, e return options.ImagePath, nil } - imagePath, _, err := findImageArtifact(env, options.ImageName, "", AllImageFormats()) + imagePath, _, err := findImageArtifact(env, options.ImageName, "", imageformat.All()) if err != nil { return "", err } @@ -260,7 +262,12 @@ func runResolvedTest( }) case string(projectconfig.TestTypeLisa): - return RunLisaTestDefinition(env, resolvedTest, imageConfig, options) + //nolint:wrapcheck // Preserve the existing command-facing diagnostic unchanged. + return lisa.RunTestDefinition(env, resolvedTest, imageConfig, &lisa.Options{ + ImageName: options.ImageName, + ImagePath: options.ImagePath, + LisaDir: options.LisaDir, + }) case "tmt": return fmt.Errorf( diff --git a/internal/app/azldev/cmds/image/test_internal_test.go b/internal/app/azldev/cmds/image/test_internal_test.go index 74377db5c..e50bd6356 100644 --- a/internal/app/azldev/cmds/image/test_internal_test.go +++ b/internal/app/azldev/cmds/image/test_internal_test.go @@ -10,7 +10,6 @@ import ( "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/testutils" "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" - "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -65,34 +64,3 @@ func TestResolveImageTestsToRun_NoWarnWhenOnlyNewTestsPresent(t *testing.T) { require.NoError(t, err) assert.NotContains(t, buf.String(), "ignored") } - -func TestResolveLisaFrameworkDir_UsesLisaDirWhenSet(t *testing.T) { - testEnv := testutils.NewTestEnv(t) - require.NoError(t, fileutils.MkdirAll(testEnv.Env.FS(), "/checkouts/lisa")) - - options := &ImageTestOptions{LisaDir: "/checkouts/lisa"} - - dir, err := resolveLisaFrameworkDir(testEnv.Env, "my-test", "/work/lisa", nil, options) - require.NoError(t, err) - assert.Equal(t, "/checkouts/lisa", dir) -} - -func TestResolveLisaFrameworkDir_LisaDirMissingReturnsError(t *testing.T) { - testEnv := testutils.NewTestEnv(t) - - options := &ImageTestOptions{LisaDir: "/does/not/exist"} - - _, err := resolveLisaFrameworkDir(testEnv.Env, "my-test", "/work/lisa", nil, options) - require.Error(t, err) - assert.Contains(t, err.Error(), "does not exist") -} - -func TestResolveLisaFrameworkDir_NoLisaDirAndNoFrameworkReturnsError(t *testing.T) { - testEnv := testutils.NewTestEnv(t) - - options := &ImageTestOptions{} - - _, err := resolveLisaFrameworkDir(testEnv.Env, "my-test", "/work/lisa", nil, options) - require.Error(t, err) - assert.Contains(t, err.Error(), "--lisa-dir") -} diff --git a/internal/app/azldev/cmds/image/test_test.go b/internal/app/azldev/cmds/image/test_test.go index f36d5d517..30f280a3b 100644 --- a/internal/app/azldev/cmds/image/test_test.go +++ b/internal/app/azldev/cmds/image/test_test.go @@ -83,3 +83,65 @@ func TestImageTestPreservesPytestOptions(t *testing.T) { "--path", "/images/candidate.raw", "--junit-xml", "/reports/results.xml", }}, commands) } + +func TestImageTestPreservesLISAOptions(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + testEnv.Config.Tests["smoke"] = projectconfig.TestDefinition{ + Type: string(projectconfig.TestTypeLisa), + Lisa: map[string]any{ + "name": "selected-case", + "extra-args": []any{ + "--image", "{image-name}", "--path", "{image-path}", + }, + }, + } + testEnv.Config.Images["selected-image"] = projectconfig.ImageConfig{ + Name: "configured-image", + Tests: &projectconfig.ImageTestsConfig{ + Tests: []projectconfig.TestRef{{Name: "smoke"}}, + }, + } + + frameworkDir := "/checkouts/lisa" + + venvDir := filepath.Join(testEnv.Env.WorkDir(), "lisa", "venv", "smoke") + for _, directory := range []string{frameworkDir, "/images", filepath.Join(venvDir, "bin")} { + require.NoError(t, fileutils.MkdirAll(testEnv.TestFS, directory)) + } + + require.NoError(t, fileutils.WriteFile( + testEnv.TestFS, filepath.Join(venvDir, "bin", "python3"), nil, fileperms.PublicExecutable, + )) + require.NoError(t, fileutils.WriteFile(testEnv.TestFS, "/images/candidate.qcow2", nil, fileperms.PublicFile)) + testEnv.CmdFactory.RegisterCommandInSearchPath("python3") + testEnv.CmdFactory.RegisterCommandInSearchPath("ssh-keygen") + + runbookPath := filepath.Join(frameworkDir, "azldev-generated-smoke.yml") + + var lisaCommands [][]string + + testEnv.CmdFactory.RunHandler = func(cmd *exec.Cmd) error { + if filepath.Base(cmd.Args[0]) == "lisa" { + require.Equal(t, filepath.Join(frameworkDir, "lisa"), cmd.Dir) + lisaCommands = append(lisaCommands, cmd.Args) + runbook, err := fileutils.ReadFile(testEnv.TestFS, runbookPath) + require.NoError(t, err) + assert.Contains(t, string(runbook), "selected-case") + assert.Contains(t, string(runbook), "/images/candidate.qcow2") + } + + return nil + } + cmd := image.NewImageTestCmd() + cmd.SetArgs([]string{ + "selected-image", "--image-path", "/images/candidate.qcow2", "--lisa-dir", frameworkDir, + }) + require.NoError(t, cmd.ExecuteContext(testEnv.Env)) + require.Equal(t, [][]string{{ + filepath.Join(venvDir, "bin", "lisa"), "-r", runbookPath, + "--image", "selected-image", "--path", "/images/candidate.qcow2", + }}, lisaCommands) + exists, err := fileutils.Exists(testEnv.TestFS, runbookPath) + require.NoError(t, err) + assert.False(t, exists, "generated runbook should still be removed") +} diff --git a/internal/app/azldev/cmds/image/lisacriteria.go b/internal/lisa/criteria.go similarity index 99% rename from internal/app/azldev/cmds/image/lisacriteria.go rename to internal/lisa/criteria.go index cfb3ea483..a267e7651 100644 --- a/internal/app/azldev/cmds/image/lisacriteria.go +++ b/internal/lisa/criteria.go @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -package image +package lisa import ( "errors" diff --git a/internal/app/azldev/cmds/image/lisacriteria_internal_test.go b/internal/lisa/criteria_internal_test.go similarity index 99% rename from internal/app/azldev/cmds/image/lisacriteria_internal_test.go rename to internal/lisa/criteria_internal_test.go index 9ef2a3478..a61e6997a 100644 --- a/internal/app/azldev/cmds/image/lisacriteria_internal_test.go +++ b/internal/lisa/criteria_internal_test.go @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -package image +package lisa import ( "testing" diff --git a/internal/app/azldev/cmds/image/lisarunbook.go b/internal/lisa/runbook.go similarity index 99% rename from internal/app/azldev/cmds/image/lisarunbook.go rename to internal/lisa/runbook.go index ba2357ac1..267948069 100644 --- a/internal/app/azldev/cmds/image/lisarunbook.go +++ b/internal/lisa/runbook.go @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -package image +package lisa import ( "fmt" diff --git a/internal/app/azldev/cmds/image/lisarunbook_internal_test.go b/internal/lisa/runbook_internal_test.go similarity index 99% rename from internal/app/azldev/cmds/image/lisarunbook_internal_test.go rename to internal/lisa/runbook_internal_test.go index 1b449cfb4..ec5f7b546 100644 --- a/internal/app/azldev/cmds/image/lisarunbook_internal_test.go +++ b/internal/lisa/runbook_internal_test.go @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -package image +package lisa import ( "testing" diff --git a/internal/app/azldev/cmds/image/lisarunner.go b/internal/lisa/runner.go similarity index 96% rename from internal/app/azldev/cmds/image/lisarunner.go rename to internal/lisa/runner.go index 2823373d8..ea86aeb06 100644 --- a/internal/app/azldev/cmds/image/lisarunner.go +++ b/internal/lisa/runner.go @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -package image +package lisa import ( "fmt" @@ -16,6 +16,7 @@ import ( "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileperms" "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils" "github.com/microsoft/azure-linux-dev-tools/internal/utils/git" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/imageformat" "github.com/microsoft/azure-linux-dev-tools/internal/utils/prereqs" "github.com/microsoft/azure-linux-dev-tools/internal/utils/pythonenv" ) @@ -35,15 +36,22 @@ const ( lisaGeneratedRunbookPrefix = "azldev-generated-" ) -// RunLisaTestDefinition runs a new-style [tests.X] LISA test definition locally, generating +// Options holds the image inputs and optional framework checkout for a LISA run. +type Options struct { + ImageName string + ImagePath string + LisaDir string +} + +// RunTestDefinition runs a new-style [tests.X] LISA test definition locally, generating // a runbook from the test's configured criteria and invoking LISA. It requires either // options.LisaDir (an already-cloned LISA checkout) or the test's [tests.X.lisa] subtable // to include a 'source' (git-url, ref) identifying the LISA framework to clone; tests with // neither are metadata-only and cannot be run locally (they must be run through external // orchestration). -func RunLisaTestDefinition( +func RunTestDefinition( env *azldev.Env, resolvedTest projectconfig.ResolvedTest, - imageConfig *projectconfig.ImageConfig, options *ImageTestOptions, + imageConfig *projectconfig.ImageConfig, options *Options, ) error { lisa := resolvedTest.Definition.Lisa if lisa == nil { @@ -105,13 +113,13 @@ func RunLisaTestDefinition( } // runLisaLocally sets up the LISA framework and venv, generates a runbook from the given -// criteria, and invokes LISA against the given image. It backs RunLisaTestDefinition +// criteria, and invokes LISA against the given image. It backs RunTestDefinition // (new-style [tests] definitions). If options.LisaDir is set, that checkout is used directly // instead of cloning framework; in that case framework may be nil. func runLisaLocally( env *azldev.Env, name string, framework *projectconfig.GitSourceConfig, criteria []lisaCriteria, pipPreInstall, pipExtras, extraArgs []string, - imageConfig *projectconfig.ImageConfig, options *ImageTestOptions, + imageConfig *projectconfig.ImageConfig, options *Options, ) error { // Ensure python3 is available; git is only needed when azldev clones the framework // itself (i.e., --lisa-dir was not given). @@ -190,7 +198,7 @@ func runLisaLocally( // introducing any git-source metadata requirement for the test. framework may be nil only // when options.LisaDir is set. func resolveLisaFrameworkDir( - env *azldev.Env, name, lisaBaseDir string, framework *projectconfig.GitSourceConfig, options *ImageTestOptions, + env *azldev.Env, name, lisaBaseDir string, framework *projectconfig.GitSourceConfig, options *Options, ) (string, error) { if options.LisaDir != "" { absDir, err := filepath.Abs(options.LisaDir) @@ -295,12 +303,13 @@ func writeGeneratedRunbook( // generated LISA runbook requires (LISA's qemu platform boots qcow2 disks). Any other // format is rejected. func requireQcow2Image(imagePath string) error { - format, err := InferImageFormat(imagePath) + format, err := imageformat.Infer(imagePath) if err != nil { + //nolint:wrapcheck // Preserve the existing image-format diagnostic unchanged. return err } - if format != string(ImageFormatQcow2) { + if format != imageformat.Qcow2 { return fmt.Errorf( "unsupported image format %#q for %#q: LISA requires a qcow2 image", format, imagePath, @@ -520,7 +529,7 @@ func buildLisaArgs( runbookPath string, extraArgs []string, imageConfig *projectconfig.ImageConfig, - options *ImageTestOptions, + options *Options, ) ([]string, error) { absImagePath, err := filepath.Abs(options.ImagePath) if err != nil { diff --git a/internal/app/azldev/cmds/image/lisarunner_internal_test.go b/internal/lisa/runner_internal_test.go similarity index 66% rename from internal/app/azldev/cmds/image/lisarunner_internal_test.go rename to internal/lisa/runner_internal_test.go index 99360b23e..bf68f8281 100644 --- a/internal/app/azldev/cmds/image/lisarunner_internal_test.go +++ b/internal/lisa/runner_internal_test.go @@ -1,13 +1,15 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -package image +package lisa import ( "path/filepath" "testing" + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/testutils" "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils" "github.com/samber/lo" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -22,7 +24,7 @@ func TestBuildLisaArgs(t *testing.T) { Properties: map[string]string{"openssl-fips-provider": "symcrypt"}, } - options := &ImageTestOptions{ + options := &Options{ ImageName: "vm-base", ImagePath: "relative/image.qcow2", } @@ -58,7 +60,7 @@ func TestBuildLisaArgs(t *testing.T) { func TestBuildLisaArgs_NoExtraArgs(t *testing.T) { imageConfig := &projectconfig.ImageConfig{} - options := &ImageTestOptions{ImageName: "vm-base", ImagePath: "/abs/image.qcow2"} + options := &Options{ImageName: "vm-base", ImagePath: "/abs/image.qcow2"} args, err := buildLisaArgs("/runbook.yml", nil, imageConfig, options) require.NoError(t, err) @@ -68,7 +70,7 @@ func TestBuildLisaArgs_NoExtraArgs(t *testing.T) { func TestBuildLisaArgs_EmptyCapabilities(t *testing.T) { imageConfig := &projectconfig.ImageConfig{} - options := &ImageTestOptions{ImageName: "vm-base", ImagePath: "/abs/image.qcow2"} + options := &Options{ImageName: "vm-base", ImagePath: "/abs/image.qcow2"} args, err := buildLisaArgs("/runbook.yml", []string{"{capabilities}"}, imageConfig, options) require.NoError(t, err) @@ -78,7 +80,7 @@ func TestBuildLisaArgs_EmptyCapabilities(t *testing.T) { func TestBuildLisaArgs_EmptyProperties(t *testing.T) { imageConfig := &projectconfig.ImageConfig{} - options := &ImageTestOptions{ImageName: "vm-base", ImagePath: "/abs/image.qcow2"} + options := &Options{ImageName: "vm-base", ImagePath: "/abs/image.qcow2"} args, err := buildLisaArgs("/runbook.yml", []string{"{properties}"}, imageConfig, options) require.NoError(t, err) @@ -109,3 +111,34 @@ func TestRequireQcow2Image(t *testing.T) { assert.Contains(t, err.Error(), "cannot infer image format") }) } + +func TestResolveLisaFrameworkDir_UsesLisaDirWhenSet(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + require.NoError(t, fileutils.MkdirAll(testEnv.Env.FS(), "/checkouts/lisa")) + + options := &Options{LisaDir: "/checkouts/lisa"} + + dir, err := resolveLisaFrameworkDir(testEnv.Env, "my-test", "/work/lisa", nil, options) + require.NoError(t, err) + assert.Equal(t, "/checkouts/lisa", dir) +} + +func TestResolveLisaFrameworkDir_LisaDirMissingReturnsError(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + + options := &Options{LisaDir: "/does/not/exist"} + + _, err := resolveLisaFrameworkDir(testEnv.Env, "my-test", "/work/lisa", nil, options) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not exist") +} + +func TestResolveLisaFrameworkDir_NoLisaDirAndNoFrameworkReturnsError(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + + options := &Options{} + + _, err := resolveLisaFrameworkDir(testEnv.Env, "my-test", "/work/lisa", nil, options) + require.Error(t, err) + assert.Contains(t, err.Error(), "--lisa-dir") +} diff --git a/internal/utils/imageformat/imageformat.go b/internal/utils/imageformat/imageformat.go new file mode 100644 index 000000000..5146f5a0c --- /dev/null +++ b/internal/utils/imageformat/imageformat.go @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package imageformat + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/samber/lo" +) + +const ( + Raw = "raw" + Qcow2 = "qcow2" + Vhd = "vhd" + Vhdx = "vhdx" + OCI = "oci" +) + +// All returns all supported image formats in priority order. +func All() []string { + return []string{ + string(Raw), string(Qcow2), + string(Vhdx), string(Vhd), + string(OCI), + } +} + +// Infer determines the image format from the file extension. +// Returns an error if the extension does not match a supported format. +func Infer(imagePath string) (string, error) { + lower := strings.ToLower(imagePath) + + // Check multi-part extensions first (e.g., ".oci.tar.xz"). + for _, ext := range []string{".oci.tar.xz", ".oci.tar.gz", ".oci.tar"} { + if strings.HasSuffix(lower, ext) { + return string(OCI), nil + } + } + + ext := strings.ToLower(filepath.Ext(imagePath)) + if ext == "" { + return "", fmt.Errorf( + "cannot infer image format from %#q: no file extension", + imagePath, + ) + } + + // Strip the leading dot (e.g., ".vhdfixed" -> "vhdfixed"). + format := ext[1:] + if format == "vhdfixed" { + format = string(Vhd) + } + + // Validate the inferred format is supported. + if !lo.Contains(All(), format) { + return "", fmt.Errorf( + "unsupported image format %#q inferred from %#q; supported formats: %v", + format, imagePath, All(), + ) + } + + return format, nil +} diff --git a/internal/utils/imageformat/imageformat_test.go b/internal/utils/imageformat/imageformat_test.go new file mode 100644 index 000000000..95786a8e0 --- /dev/null +++ b/internal/utils/imageformat/imageformat_test.go @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package imageformat_test + +import ( + "testing" + + "github.com/microsoft/azure-linux-dev-tools/internal/utils/imageformat" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAllImageFormats(t *testing.T) { + formats := imageformat.All() + require.NotEmpty(t, formats) + assert.Contains(t, formats, "raw") + assert.Contains(t, formats, "qcow2") + assert.Contains(t, formats, "oci") +} + +func TestInferImageFormat(t *testing.T) { + tests := []struct { + name string + path string + expected string + }{ + {name: "raw", path: "/path/to/image.raw", expected: "raw"}, + {name: "qcow2", path: "/path/to/image.qcow2", expected: "qcow2"}, + {name: "vhd", path: "/path/to/image.vhd", expected: "vhd"}, + {name: "vhdfixed", path: "/path/to/image.vhdfixed", expected: "vhd"}, + {name: "vhdx", path: "/path/to/image.vhdx", expected: "vhdx"}, + {name: "oci.tar.xz", path: "/path/to/image.oci.tar.xz", expected: "oci"}, + {name: "oci.tar.gz", path: "/path/to/image.oci.tar.gz", expected: "oci"}, + {name: "oci.tar", path: "/path/to/image.oci.tar", expected: "oci"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + format, err := imageformat.Infer(test.path) + require.NoError(t, err) + assert.Equal(t, test.expected, format) + }) + } +} + +func TestInferImageFormat_NoExtension(t *testing.T) { + _, err := imageformat.Infer("/path/to/imagefile") + require.Error(t, err) + assert.Contains(t, err.Error(), "no file extension") +} + +func TestInferImageFormat_UnsupportedExtension(t *testing.T) { + _, err := imageformat.Infer("/path/to/image.iso") + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported image format") + assert.Contains(t, err.Error(), "iso") +}