diff --git a/internal/app/azldev/cmds/image/lisarunner.go b/internal/app/azldev/cmds/image/lisarunner.go index 990f7eba..2823373d 100644 --- a/internal/app/azldev/cmds/image/lisarunner.go +++ b/internal/app/azldev/cmds/image/lisarunner.go @@ -17,6 +17,7 @@ import ( "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/prereqs" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/pythonenv" ) const ( @@ -114,7 +115,7 @@ func runLisaLocally( ) error { // Ensure python3 is available; git is only needed when azldev clones the framework // itself (i.e., --lisa-dir was not given). - if err := prereqs.RequireExecutable(env, pythonProgram, nil); err != nil { + if err := prereqs.RequireExecutable(env, pythonenv.Program, nil); err != nil { return fmt.Errorf("python3 is required to run LISA tests:\n%w", err) } @@ -446,7 +447,7 @@ func ensureLisaVenv( ) (string, error) { venvDir := filepath.Join(env.WorkDir(), lisaDirName, lisaVenvDirName, suiteName) - venvPython := filepath.Join(venvDir, "bin", pythonProgram) + venvPython := filepath.Join(venvDir, "bin", pythonenv.Program) venvExists, err := fileutils.Exists(env.FS(), venvPython) if err != nil { @@ -456,7 +457,8 @@ func ensureLisaVenv( if !venvExists { slog.Info("Creating LISA Python venv", slog.String("path", venvDir)) - if err := createPythonVenv(env, venvDir); err != nil { + if err := pythonenv.Create(env, venvDir); err != nil { + //nolint:wrapcheck // Preserve the existing venv-creation error unchanged. return "", err } } else { @@ -525,16 +527,17 @@ func buildLisaArgs( absImagePath = options.ImagePath } - properties, err := serializeImageProperties(imageConfig.Properties) + properties, err := imageConfig.PropertiesJSON() if err != nil { + //nolint:wrapcheck // Preserve the existing property-serialization error unchanged. return nil, err } replacer := strings.NewReplacer( - imagePlaceholder, absImagePath, - imageNamePlaceholder, options.ImageName, - capabilitiesPlaceholder, strings.Join(imageConfig.Capabilities.EnabledNames(), ","), - propertiesPlaceholder, properties, + projectconfig.ImagePlaceholder, absImagePath, + projectconfig.ImageNamePlaceholder, options.ImageName, + projectconfig.CapabilitiesPlaceholder, strings.Join(imageConfig.Capabilities.EnabledNames(), ","), + projectconfig.PropertiesPlaceholder, properties, ) baseArgs := []string{"-r", runbookPath} diff --git a/internal/app/azldev/cmds/image/list.go b/internal/app/azldev/cmds/image/list.go index 42c16b43..f3cbaa9b 100644 --- a/internal/app/azldev/cmds/image/list.go +++ b/internal/app/azldev/cmds/image/list.go @@ -143,8 +143,9 @@ func ListImages(env *azldev.Env, options *ListImageOptions) ([]ImageListResult, imageConfig := cfg.Images[name] - propertiesSummary, err := serializeImageProperties(imageConfig.Properties) + propertiesSummary, err := imageConfig.PropertiesJSON() if err != nil { + //nolint:wrapcheck // Preserve the existing property-serialization error unchanged. return nil, err } diff --git a/internal/app/azldev/cmds/image/test.go b/internal/app/azldev/cmds/image/test.go index dc1ec7b0..e91c5ae9 100644 --- a/internal/app/azldev/cmds/image/test.go +++ b/internal/app/azldev/cmds/image/test.go @@ -13,8 +13,8 @@ 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/projectconfig" + "github.com/microsoft/azure-linux-dev-tools/internal/pytest" "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils" - "github.com/pelletier/go-toml/v2" "github.com/spf13/cobra" ) @@ -252,12 +252,12 @@ func runResolvedTest( ) error { switch resolvedTest.Definition.Type { case string(projectconfig.TestTypePytest): - suiteConfig, err := testDefinitionToSuiteConfig(resolvedTest) - if err != nil { - return err - } - - return RunPytestSuite(env, suiteConfig, imageConfig, options) + //nolint:wrapcheck // Preserve the existing command-facing error unchanged. + return pytest.RunTestDefinition(env, resolvedTest, imageConfig, &pytest.Options{ + ImageName: options.ImageName, + ImagePath: options.ImagePath, + JUnitXMLPath: options.JUnitXMLPath, + }) case string(projectconfig.TestTypeLisa): return RunLisaTestDefinition(env, resolvedTest, imageConfig, options) @@ -273,45 +273,3 @@ func runResolvedTest( return fmt.Errorf("unsupported test type %#q for test %#q", resolvedTest.Definition.Type, resolvedTest.Name) } } - -func testDefinitionToSuiteConfig(resolvedTest projectconfig.ResolvedTest) (*projectconfig.TestSuiteConfig, error) { - pytestConfig, err := decodePytestConfig(resolvedTest.Definition.Pytest) - if err != nil { - return nil, fmt.Errorf("decode pytest config for test %#q:\n%w", resolvedTest.Name, err) - } - - // The stored config keeps 'working-dir' as authored; resolve it relative to - // the defining config file's directory only here, for execution. - pytestConfig.WorkingDir = resolvedTest.Definition.PytestWorkingDir() - - suiteConfig := &projectconfig.TestSuiteConfig{ - Name: resolvedTest.Name, - Description: resolvedTest.Definition.Description, - Type: projectconfig.TestTypePytest, - Pytest: pytestConfig, - } - - if err := suiteConfig.Validate(); err != nil { - return nil, fmt.Errorf("invalid pytest test %#q:\n%w", resolvedTest.Name, err) - } - - return suiteConfig, nil -} - -func decodePytestConfig(raw map[string]any) (*projectconfig.PytestConfig, error) { - if raw == nil { - return nil, errors.New("missing [pytest] subtable") - } - - bytes, err := toml.Marshal(raw) - if err != nil { - return nil, fmt.Errorf("marshal pytest config:\n%w", err) - } - - pytestConfig := &projectconfig.PytestConfig{} - if err := toml.Unmarshal(bytes, pytestConfig); err != nil { - return nil, fmt.Errorf("unmarshal pytest config:\n%w", err) - } - - return pytestConfig, nil -} diff --git a/internal/app/azldev/cmds/image/test_internal_test.go b/internal/app/azldev/cmds/image/test_internal_test.go index ee4ce057..74377db5 100644 --- a/internal/app/azldev/cmds/image/test_internal_test.go +++ b/internal/app/azldev/cmds/image/test_internal_test.go @@ -96,39 +96,3 @@ func TestResolveLisaFrameworkDir_NoLisaDirAndNoFrameworkReturnsError(t *testing. require.Error(t, err) assert.Contains(t, err.Error(), "--lisa-dir") } - -func TestTestDefinitionToSuiteConfig_Pytest(t *testing.T) { - resolvedTest := projectconfig.ResolvedTest{ - Name: "static-image-checks", - Definition: projectconfig.TestDefinition{ - Type: "pytest", - Description: "offline validation", - Pytest: map[string]any{"working-dir": "/project/tests", "install": "pyproject"}, - }, - } - - suite, err := testDefinitionToSuiteConfig(resolvedTest) - require.NoError(t, err) - require.NotNil(t, suite) - assert.Equal(t, "static-image-checks", suite.Name) - require.NotNil(t, suite.Pytest) - assert.Equal(t, "/project/tests", suite.Pytest.WorkingDir) - assert.Equal(t, projectconfig.PytestInstallPyproject, suite.Pytest.Install) -} - -func TestTestDefinitionToSuiteConfig_Pytest_ResolvesRelativeWorkingDir(t *testing.T) { - // Simulate a test defined in an included config under /project/sub with a - // relative working-dir. The generated suite must receive the working-dir - // resolved against that config's directory, not the authored relative value. - definition := projectconfig.TestDefinition{ - Type: "pytest", - Pytest: map[string]any{"working-dir": "tests", "test-paths": []any{"cases/"}}, - }.WithConfigDir("/project/sub") - - resolvedTest := projectconfig.ResolvedTest{Name: "static-image-checks", Definition: definition} - - suite, err := testDefinitionToSuiteConfig(resolvedTest) - require.NoError(t, err) - require.NotNil(t, suite.Pytest) - assert.Equal(t, "/project/sub/tests", suite.Pytest.WorkingDir) -} diff --git a/internal/app/azldev/cmds/image/test_test.go b/internal/app/azldev/cmds/image/test_test.go index 26ab6e2b..f36d5d51 100644 --- a/internal/app/azldev/cmds/image/test_test.go +++ b/internal/app/azldev/cmds/image/test_test.go @@ -4,9 +4,15 @@ package image_test import ( + "os/exec" + "path/filepath" "testing" "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/cmds/image" + "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/fileperms" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -25,3 +31,55 @@ func TestNewImageTestCmd_Flags(t *testing.T) { assert.NotNil(t, cmd.Flags().Lookup("image-path")) assert.NotNil(t, cmd.Flags().Lookup("junit-xml")) } + +func TestImageTestPreservesPytestOptions(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + testEnv.Config.Tests = map[string]projectconfig.TestDefinition{ + "smoke": { + Type: string(projectconfig.TestTypePytest), + Pytest: map[string]any{ + "working-dir": "/tests", + "test-paths": []string{"cases/"}, + "extra-args": []string{"--image", "{image-name}", "--path", "{image-path}"}, + "install": "none", + }, + }, + } + testEnv.Config.Images = map[string]projectconfig.ImageConfig{ + "selected-image": { + Name: "configured-image", + Tests: &projectconfig.ImageTestsConfig{ + Tests: []projectconfig.TestRef{{Name: "smoke"}}, + }, + }, + } + + venvDir := filepath.Join(testEnv.Env.WorkDir(), "pytest-venv", "smoke") + for _, directory := range []string{"/tests", "/images", filepath.Join(venvDir, "bin")} { + require.NoError(t, fileutils.MkdirAll(testEnv.TestFS, directory)) + } + + venvPython := filepath.Join(venvDir, "bin", "python3") + require.NoError(t, fileutils.WriteFile(testEnv.TestFS, venvPython, nil, fileperms.PublicExecutable)) + require.NoError(t, fileutils.WriteFile(testEnv.TestFS, "/images/candidate.raw", nil, fileperms.PublicFile)) + testEnv.CmdFactory.RegisterCommandInSearchPath("python3") + + var commands [][]string + + testEnv.CmdFactory.RunHandler = func(cmd *exec.Cmd) error { + require.Equal(t, "/tests", cmd.Dir) + commands = append(commands, cmd.Args) + + return nil + } + cmd := image.NewImageTestCmd() + cmd.SetArgs([]string{ + "selected-image", "--image-path", "/images/candidate.raw", + "--junit-xml", "/reports/results.xml", + }) + require.NoError(t, cmd.ExecuteContext(testEnv.Env)) + require.Equal(t, [][]string{{ + venvPython, "-m", "pytest", "cases/", "--image", "selected-image", + "--path", "/images/candidate.raw", "--junit-xml", "/reports/results.xml", + }}, commands) +} diff --git a/internal/projectconfig/image.go b/internal/projectconfig/image.go index 13ab6b92..e75a58f4 100644 --- a/internal/projectconfig/image.go +++ b/internal/projectconfig/image.go @@ -4,6 +4,7 @@ package projectconfig import ( + "encoding/json" "fmt" "slices" @@ -91,6 +92,21 @@ type ImageConfig struct { Properties map[string]string `toml:"properties,omitempty" json:"properties,omitempty" jsonschema:"title=Properties,description=Extensible string properties describing this image"` } +// PropertiesJSON encodes the image property bag for test arguments and listings. +func (i *ImageConfig) PropertiesJSON() (string, error) { + properties := i.Properties + if properties == nil { + properties = map[string]string{} + } + + serialized, err := json.Marshal(properties) + if err != nil { + return "", fmt.Errorf("failed to serialize image properties:\n%w", err) + } + + return string(serialized), nil +} + // SupportsArchitecture reports whether the image supports arch. An image with no // declared Architectures is treated as unrestricted, for compatibility with // images.toml files that predate this field, but arch must still be one of the diff --git a/internal/projectconfig/image_test.go b/internal/projectconfig/image_test.go new file mode 100644 index 00000000..59c1aea0 --- /dev/null +++ b/internal/projectconfig/image_test.go @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package projectconfig_test + +import ( + "testing" + + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" + "github.com/stretchr/testify/require" +) + +func TestImagePropertiesJSONPreservesNilProperties(t *testing.T) { + image := &projectconfig.ImageConfig{} + properties, err := image.PropertiesJSON() + require.NoError(t, err) + require.Equal(t, "{}", properties) + require.Nil(t, image.Properties) +} diff --git a/internal/projectconfig/testsuite.go b/internal/projectconfig/testsuite.go index 257acd46..1d869607 100644 --- a/internal/projectconfig/testsuite.go +++ b/internal/projectconfig/testsuite.go @@ -19,6 +19,17 @@ const ( TestTypeLisa TestType = "lisa" ) +const ( + // ImagePlaceholder is the placeholder token for the image path. + ImagePlaceholder = "{image-path}" + // ImageNamePlaceholder is the placeholder token for the image name. + ImageNamePlaceholder = "{image-name}" + // CapabilitiesPlaceholder is the placeholder token for the comma-delimited capabilities. + CapabilitiesPlaceholder = "{capabilities}" + // PropertiesPlaceholder is the placeholder token for the JSON-encoded image properties. + PropertiesPlaceholder = "{properties}" +) + var ( // ErrDuplicateTests is returned when duplicate conflicting [tests] entries are found. ErrDuplicateTests = errors.New("duplicate test") diff --git a/internal/pytest/config.go b/internal/pytest/config.go new file mode 100644 index 00000000..f1122c13 --- /dev/null +++ b/internal/pytest/config.go @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package pytest + +import ( + "errors" + "fmt" + + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev" + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" + "github.com/pelletier/go-toml/v2" +) + +// RunTestDefinition prepares a configured pytest test and executes it. +func RunTestDefinition( + env *azldev.Env, resolvedTest projectconfig.ResolvedTest, + imageConfig *projectconfig.ImageConfig, options *Options, +) error { + suiteConfig, err := testDefinitionToSuiteConfig(resolvedTest) + if err != nil { + return err + } + + return RunPytestSuite(env, suiteConfig, imageConfig, options) +} + +func testDefinitionToSuiteConfig(resolvedTest projectconfig.ResolvedTest) (*projectconfig.TestSuiteConfig, error) { + pytestConfig, err := decodePytestConfig(resolvedTest.Definition.Pytest) + if err != nil { + return nil, fmt.Errorf("decode pytest config for test %#q:\n%w", resolvedTest.Name, err) + } + + // The stored config keeps 'working-dir' as authored; resolve it relative to + // the defining config file's directory only here, for execution. + pytestConfig.WorkingDir = resolvedTest.Definition.PytestWorkingDir() + + suiteConfig := &projectconfig.TestSuiteConfig{ + Name: resolvedTest.Name, + Description: resolvedTest.Definition.Description, + Type: projectconfig.TestTypePytest, + Pytest: pytestConfig, + } + + if err := suiteConfig.Validate(); err != nil { + return nil, fmt.Errorf("invalid pytest test %#q:\n%w", resolvedTest.Name, err) + } + + return suiteConfig, nil +} + +func decodePytestConfig(raw map[string]any) (*projectconfig.PytestConfig, error) { + if raw == nil { + return nil, errors.New("missing [pytest] subtable") + } + + bytes, err := toml.Marshal(raw) + if err != nil { + return nil, fmt.Errorf("marshal pytest config:\n%w", err) + } + + pytestConfig := &projectconfig.PytestConfig{} + if err := toml.Unmarshal(bytes, pytestConfig); err != nil { + return nil, fmt.Errorf("unmarshal pytest config:\n%w", err) + } + + return pytestConfig, nil +} diff --git a/internal/pytest/config_internal_test.go b/internal/pytest/config_internal_test.go new file mode 100644 index 00000000..3dd17ed8 --- /dev/null +++ b/internal/pytest/config_internal_test.go @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package pytest + +import ( + "testing" + + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTestDefinitionToSuiteConfig_Pytest(t *testing.T) { + resolvedTest := projectconfig.ResolvedTest{ + Name: "static-image-checks", + Definition: projectconfig.TestDefinition{ + Type: "pytest", + Description: "offline validation", + Pytest: map[string]any{"working-dir": "/project/tests", "install": "pyproject"}, + }, + } + + suite, err := testDefinitionToSuiteConfig(resolvedTest) + require.NoError(t, err) + require.NotNil(t, suite) + assert.Equal(t, "static-image-checks", suite.Name) + require.NotNil(t, suite.Pytest) + assert.Equal(t, "/project/tests", suite.Pytest.WorkingDir) + assert.Equal(t, projectconfig.PytestInstallPyproject, suite.Pytest.Install) +} + +func TestTestDefinitionToSuiteConfig_Pytest_ResolvesRelativeWorkingDir(t *testing.T) { + // Simulate a test defined in an included config under /project/sub with a + // relative working-dir. The generated suite must receive the working-dir + // resolved against that config's directory, not the authored relative value. + definition := projectconfig.TestDefinition{ + Type: "pytest", + Pytest: map[string]any{"working-dir": "tests", "test-paths": []any{"cases/"}}, + }.WithConfigDir("/project/sub") + + resolvedTest := projectconfig.ResolvedTest{Name: "static-image-checks", Definition: definition} + + suite, err := testDefinitionToSuiteConfig(resolvedTest) + require.NoError(t, err) + require.NotNil(t, suite.Pytest) + assert.Equal(t, "/project/sub/tests", suite.Pytest.WorkingDir) +} diff --git a/internal/app/azldev/cmds/image/pytestrunner.go b/internal/pytest/runner.go similarity index 82% rename from internal/app/azldev/cmds/image/pytestrunner.go rename to internal/pytest/runner.go index b609eb51..4fb0dfdd 100644 --- a/internal/app/azldev/cmds/image/pytestrunner.go +++ b/internal/pytest/runner.go @@ -1,10 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -package image +package pytest import ( - "encoding/json" "fmt" "log/slog" "os" @@ -18,29 +17,23 @@ import ( "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils" "github.com/microsoft/azure-linux-dev-tools/internal/utils/prereqs" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/pythonenv" ) -const ( - // pythonProgram is the Python interpreter used to create venvs and run pytest. - pythonProgram = "python3" - - // venvDirName is the name of the venv directory created under the azldev work dir. - venvDirName = "pytest-venv" - - // imagePlaceholder is the placeholder token for the image path. - imagePlaceholder = "{image-path}" - // imageNamePlaceholder is the placeholder token for the image name. - imageNamePlaceholder = "{image-name}" - // capabilitiesPlaceholder is the placeholder token for the comma-delimited capabilities. - capabilitiesPlaceholder = "{capabilities}" - // propertiesPlaceholder is the placeholder token for the JSON-encoded image properties. - propertiesPlaceholder = "{properties}" -) +// venvDirName is the name of the venv directory created under the azldev work dir. +const venvDirName = "pytest-venv" + +// Options holds the image inputs and report destination for a pytest run. +type Options struct { + ImageName string + ImagePath string + JUnitXMLPath string +} // RunPytestSuite runs a pytest-based test suite natively using a Python venv. func RunPytestSuite( env *azldev.Env, suiteConfig *projectconfig.TestSuiteConfig, - imageConfig *projectconfig.ImageConfig, options *ImageTestOptions, + imageConfig *projectconfig.ImageConfig, options *Options, ) error { pytestConfig := suiteConfig.Pytest if pytestConfig == nil { @@ -66,7 +59,7 @@ func RunPytestSuite( } // Ensure python3 is available. - if err := prereqs.RequireExecutable(env, pythonProgram, nil); err != nil { + if err := prereqs.RequireExecutable(env, pythonenv.Program, nil); err != nil { return fmt.Errorf("python3 is required to run pytest tests:\n%w", err) } @@ -84,7 +77,7 @@ func RunPytestSuite( slog.Info("Running pytest", slog.Any("args", pytestArgs)) - venvPython := filepath.Join(venvDir, "bin", pythonProgram) + venvPython := filepath.Join(venvDir, "bin", pythonenv.Program) cmdArgs := append([]string{"-m", "pytest"}, pytestArgs...) @@ -128,7 +121,7 @@ func ensurePytestVenv( venvDir := filepath.Join(workDir, venvDirName, testName) - venvPython := filepath.Join(venvDir, "bin", pythonProgram) + venvPython := filepath.Join(venvDir, "bin", pythonenv.Program) venvExists, err := fileutils.Exists(env.FS(), venvPython) if err != nil { @@ -136,7 +129,8 @@ func ensurePytestVenv( } if !venvExists { - if err := createPythonVenv(env, venvDir); err != nil { + if err := pythonenv.Create(env, venvDir); err != nil { + //nolint:wrapcheck // Preserve the existing venv-creation error unchanged. return "", err } } else { @@ -151,26 +145,6 @@ func ensurePytestVenv( return venvDir, nil } -// createPythonVenv creates a new Python virtual environment at venvDir. -func createPythonVenv(env *azldev.Env, venvDir string) error { - slog.Info("Creating Python venv", slog.String("path", venvDir)) - - venvCmd := exec.CommandContext(env, pythonProgram, "-m", "venv", venvDir) - venvCmd.Stdout = os.Stdout - venvCmd.Stderr = os.Stderr - - cmd, err := env.Command(venvCmd) - if err != nil { - return fmt.Errorf("failed to create venv command:\n%w", err) - } - - if err := cmd.Run(env); err != nil { - return fmt.Errorf("failed to create Python venv at %#q:\n%w", venvDir, err) - } - - return nil -} - // installPytestDependencies installs Python dependencies into the venv according to the // configured [projectconfig.PytestInstallMode]. Config validation guarantees that // 'working-dir' is set whenever the effective mode requires it. @@ -296,24 +270,25 @@ func BuildNativePytestArgs( fs opctx.FS, pytestConfig *projectconfig.PytestConfig, imageConfig *projectconfig.ImageConfig, - options *ImageTestOptions, + options *Options, ) ([]string, error) { absImagePath, err := filepath.Abs(options.ImagePath) if err != nil { absImagePath = options.ImagePath } - properties, err := serializeImageProperties(imageConfig.Properties) + properties, err := imageConfig.PropertiesJSON() if err != nil { + //nolint:wrapcheck // Preserve the existing property-serialization error unchanged. return nil, err } // Build a replacer for all known placeholders. replacer := strings.NewReplacer( - imagePlaceholder, absImagePath, - imageNamePlaceholder, options.ImageName, - capabilitiesPlaceholder, strings.Join(imageConfig.Capabilities.EnabledNames(), ","), - propertiesPlaceholder, properties, + projectconfig.ImagePlaceholder, absImagePath, + projectconfig.ImageNamePlaceholder, options.ImageName, + projectconfig.CapabilitiesPlaceholder, strings.Join(imageConfig.Capabilities.EnabledNames(), ","), + projectconfig.PropertiesPlaceholder, properties, ) args := make([]string, 0, len(pytestConfig.TestPaths)+len(pytestConfig.ExtraArgs)) @@ -338,19 +313,6 @@ func BuildNativePytestArgs( return args, nil } -func serializeImageProperties(properties map[string]string) (string, error) { - if properties == nil { - properties = map[string]string{} - } - - serialized, err := json.Marshal(properties) - if err != nil { - return "", fmt.Errorf("failed to serialize image properties:\n%w", err) - } - - return string(serialized), nil -} - // expandGlob expands a glob pattern relative to workingDir using [fileutils.Glob], which // supports recursive ** patterns and operates through the project's filesystem abstraction. // If the pattern matches no files, the original pattern is returned unchanged (letting diff --git a/internal/app/azldev/cmds/image/pytestrunner_test.go b/internal/pytest/runner_test.go similarity index 86% rename from internal/app/azldev/cmds/image/pytestrunner_test.go rename to internal/pytest/runner_test.go index f997817d..e272b4d2 100644 --- a/internal/app/azldev/cmds/image/pytestrunner_test.go +++ b/internal/pytest/runner_test.go @@ -1,17 +1,17 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -package image_test +package pytest_test import ( "os" "path/filepath" "testing" - "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/cmds/image" "github.com/microsoft/azure-linux-dev-tools/internal/global/opctx" "github.com/microsoft/azure-linux-dev-tools/internal/global/testctx" "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" + "github.com/microsoft/azure-linux-dev-tools/internal/pytest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -38,11 +38,11 @@ func mustBuildNativePytestArgs( fs opctx.FS, pytestConfig *projectconfig.PytestConfig, imageConfig *projectconfig.ImageConfig, - options *image.ImageTestOptions, + options *pytest.Options, ) []string { t.Helper() - args, err := image.BuildNativePytestArgs(fs, pytestConfig, imageConfig, options) + args, err := pytest.BuildNativePytestArgs(fs, pytestConfig, imageConfig, options) require.NoError(t, err) return args @@ -53,7 +53,7 @@ func TestBuildNativePytestArgs_BasicTestPaths(t *testing.T) { TestPaths: []string{"cases/", "other/"}, ExtraArgs: []string{"--image-path", "{image-path}"}, } - options := &image.ImageTestOptions{ + options := &pytest.Options{ ImagePath: "/images/test.raw", } @@ -76,7 +76,7 @@ func TestBuildNativePytestArgs_GlobExpansion(t *testing.T) { TestPaths: []string{"cases/test_*.py"}, ExtraArgs: []string{"--image-path", "{image-path}"}, } - options := &image.ImageTestOptions{ + options := &pytest.Options{ ImagePath: "/images/test.raw", } @@ -96,7 +96,7 @@ func TestBuildNativePytestArgs_GlobNoMatch(t *testing.T) { WorkingDir: tmpDir, TestPaths: []string{"cases/test_*.py"}, } - options := &image.ImageTestOptions{ + options := &pytest.Options{ ImagePath: "/images/test.raw", } @@ -110,7 +110,7 @@ func TestBuildNativePytestArgs_ExtraArgsNeverGlobExpanded(t *testing.T) { pytestConfig := &projectconfig.PytestConfig{ ExtraArgs: []string{"--pattern", "test_*.py"}, } - options := &image.ImageTestOptions{ + options := &pytest.Options{ ImagePath: "/images/test.raw", } @@ -125,7 +125,7 @@ func TestBuildNativePytestArgs_JUnitXMLAppended(t *testing.T) { TestPaths: []string{"cases/"}, ExtraArgs: []string{"--image-path", "{image-path}"}, } - options := &image.ImageTestOptions{ + options := &pytest.Options{ ImagePath: "/images/test.raw", JUnitXMLPath: "/output/results.xml", } @@ -143,7 +143,7 @@ func TestBuildNativePytestArgs_NoJUnitXMLWhenNotRequested(t *testing.T) { pytestConfig := &projectconfig.PytestConfig{ TestPaths: []string{"cases/"}, } - options := &image.ImageTestOptions{ + options := &pytest.Options{ ImagePath: "/images/test.raw", } @@ -154,7 +154,7 @@ func TestBuildNativePytestArgs_NoJUnitXMLWhenNotRequested(t *testing.T) { func TestBuildNativePytestArgs_EmptyConfig(t *testing.T) { pytestConfig := &projectconfig.PytestConfig{} - options := &image.ImageTestOptions{ + options := &pytest.Options{ ImagePath: "/images/test.raw", } @@ -167,7 +167,7 @@ func TestBuildNativePytestArgs_PlaceholderNotInTestPaths(t *testing.T) { pytestConfig := &projectconfig.PytestConfig{ TestPaths: []string{"{image-path}"}, } - options := &image.ImageTestOptions{ + options := &pytest.Options{ ImagePath: "/images/test.raw", } @@ -180,7 +180,7 @@ func TestBuildNativePytestArgs_ImageNamePlaceholder(t *testing.T) { pytestConfig := &projectconfig.PytestConfig{ ExtraArgs: []string{"--image-name", "{image-name}"}, } - options := &image.ImageTestOptions{ + options := &pytest.Options{ ImageName: "vm-base", ImagePath: "/images/test.raw", } @@ -203,7 +203,7 @@ func TestBuildNativePytestArgs_CapabilitiesPlaceholder(t *testing.T) { pytestConfig := &projectconfig.PytestConfig{ ExtraArgs: []string{"--capabilities", "{capabilities}"}, } - options := &image.ImageTestOptions{ + options := &pytest.Options{ ImagePath: "/images/test.raw", } @@ -227,7 +227,7 @@ func TestBuildNativePytestArgs_PropertiesPlaceholder(t *testing.T) { pytestConfig := &projectconfig.PytestConfig{ ExtraArgs: []string{"--properties", "{properties}"}, } - options := &image.ImageTestOptions{ + options := &pytest.Options{ ImagePath: "/images/test.raw", } @@ -251,7 +251,7 @@ func TestBuildNativePytestArgs_CapabilitiesEmpty(t *testing.T) { pytestConfig := &projectconfig.PytestConfig{ ExtraArgs: []string{"--capabilities", "{capabilities}"}, } - options := &image.ImageTestOptions{ + options := &pytest.Options{ ImagePath: "/images/test.raw", } @@ -266,11 +266,21 @@ func TestRunPytestSuite_MissingPytestConfig(t *testing.T) { Type: projectconfig.TestTypePytest, } - options := &image.ImageTestOptions{ + options := &pytest.Options{ ImagePath: "/images/test.raw", } - err := image.RunPytestSuite(nil, suiteConfig, testImageConfig(), options) + err := pytest.RunPytestSuite(nil, suiteConfig, testImageConfig(), options) require.Error(t, err) assert.Contains(t, err.Error(), "missing pytest configuration") } + +func TestRunPytestSuiteMissingConfigBeforeOptions(t *testing.T) { + err := pytest.RunPytestSuite(nil, &projectconfig.TestSuiteConfig{Name: "smoke"}, nil, nil) + require.EqualError(t, err, "test suite `smoke` is missing pytest configuration") +} + +func TestRunTestDefinitionRejectsMissingConfigBeforeSetup(t *testing.T) { + err := pytest.RunTestDefinition(nil, projectconfig.ResolvedTest{Name: "smoke"}, nil, nil) + require.EqualError(t, err, "decode pytest config for test `smoke`:\nmissing [pytest] subtable") +} diff --git a/internal/utils/pythonenv/pythonenv.go b/internal/utils/pythonenv/pythonenv.go new file mode 100644 index 00000000..ea461073 --- /dev/null +++ b/internal/utils/pythonenv/pythonenv.go @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package pythonenv + +import ( + "fmt" + "log/slog" + "os" + "os/exec" + + "github.com/microsoft/azure-linux-dev-tools/internal/global/opctx" +) + +// Program is the Python interpreter used to create and run virtual environments. +const Program = "python3" + +// Create creates a Python virtual environment at venvDir. +func Create(ctx opctx.Ctx, venvDir string) error { + slog.Info("Creating Python venv", slog.String("path", venvDir)) + + venvCmd := exec.CommandContext(ctx, Program, "-m", "venv", venvDir) + venvCmd.Stdout = os.Stdout + venvCmd.Stderr = os.Stderr + + cmd, err := ctx.Command(venvCmd) + if err != nil { + return fmt.Errorf("failed to create venv command:\n%w", err) + } + + if err := cmd.Run(ctx); err != nil { + return fmt.Errorf("failed to create Python venv at %#q:\n%w", venvDir, err) + } + + return nil +} diff --git a/internal/utils/pythonenv/pythonenv_test.go b/internal/utils/pythonenv/pythonenv_test.go new file mode 100644 index 00000000..9694f8bb --- /dev/null +++ b/internal/utils/pythonenv/pythonenv_test.go @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package pythonenv_test + +import ( + "os" + "os/exec" + "testing" + + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/testutils" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/pythonenv" + "github.com/stretchr/testify/require" +) + +func TestCreate(t *testing.T) { + for _, testCase := range []struct { + name string + runErr error + }{ + {name: "success"}, + {name: "command failure", runErr: os.ErrPermission}, + } { + t.Run(testCase.name, func(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + testEnv.CmdFactory.RegisterCommandInSearchPath("python3") + + calls := 0 + testEnv.CmdFactory.RunHandler = func(cmd *exec.Cmd) error { + calls++ + + require.Equal(t, []string{"python3", "-m", "venv", "/work/venv"}, cmd.Args) + require.Empty(t, cmd.Dir) + + return testCase.runErr + } + + err := pythonenv.Create(testEnv.Env, "/work/venv") + + require.Equal(t, 1, calls) + + if testCase.runErr != nil { + require.EqualError(t, err, "failed to create Python venv at `/work/venv`:\npermission denied") + require.ErrorIs(t, err, testCase.runErr) + } else { + require.NoError(t, err) + } + }) + } +}