Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions internal/app/azldev/cmds/image/lisarunner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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}
Expand Down
3 changes: 2 additions & 1 deletion internal/app/azldev/cmds/image/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
56 changes: 7 additions & 49 deletions internal/app/azldev/cmds/image/test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
Expand All @@ -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
}
36 changes: 0 additions & 36 deletions internal/app/azldev/cmds/image/test_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
58 changes: 58 additions & 0 deletions internal/app/azldev/cmds/image/test_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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)
}
16 changes: 16 additions & 0 deletions internal/projectconfig/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package projectconfig

import (
"encoding/json"
"fmt"
"slices"

Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions internal/projectconfig/image_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
11 changes: 11 additions & 0 deletions internal/projectconfig/testsuite.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
68 changes: 68 additions & 0 deletions internal/pytest/config.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading