From 90b5eca081efcb88b64396f125cc297081df4549 Mon Sep 17 00:00:00 2001 From: bhagyapathak Date: Fri, 4 Sep 2026 11:51:50 +0530 Subject: [PATCH] feat: add component TMT tests --- docs/user/reference/cli/azldev_component.md | 1 + .../reference/cli/azldev_component_test.md | 97 ++ docs/user/reference/config/tests.md | 38 +- .../app/azldev/cmds/component/component.go | 1 + internal/app/azldev/cmds/component/test.go | 901 ++++++++++++++++++ .../cmds/component/test_internal_test.go | 470 +++++++++ internal/utils/prereqs/prereqs.go | 55 +- internal/utils/prereqs/prereqs_test.go | 27 + 8 files changed, 1576 insertions(+), 14 deletions(-) create mode 100644 docs/user/reference/cli/azldev_component_test.md create mode 100644 internal/app/azldev/cmds/component/test.go create mode 100644 internal/app/azldev/cmds/component/test_internal_test.go diff --git a/docs/user/reference/cli/azldev_component.md b/docs/user/reference/cli/azldev_component.md index 525c63a71..a35935e72 100644 --- a/docs/user/reference/cli/azldev_component.md +++ b/docs/user/reference/cli/azldev_component.md @@ -47,5 +47,6 @@ components defined in the project configuration. * [azldev component prepare-sources](azldev_component_prepare-sources.md) - Prepare buildable sources for components * [azldev component query](azldev_component_query.md) - Query info from locally rendered component specs * [azldev component render](azldev_component_render.md) - Render post-overlay specs and sidecar files to a checked-in directory +* [azldev component test](azldev_component_test.md) - Run a component's TMT tests in a local QEMU VM * [azldev component update](azldev_component_update.md) - Resolve and lock source identities for components diff --git a/docs/user/reference/cli/azldev_component_test.md b/docs/user/reference/cli/azldev_component_test.md new file mode 100644 index 000000000..9f45d8fdd --- /dev/null +++ b/docs/user/reference/cli/azldev_component_test.md @@ -0,0 +1,97 @@ + + +## azldev component test + +Run a component's TMT tests in a local QEMU VM + +### Synopsis + +Run TMT test definitions associated with a component against an Azure Linux +image using TMT's virtual provisioner (QEMU/testcloud). + +The image must be a local qcow2 file. Every RPM supplied with --rpm is copied +into the guest and installed by TMT before the selected plan executes. This +ensures the test validates the locally built package rather than the version +already present in the image. The guest can be modified by test preparation and +execution; a disposable testcloud overlay is used for the virtual disk. + +REQUIRED RPMs: + After building a component with 'azldev component build COMPONENT', built RPMs + are typically located in base/out/rpms/rpm-base/. You must pass at minimum: + - The main package (e.g., buildah-1.0.0-1.x86_64.rpm) + - The -tests package (e.g., buildah-tests-1.0.0-1.x86_64.rpm) + + To discover what was built: + ls base/out/rpms/rpm-base/* + + Then pass each relevant RPM with a separate --rpm flag. + +PROVISIONER MODES: + - virtual (default): Runs tests in a QEMU/testcloud VM using the Azure Linux + image. Most flexible; guest is isolated. Requires --image-path. + - local: Runs tests directly on this machine. Host must be Azure Linux 4. + Useful for quick testing; modifies host state. + +AZURE LINUX 4 PREREQUISITES: + To run with --provision local, install the host dependencies: + sudo tdnf install -y python3 python3-pip git sudo + + azldev creates a per-work-directory Python environment and installs the + pinned TMT version there. The local provisioner uses sudo to install the + supplied candidate RPMs and execute the plan, so it modifies the host. + +azldev creates or reuses an isolated Python environment under --work-dir and +installs TMT with virtual-provisioner support there. python3 and git must be +available on the host. + +``` +azldev component test COMPONENT [flags] +``` + +### Examples + +``` + # Build the component first + azldev component build buildah + + # Discover available RPMs + ls base/out/rpms/rpm-base/buildah* + + # Run tests with discovered RPMs + azldev component test buildah \ + --image-path ./base/out/images/vm-base/azl4-vm-base.x86_64.qcow2 \ + --rpm ./base/out/rpms/rpm-base/buildah-1.29.0-5.x86_64.rpm \ + --rpm ./base/out/rpms/rpm-base/buildah-tests-1.29.0-5.x86_64.rpm +``` + +### Options + +``` + -h, --help help for test + -i, --image-path string Path to the qcow2 image under test + --provision string TMT provisioner mode: 'virtual' (default) runs tests in QEMU; 'local' runs on this machine (must be Azure Linux 4) (default "virtual") + -r, --rpm strings Local RPM to install before testing (required; may be repeated). Typically include the main package and -tests package. + -t, --test strings Mapped TMT test name to run (may be repeated; defaults to all) + --work-dir string Directory for cloned metadata and TMT artifacts (default: current directory) +``` + +### Options inherited from parent commands + +``` + -y, --accept-all accept all prompts + --color mode output colorization mode {always, auto, never} (default auto) + --config-file stringArray additional TOML config file(s) to merge (may be repeated) + -n, --dry-run dry run only (do not take action) + --network-retries int maximum number of attempts for network operations (minimum 1) (default 3) + --no-default-config disable default configuration + -O, --output-format fmt output format {csv, json, markdown, table} (default table) + --permissive-config do not fail on unknown fields in TOML config files + -C, --project string path to Azure Linux project + -q, --quiet only enable minimal output + -v, --verbose enable verbose output +``` + +### SEE ALSO + +* [azldev component](azldev_component.md) - Manage components + diff --git a/docs/user/reference/config/tests.md b/docs/user/reference/config/tests.md index 754fcd769..6f47dc82d 100644 --- a/docs/user/reference/config/tests.md +++ b/docs/user/reference/config/tests.md @@ -11,12 +11,9 @@ without enumerating every member. Each entry under `[tests.]` describes one configuration of one runner. Framework-specific options live in a typed subtable -(`pytest`, `lisa`, `tmt`) whose contents are passed through -to the runner. azldev validates only the minimal set of keys each -framework requires (e.g. `pytest` requires `working-dir`/`test-paths`, -`tmt` requires `plan`/`source`, `lisa` requires at least one selector); -any other keys are passed through unvalidated so frameworks can evolve -independently. +(`pytest`, `lisa`, `tmt`). azldev validates the fields it consumes for local +execution; other framework-specific fields are passed through so frameworks +can evolve independently. | Field | TOML Key | Type | Required | Description | |-------|----------|------|----------|-------------| @@ -27,7 +24,7 @@ independently. | Metrics enabled | `metrics-enabled` | boolean | No | Hints to the test execution environment/validation service whether metrics from this test should be collected and stored | | Required capabilities | `required-capabilities` | string array | No | Capability tokens the image must declare for this test to be applicable | | Lisa | `lisa` | table | No | LISA-specific configuration (see [LISA fields](#lisa-fields)) | -| Tmt | `tmt` | table | No | TMT-specific configuration (opaque to azldev; metadata-only, no local execution) | +| Tmt | `tmt` | table | No | TMT plan configuration (see [TMT fields](#tmt-fields)) | | Pytest | `pytest` | table | No | pytest-specific configuration (opaque to azldev) | ### LISA Fields @@ -51,6 +48,33 @@ VM, same as legacy `[test-suites]` LISA suites). At least one of `criteria`, `name`, `testcase-name`, or `testcase-names` is required. +### TMT Fields + +The `[tests..tmt]` subtable identifies a pinned upstream TMT plan. It is +also used by [`azldev component test`](../cli/azldev_component_test.md) to run +the mapped plan locally in a QEMU VM. Local execution clones the source at the +configured commit, provisions the supplied image with TMT/testcloud, and +installs the RPMs passed through `--rpm` before the plan runs. + +| Field | TOML Key | Type | Description | +|-------|----------|------|-------------| +| Source | `source` | table (`git-url`, `ref`) | Git repository containing the plan. Required; `ref` must be a full 40-character hex commit SHA. | +| Plan | `plan` | string | Absolute TMT plan name to run. Required. | + +For example, define a plan as follows: + +```toml +[tests.example-tmt] +type = "tmt" + +[tests.example-tmt.tmt] +source = { git-url = "https://example.test/tests.git", ref = "0123456789012345678901234567890123456789" } +plan = "/plans/example" +``` + +After associating `example-tmt` with a component, run it locally with +`azldev component test --image-path --rpm `. + ## Test Group Each entry under `[test-groups.]` names an ordered list of test diff --git a/internal/app/azldev/cmds/component/component.go b/internal/app/azldev/cmds/component/component.go index 9cfee2ea3..571e21f7b 100644 --- a/internal/app/azldev/cmds/component/component.go +++ b/internal/app/azldev/cmds/component/component.go @@ -32,5 +32,6 @@ components defined in the project configuration.`, prepareOnAppInit(app, cmd) queryOnAppInit(app, cmd) renderOnAppInit(app, cmd) + testOnAppInit(app, cmd) updateOnAppInit(app, cmd) } diff --git a/internal/app/azldev/cmds/component/test.go b/internal/app/azldev/cmds/component/test.go new file mode 100644 index 000000000..28327345a --- /dev/null +++ b/internal/app/azldev/cmds/component/test.go @@ -0,0 +1,901 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package component + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "path/filepath" + "slices" + "sort" + "strings" + + "github.com/bmatcuk/doublestar/v4" + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev" + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/components" + "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/microsoft/azure-linux-dev-tools/internal/utils/prereqs" + "github.com/spf13/cobra" +) + +// ComponentTestOptions holds options for running component TMT tests locally. +type ComponentTestOptions struct { + ImagePath string + RPMPaths []string + Tests []string + WorkDir string + Provision string +} + +type tmtSource struct { + GitURL string `toml:"git-url"` + Ref string `toml:"ref"` +} + +type tmtConfig struct { + Source tmtSource `toml:"source"` + Plan string `toml:"plan"` +} + +type tmtPlanExport struct { + Name string `json:"name"` + Provision []map[string]any `json:"provision"` +} + +// tmtRunSettings holds the fully resolved inputs shared by every test in a run. +type tmtRunSettings struct { + ImagePath string + RPMs []string + WorkDir string + TMTProgramPath string + Provision string +} + +const ( + tmtPythonProgram = "python3" + tmtVenvDirName = "venv" + tmtProgram = "tmt" + tmtProvisionLocal = "local" + tmtProvisionVirtual = "virtual" + // Keep the TMT CLI behavior stable for a given azldev revision. Update this + // pin deliberately after validating component test execution with the newer + // version. + tmtVersion = "1.78.0" + tmtPipRequirement = "tmt[provision-virtual]==" + tmtVersion + tmtCandidateRPMPrepareStepName = "azldev-candidate-rpms" +) + +const testcloudPluginTemplate = `import tmt.steps.provision.testcloud as testcloud +from tmt.guest import GuestSsh +from tmt.utils import Command + +testcloud.TESTCLOUD_WORKAROUNDS.extend(%s) + +# TMT pulls each finish task's data over the unprivileged SSH connection +# immediately after that task finishes. An appended finish task is therefore +# too late for an upstream task which creates a root-owned artifact, such as +# a copied audit.log. Repair the disposable guest artifact directory directly +# before each pull so the source task's output is included in the transfer. +_azldev_original_pull = GuestSsh.pull + +def _azldev_pull(self, source=None, destination=None, options=None): + path = source or self.plan_workdir + if self.user != "root": + self.execute(Command("sudo", "chmod", "-R", "a+rX", path), silent=True) + return _azldev_original_pull(self, source=source, destination=destination, options=options) + +GuestSsh.pull = _azldev_pull +` + +func testOnAppInit(_ *azldev.App, parentCmd *cobra.Command) { + parentCmd.AddCommand(NewComponentTestCmd()) +} + +// NewComponentTestCmd constructs the 'component test' command. +func NewComponentTestCmd() *cobra.Command { + options := &ComponentTestOptions{} + + cmd := &cobra.Command{ + Use: "test COMPONENT", + Short: "Run a component's TMT tests in a local QEMU VM", + Long: `Run TMT test definitions associated with a component against an Azure Linux +image using TMT's virtual provisioner (QEMU/testcloud). + +The image must be a local qcow2 file. Every RPM supplied with --rpm is copied +into the guest and installed by TMT before the selected plan executes. This +ensures the test validates the locally built package rather than the version +already present in the image. The guest can be modified by test preparation and +execution; a disposable testcloud overlay is used for the virtual disk. + +REQUIRED RPMs: + After building a component with 'azldev component build COMPONENT', built RPMs + are typically located in base/out/rpms/rpm-base/. You must pass at minimum: + - The main package (e.g., buildah-1.0.0-1.x86_64.rpm) + - The -tests package (e.g., buildah-tests-1.0.0-1.x86_64.rpm) + + To discover what was built: + ls base/out/rpms/rpm-base/* + + Then pass each relevant RPM with a separate --rpm flag. + +PROVISIONER MODES: + - virtual (default): Runs tests in a QEMU/testcloud VM using the Azure Linux + image. Most flexible; guest is isolated. Requires --image-path. + - local: Runs tests directly on this machine. Host must be Azure Linux 4. + Useful for quick testing; modifies host state. + +AZURE LINUX 4 PREREQUISITES: + To run with --provision local, install the host dependencies: + sudo tdnf install -y python3 python3-pip git sudo + + azldev creates a per-work-directory Python environment and installs the + pinned TMT version there. The local provisioner uses sudo to install the + supplied candidate RPMs and execute the plan, so it modifies the host. + +azldev creates or reuses an isolated Python environment under --work-dir and +installs TMT with virtual-provisioner support there. python3 and git must be +available on the host.`, + Example: ` # Build the component first + azldev component build buildah + + # Discover available RPMs + ls base/out/rpms/rpm-base/buildah* + + # Run tests with discovered RPMs + azldev component test buildah \ + --image-path ./base/out/images/vm-base/azl4-vm-base.x86_64.qcow2 \ + --rpm ./base/out/rpms/rpm-base/buildah-1.29.0-5.x86_64.rpm \ + --rpm ./base/out/rpms/rpm-base/buildah-tests-1.29.0-5.x86_64.rpm`, + Args: cobra.ExactArgs(1), + RunE: azldev.RunFuncWithExtraArgs(func(env *azldev.Env, args []string) (interface{}, error) { + return nil, runComponentTMTTests(env, args[0], options) + }), + } + + cmd.Flags().StringVarP(&options.ImagePath, "image-path", "i", "", "Path to the qcow2 image under test") + _ = cmd.MarkFlagFilename("image-path") + cmd.Flags().StringSliceVarP( + &options.RPMPaths, "rpm", "r", nil, "Local RPM to install before testing (required; may be repeated). "+ + "Typically include the main package and -tests package.", + ) + _ = cmd.MarkFlagFilename("rpm") + cmd.Flags().StringSliceVarP( + &options.Tests, "test", "t", nil, "Mapped TMT test name to run (may be repeated; defaults to all)", + ) + cmd.Flags().StringVar( + &options.WorkDir, "work-dir", "", "Directory for cloned metadata and TMT artifacts (default: current directory)", + ) + _ = cmd.MarkFlagDirname("work-dir") + cmd.Flags().StringVar(&options.Provision, "provision", tmtProvisionVirtual, + "TMT provisioner mode: 'virtual' (default) runs tests in QEMU; 'local' runs on this machine (must be Azure Linux 4)") + _ = cmd.MarkFlagRequired("rpm") + + return cmd +} + +func runComponentTMTTests(env *azldev.Env, componentName string, options *ComponentTestOptions) error { + if err := validateComponentTestOptions(env, options); err != nil { + return err + } + + imagePath, err := resolveTMTImagePath(env, options.Provision, options.ImagePath) + if err != nil { + return err + } + + rpms, err := absoluteRegularFiles(env, options.RPMPaths, "rpm") + if err != nil { + return err + } + + resolved, err := resolveComponentTMTTests(env, componentName, options.Tests) + if err != nil { + return err + } + + workDir, tmtProgramPath, err := prepareTMTEnvironment(env, options.WorkDir, options.Provision) + if err != nil { + return err + } + + settings := tmtRunSettings{ + ImagePath: imagePath, + RPMs: rpms, + WorkDir: workDir, + TMTProgramPath: tmtProgramPath, + Provision: options.Provision, + } + + for _, test := range resolved { + if err := runOneTMTTest(env, test, settings); err != nil { + return fmt.Errorf("TMT test %#q:\n%w", test.Name, err) + } + } + + return nil +} + +func validateComponentTestOptions(env *azldev.Env, options *ComponentTestOptions) error { + if err := validateTMTProvisionOptions(options.Provision, options.ImagePath); err != nil { + return err + } + + // The local provisioner runs the plan's prepare and execute steps directly on + // this machine, so restrict it to the distribution the candidate RPMs target. + if options.Provision == tmtProvisionLocal { + if err := prereqs.RequireAzureLinux4(env); err != nil { + return fmt.Errorf("validate local TMT provisioner host:\n%w", err) + } + + // The --become flag in TMT args requires sudo for privilege escalation. + if err := prereqs.RequireExecutable(env, "sudo", nil); err != nil { + return fmt.Errorf("sudo is required for '--provision local':\n%w", err) + } + } + + return nil +} + +func resolveTMTImagePath(env *azldev.Env, provision string, configuredImagePath string) (string, error) { + if provision != tmtProvisionVirtual { + return "", nil + } + + if configuredImagePath == "" { + return "", errors.New("'--image-path' is required with '--provision virtual'") + } + + imagePath, err := absoluteProjectPath(env.ProjectDir(), configuredImagePath) + if err != nil { + return "", fmt.Errorf("resolve image path:\n%w", err) + } + + info, err := env.FS().Stat(imagePath) + if err != nil { + return "", fmt.Errorf("image path %#q:\n%w", imagePath, err) + } + + // The virtual provisioner copies the image into a testcloud overlay, so a + // directory or device node would only fail once TMT is already running. + if !info.Mode().IsRegular() { + return "", fmt.Errorf("image path %#q must be a regular file, found %s", imagePath, info.Mode().Type()) + } + + return imagePath, nil +} + +func resolveComponentTMTTests( + env *azldev.Env, componentName string, selectors []string, +) ([]projectconfig.ResolvedTest, error) { + resolver := components.NewResolver(env) + + set, err := resolver.FindComponents(&components.ComponentFilter{ComponentNamePatterns: []string{componentName}}) + if err != nil { + return nil, fmt.Errorf("resolve component %#q:\n%w", componentName, err) + } + + if set.Len() != 1 { + return nil, fmt.Errorf("expected exactly one component named %#q, found %d", componentName, set.Len()) + } + + resolved, err := env.Config().ResolveComponentTests(set.Components()[0].GetConfig()) + if err != nil { + return nil, fmt.Errorf("resolve tests for component %#q:\n%w", componentName, err) + } + + resolved = selectTMTTests(resolved, selectors) + if len(resolved) == 0 { + return nil, fmt.Errorf("component %#q has no selected TMT tests", componentName) + } + + return resolved, nil +} + +func prepareTMTEnvironment(env *azldev.Env, configuredWorkDir string, provision string) (string, string, error) { + workDir, err := componentTMTWorkDir(env, configuredWorkDir) + if err != nil { + return "", "", fmt.Errorf("resolve work directory:\n%w", err) + } + + tmtProgramPath := filepath.Join(workDir, "tmt", tmtVenvDirName, "bin", tmtProgram) + if env.DryRun() { + return workDir, tmtProgramPath, nil + } + + if err := env.FS().MkdirAll(workDir, fileperms.PublicDir); err != nil { + return "", "", fmt.Errorf("create work directory:\n%w", err) + } + + tmtProgramPath, err = ensureTMTVenv(env, workDir, provision) + if err != nil { + return "", "", err + } + + return workDir, tmtProgramPath, nil +} + +// ensureTMTVenv creates or reuses an isolated TMT installation. This follows +// the local LISA runner pattern: Python and git are explicit host +// prerequisites, while the test framework itself is installed in a venv under +// the selected work directory rather than assumed to be packaged by the host +// distribution. For virtual provisioning, the testcloud plugin supplies +// provisioner support. For local provisioning, only base TMT is required. +func ensureTMTVenv(env *azldev.Env, workDir string, provision string) (string, error) { + if err := prereqs.RequireExecutable(env, tmtPythonProgram, nil); err != nil { + return "", fmt.Errorf("python3 is required to run TMT tests:\n%w", err) + } + + if err := prereqs.RequireExecutable(env, "git", nil); err != nil { + return "", fmt.Errorf("git is required to clone TMT test metadata:\n%w", err) + } + + venvDir := filepath.Join(workDir, "tmt", tmtVenvDirName) + venvPython := filepath.Join(venvDir, "bin", tmtPythonProgram) + tmtProgramPath := filepath.Join(venvDir, "bin", tmtProgram) + + venvExists, err := fileutils.Exists(env.FS(), venvPython) + if err != nil { + return "", fmt.Errorf("check TMT venv at %#q:\n%w", venvDir, err) + } + + if !venvExists { + slog.Info("Creating TMT Python venv", slog.String("path", venvDir)) + + if err := runHostCommand(env, "", tmtPythonProgram, "-m", "venv", venvDir); err != nil { + return "", fmt.Errorf("create TMT Python venv at %#q:\n%w", venvDir, err) + } + } else { + slog.Info("Reusing TMT Python venv", slog.String("path", venvDir)) + } + + requirement := "tmt==" + tmtVersion + if provision == tmtProvisionVirtual { + requirement = tmtPipRequirement + } + + slog.Info("Installing TMT", slog.String("venv", venvDir), slog.String("requirement", requirement)) + + if err := runHostCommand( + env, "", venvPython, "-m", "pip", "install", "--quiet", requirement, + ); err != nil { + return "", fmt.Errorf("install TMT:\n%w", err) + } + + return tmtProgramPath, nil +} + +func componentTMTWorkDir(env *azldev.Env, configuredWorkDir string) (string, error) { + if configuredWorkDir == "" { + workDir, err := env.OSEnv().Getwd() + if err != nil { + return "", fmt.Errorf("get current working directory:\n%w", err) + } + + return workDir, nil + } + + return absoluteProjectPath(env.ProjectDir(), configuredWorkDir) +} + +func absoluteProjectPath(projectDir string, path string) (string, error) { + if !filepath.IsAbs(path) { + path = filepath.Join(projectDir, path) + } + + absolutePath, err := filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("make path %#q absolute:\n%w", path, err) + } + + return absolutePath, nil +} + +func absoluteRegularFiles(env *azldev.Env, paths []string, kind string) ([]string, error) { + result := make([]string, 0, len(paths)) + for _, path := range paths { + pattern, err := absoluteProjectPath(env.ProjectDir(), path) + if err != nil { + return nil, fmt.Errorf("resolve %s path %#q:\n%w", kind, path, err) + } + + matches, err := fileutils.Glob(env.FS(), pattern, + doublestar.WithFilesOnly(), + doublestar.WithFailOnIOErrors(), + ) + if err != nil { + return nil, fmt.Errorf("expand %s path pattern %#q:\n%w", kind, path, err) + } + + if len(matches) == 0 { + return nil, fmt.Errorf("%s path pattern %#q matched no files", kind, path) + } + + sort.Strings(matches) + result = append(result, matches...) + } + + return result, nil +} + +func removePreviousTMTRepository(env *azldev.Env, repoDir string) error { + if err := env.FS().RemoveAll(repoDir); err != nil { + return fmt.Errorf("remove test metadata repository %#q:\n%w", repoDir, err) + } + + return nil +} + +func selectTMTTests(tests []projectconfig.ResolvedTest, selectors []string) []projectconfig.ResolvedTest { + result := make([]projectconfig.ResolvedTest, 0, len(tests)) + for _, test := range tests { + if test.Definition.Type != "tmt" { + continue + } + + if len(selectors) == 0 || slices.Contains(selectors, test.Name) { + result = append(result, test) + } + } + + return result +} + +func runOneTMTTest(env *azldev.Env, test projectconfig.ResolvedTest, settings tmtRunSettings) error { + config, err := decodeTMTConfig(test.Definition.Tmt) + if err != nil { + return err + } + + if err := validateTMTTestName(test.Name); err != nil { + return err + } + + testDir := filepath.Join(settings.WorkDir, test.Name) + repoDir := filepath.Join(testDir, "repo") + tmtWorkDir := filepath.Join(testDir, "tmt") + + pluginDir, err := prepareTMTTestDir(env, testDir, repoDir, settings) + if err != nil { + return err + } + + if err := runHostCommand(env, testDir, "git", "clone", "--no-checkout", config.Source.GitURL, repoDir); err != nil { + return fmt.Errorf("clone test metadata:\n%w", err) + } + + if err := runHostCommand(env, repoDir, "git", "checkout", "--detach", config.Source.Ref); err != nil { + return fmt.Errorf("checkout test metadata:\n%w", err) + } + + var hardwareArgs []string + if settings.Provision == tmtProvisionVirtual { + hardwareArgs, err = resolvedPlanHardwareArgs( + env, repoDir, settings.TMTProgramPath, config.Plan, + ) + if err != nil { + return fmt.Errorf("resolve hardware for TMT plan %#q:\n%w", config.Plan, err) + } + } + + args := componentTMTArgs(config, tmtWorkDir, settings.Provision, settings.ImagePath, hardwareArgs, settings.RPMs) + + if err := runTMTCommand(env, repoDir, pluginDir, settings.TMTProgramPath, settings.Provision, args...); err != nil { + return fmt.Errorf("run TMT plan %#q (artifacts: %#q):\n%w", config.Plan, tmtWorkDir, err) + } + + return nil +} + +// prepareTMTTestDir creates the per-test directory and, for virtual runs, the +// testcloud plugin. It returns the plugin directory, which is empty when no +// plugin is needed. +func prepareTMTTestDir(env *azldev.Env, testDir string, repoDir string, settings tmtRunSettings) (string, error) { + if env.DryRun() { + return "", nil + } + + if err := env.FS().MkdirAll(testDir, fileperms.PublicDir); err != nil { + return "", fmt.Errorf("create test directory:\n%w", err) + } + + // A caller may intentionally reuse --work-dir after a failed or completed + // run. Metadata is always cloned at the pinned ref, so remove only the + // previous checkout while retaining TMT artifacts for diagnosis. + if err := removePreviousTMTRepository(env, repoDir); err != nil { + return "", fmt.Errorf("remove previous test metadata checkout:\n%w", err) + } + + if settings.Provision != tmtProvisionVirtual { + return "", nil + } + + pluginDir, err := writeTestcloudPlugin(env, testDir) + if err != nil { + return "", fmt.Errorf("write testcloud cloud-init plugin:\n%w", err) + } + + return pluginDir, nil +} + +func validateTMTTestName(name string) error { + if name == "" || name == "." || name == ".." || + filepath.IsAbs(name) || filepath.Clean(name) != name || strings.ContainsAny(name, `/\`) { + return fmt.Errorf("invalid test name %#q: must be a simple name (no path separators)", name) + } + + return nil +} + +// componentTMTArgs builds a run which changes only the provisioner and adds the +// candidate RPM installation. In particular, it deliberately leaves discover, +// execute, and report to the selected plan: upstream Fedora plans commonly use +// non-default plugins or scripts for those steps. +func componentTMTArgs( + config tmtConfig, + tmtWorkDir string, + provision string, + imagePath string, + hardwareArgs []string, + rpms []string, +) []string { + args := make([]string, 0, 32+len(hardwareArgs)+len(rpms)) + + // Both provisioners need --become: candidate RPM installation and most plans' + // execute steps require root, and neither the testcloud guest user nor the + // invoking local user is root. TMT escalates with passwordless sudo. + args = append(args, + "run", "--all", "--keep", "--workdir-root", tmtWorkDir, + "plan", "--name", config.Plan, + "provision", "--how", provision, "--become", + ) + + // Only the virtual provisioner boots an image, so the disk and hardware + // constraints are meaningless for a local run. + if provision == tmtProvisionVirtual { + args = append(args, "--image", imagePath) + + // Emit hardware constraints from the plan, plus boot.method for UEFI. + for _, hardwareArg := range hardwareArgs { + args = append(args, "--hardware", hardwareArg) + } + } + + // Insert a distinct step rather than updating the plan's preparation. An + // update can inherit a plan entry's `when` conditions and skip candidate + // RPM installation entirely. + args = append(args, "prepare", "--insert", "--how", "install", "--name", tmtCandidateRPMPrepareStepName) + for _, rpm := range rpms { + args = append(args, "--package", rpm) + } + + return args +} + +// resolvedPlanHardwareArgs exports the plan's declared hardware constraints and +// appends boot.method=uefi. TMT treats command-line --hardware options as a +// replacement for a plan's hardware block, not an addition, so re-emitting the +// resolved constraints retains requirements such as memory, CPU topology, and +// disk size while adding UEFI firmware. +func resolvedPlanHardwareArgs( + env *azldev.Env, repoDir string, tmtProgramPath string, plan string, +) ([]string, error) { + // The plan export command produces no output in dry-run mode. In that case, + // emit only boot.method so the printed invocation represents what would run. + if env.DryRun() { + return []string{"boot.method = uefi"}, nil + } + + hardware, err := exportedPlanHardware(env, repoDir, tmtProgramPath, plan) + if err != nil { + return nil, err + } + + return mergePlanHardwareArgs(hardware) +} + +func exportedPlanHardware(env *azldev.Env, repoDir string, tmtProgramPath string, plan string) (map[string]any, error) { + output, err := runHostCommandOutput(env, repoDir, tmtProgramPath, "plan", "export", plan, "--how", "json") + if err != nil { + return nil, err + } + + var plans []tmtPlanExport + if err := json.Unmarshal(output, &plans); err != nil { + return nil, fmt.Errorf("decode tmt plan export output:\n%w", err) + } + + var selected *tmtPlanExport + + for index := range plans { + if plans[index].Name != plan { + continue + } + + if selected != nil { + return nil, fmt.Errorf("multiple exported plans are named %#q", plan) + } + + selected = &plans[index] + } + + if selected == nil { + return nil, fmt.Errorf("plan %#q was not returned by tmt plan export", plan) + } + + if len(selected.Provision) > 0 { + if value, ok := selected.Provision[0]["hardware"].(map[string]any); ok { + return value, nil + } + } + + return map[string]any{}, nil +} + +func mergePlanHardwareArgs(hardware map[string]any) ([]string, error) { + constraints, err := flattenHardwareConstraints("", hardware) + if err != nil { + return nil, err + } + + args := make([]string, 0, len(constraints)+1) + for _, constraint := range constraints { + // Skip any boot.method from the plan; we hardcode UEFI below. + if strings.HasPrefix(constraint, "boot.method ") { + continue + } + + args = append(args, constraint) + } + + args = append(args, "boot.method = uefi") + + return args, nil +} + +// flattenHardwareConstraints converts the JSON-shaped TMT hardware block into +// the constraint syntax accepted by --hardware. Lists use TMT's indexed syntax, +// e.g. disk[0].size >= 512 GB. Boolean expressions cannot be represented by +// the CLI's flat syntax without changing their meaning, so reject them rather +// than silently weakening a test plan. +// +//nolint:cyclop // Each supported JSON type has distinct constraint serialization. +func flattenHardwareConstraints(prefix string, value any) ([]string, error) { + switch typed := value.(type) { + case map[string]any: + keys := make([]string, 0, len(typed)) + for key := range typed { + if key == "and" || key == "or" { + return nil, fmt.Errorf("hardware constraint group %#q cannot be safely overridden", key) + } + + keys = append(keys, key) + } + + sort.Strings(keys) + + var constraints []string + + for _, key := range keys { + nextPrefix := key + if prefix != "" { + nextPrefix = prefix + "." + key + } + + child, err := flattenHardwareConstraints(nextPrefix, typed[key]) + if err != nil { + return nil, err + } + + constraints = append(constraints, child...) + } + + return constraints, nil + case []any: + var constraints []string + + for index, item := range typed { + child, err := flattenHardwareConstraints(fmt.Sprintf("%s[%d]", prefix, index), item) + if err != nil { + return nil, err + } + + constraints = append(constraints, child...) + } + + return constraints, nil + case string: + if prefix == "" { + return nil, errors.New("hardware constraint has no key") + } + + return []string{prefix + " " + typed}, nil + case float64, bool: + if prefix == "" { + return nil, errors.New("hardware constraint has no key") + } + + return []string{fmt.Sprintf("%s = %v", prefix, typed)}, nil + default: + return nil, fmt.Errorf("unsupported hardware constraint %#q of type %T", prefix, value) + } +} + +func decodeTMTConfig(raw map[string]any) (tmtConfig, error) { + source, ok := raw["source"].(map[string]any) + if !ok { + return tmtConfig{}, errors.New("missing 'tmt.source'") + } + + gitURL, _ := source["git-url"].(string) + ref, _ := source["ref"].(string) + + sourceConfig := projectconfig.GitSourceConfig{GitURL: gitURL, Ref: ref} + if err := sourceConfig.Validate("'tmt.source'"); err != nil { + return tmtConfig{}, fmt.Errorf("validate 'tmt.source':\n%w", err) + } + + plan, _ := raw["plan"].(string) + if !strings.HasPrefix(plan, "/") { + return tmtConfig{}, errors.New("'tmt.plan' must be an absolute plan name") + } + + return tmtConfig{Source: tmtSource{GitURL: gitURL, Ref: ref}, Plan: plan}, nil +} + +func validateTMTProvision(provision string) error { + if provision != tmtProvisionLocal && provision != tmtProvisionVirtual { + return fmt.Errorf("'--provision' must be either %q or %q", tmtProvisionLocal, tmtProvisionVirtual) + } + + return nil +} + +func validateTMTProvisionOptions(provision string, imagePath string) error { + if err := validateTMTProvision(provision); err != nil { + return err + } + + if provision == tmtProvisionLocal && imagePath != "" { + return errors.New("'--image-path' cannot be used with '--provision local'") + } + + return nil +} + +func runHostCommand(env *azldev.Env, dir string, program string, args ...string) error { + command := exec.CommandContext(env, program, args...) + command.Dir = dir + command.Stdout = os.Stdout + + var stderr bytes.Buffer + + command.Stderr = io.MultiWriter(os.Stderr, &stderr) + + wrapped, err := env.Command(command) + if err != nil { + return fmt.Errorf("wrap host command %#q:\n%w", program, err) + } + + if err := wrapped.Run(env); err != nil { + if trimmedStderr := strings.TrimSpace(stderr.String()); trimmedStderr != "" { + return fmt.Errorf("run host command %#q:\n%s\n%w", program, trimmedStderr, err) + } + + return fmt.Errorf("run host command %#q:\n%w", program, err) + } + + return nil +} + +func runHostCommandOutput(env *azldev.Env, dir string, program string, args ...string) ([]byte, error) { + command := exec.CommandContext(env, program, args...) + command.Dir = dir + + var stderr bytes.Buffer + + command.Stderr = &stderr + + wrapped, err := env.Command(command) + if err != nil { + return nil, fmt.Errorf("wrap host command %#q:\n%w", program, err) + } + + output, err := wrapped.RunAndGetOutput(env) + if err != nil { + if trimmedStderr := strings.TrimSpace(stderr.String()); trimmedStderr != "" { + return nil, fmt.Errorf("run host command %#q:\n%s\n%w", program, trimmedStderr, err) + } + + return nil, fmt.Errorf("run host command %#q:\n%w", program, err) + } + + return []byte(output), nil +} + +// runTMTCommand runs the TMT CLI with the environment produced by +// [tmtCommandEnv] layered on top of the inherited process environment. +func runTMTCommand( + env *azldev.Env, dir string, pluginDir string, tmtProgramPath string, provision string, args ...string, +) error { + command := exec.CommandContext(env, tmtProgramPath, args...) + command.Dir = dir + command.Stdout = os.Stdout + command.Stderr = os.Stderr + + command.Env = append(os.Environ(), tmtCommandEnv(env, pluginDir, provision)...) + + wrapped, err := env.Command(command) + if err != nil { + return fmt.Errorf("wrap tmt command:\n%w", err) + } + + if err := wrapped.Run(env); err != nil { + return fmt.Errorf("run tmt command:\n%w", err) + } + + return nil +} + +// tmtCommandEnv returns the environment variables azldev adds on top of the +// inherited process environment when invoking TMT. +func tmtCommandEnv(env *azldev.Env, pluginDir string, provision string) []string { + var vars []string + + if pluginDir != "" { + vars = append(vars, "TMT_PLUGINS="+pluginDir) + } + + // Give cloud-init a longer, but bounded, time to make the guest reachable. + // Respect an explicit user setting for unusually fast or slow hosts. + if env.OSEnv().Getenv("TMT_BOOT_TIMEOUT") == "" { + vars = append(vars, "TMT_BOOT_TIMEOUT=300") + } + + // The local provisioner executes the plan on this machine, which TMT + // refuses to do without an explicit acknowledgement. + if provision == tmtProvisionLocal { + vars = append(vars, "TMT_FEELING_SAFE=1") + } + + return vars +} + +// writeTestcloudPlugin installs a tiny TMT plugin which adds a cloud-init +// workaround before testcloud's readiness check. Azure Linux VM images can run +// firewalld with no default allowance for testcloud's SSH endpoint. +func writeTestcloudPlugin(env *azldev.Env, testDir string) (string, error) { + workarounds := []string{ + // Fedora Podman plans use this standard test account for linger and + // rootless-container setup. Azure Linux cloud images do not ship it. + "id -u fedora >/dev/null 2>&1 || useradd --create-home --groups wheel fedora", + "firewall-cmd --add-service=ssh || :", + } + + encodedWorkarounds, err := json.Marshal(workarounds) + if err != nil { + return "", fmt.Errorf("encode testcloud workarounds:\n%w", err) + } + + pluginDir := filepath.Join(testDir, "tmt-plugins") + if err := env.FS().MkdirAll(pluginDir, fileperms.PublicDir); err != nil { + return "", fmt.Errorf("create testcloud plugin directory:\n%w", err) + } + + plugin := fmt.Sprintf(testcloudPluginTemplate, encodedWorkarounds) + + pluginPath := filepath.Join(pluginDir, "azldev_testcloud.py") + if err := fileutils.WriteFile(env.FS(), pluginPath, []byte(plugin), fileperms.PrivateFile); err != nil { + return "", fmt.Errorf("write testcloud plugin:\n%w", err) + } + + return pluginDir, nil +} diff --git a/internal/app/azldev/cmds/component/test_internal_test.go b/internal/app/azldev/cmds/component/test_internal_test.go new file mode 100644 index 000000000..03f95c6f6 --- /dev/null +++ b/internal/app/azldev/cmds/component/test_internal_test.go @@ -0,0 +1,470 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package component + +import ( + "errors" + "io/fs" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev" + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/testutils" + "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/utils/fileperms" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDecodeTMTConfig(t *testing.T) { + config, err := decodeTMTConfig(map[string]any{ + "source": map[string]any{ + "git-url": "https://example.test/tests.git", + "ref": "0123456789012345678901234567890123456789", + }, + "plan": "/plans/all", + }) + + require.NoError(t, err) + assert.Equal(t, "https://example.test/tests.git", config.Source.GitURL) + assert.Equal(t, "/plans/all", config.Plan) +} + +func TestDecodeTMTConfigRejectsIncompleteConfig(t *testing.T) { + _, err := decodeTMTConfig(map[string]any{"plan": "/plans/all"}) + + require.ErrorContains(t, err, "missing 'tmt.source'") +} + +func TestDecodeTMTConfigRejectsInvalidRefAndPlan(t *testing.T) { + for name, test := range map[string]struct { + config map[string]any + expectedError string + }{ + "non-commit ref": { + config: map[string]any{ + "source": map[string]any{ + "git-url": "https://example.test/tests.git", + "ref": "main", + }, + "plan": "/plans/all", + }, + expectedError: "'tmt.source'", + }, + "relative plan": { + config: map[string]any{ + "source": map[string]any{ + "git-url": "https://example.test/tests.git", + "ref": "0123456789012345678901234567890123456789", + }, + "plan": "plans/all", + }, + expectedError: "'tmt.plan'", + }, + } { + t.Run(name, func(t *testing.T) { + _, err := decodeTMTConfig(test.config) + require.ErrorContains(t, err, test.expectedError) + }) + } +} + +func TestValidateTMTProvisionOptions(t *testing.T) { + require.NoError(t, validateTMTProvisionOptions(tmtProvisionVirtual, "/images/azure.qcow2")) + require.NoError(t, validateTMTProvisionOptions(tmtProvisionLocal, "")) + require.ErrorContains(t, validateTMTProvisionOptions(tmtProvisionLocal, "/images/azure.qcow2"), "cannot be used") + require.ErrorContains(t, validateTMTProvisionOptions("container", ""), "must be either") +} + +func TestTMTPipRequirementPinsTMTVersion(t *testing.T) { + assert.Equal(t, "tmt[provision-virtual]==1.78.0", tmtPipRequirement) +} + +func TestSelectTMTTests(t *testing.T) { + tests := []projectconfig.ResolvedTest{ + {Name: "tmt-one", Definition: projectconfig.TestDefinition{Type: "tmt"}}, + {Name: "lisa-one", Definition: projectconfig.TestDefinition{Type: "lisa"}}, + {Name: "tmt-two", Definition: projectconfig.TestDefinition{Type: "tmt"}}, + } + + assert.Equal(t, []projectconfig.ResolvedTest{tests[0], tests[2]}, selectTMTTests(tests, nil)) + assert.Equal(t, []projectconfig.ResolvedTest{tests[2]}, selectTMTTests(tests, []string{"tmt-two"})) +} + +func TestValidateTMTTestName(t *testing.T) { + for _, name := range []string{"tmt-buildah", "nodejs22-tier1", ".metadata"} { + t.Run(name, func(t *testing.T) { + require.NoError(t, validateTMTTestName(name)) + }) + } + + for _, name := range []string{"", ".", "..", "../outside", "nested/test", `nested\test`, "/outside"} { + t.Run(name, func(t *testing.T) { + require.ErrorContains(t, validateTMTTestName(name), "must be a simple name") + }) + } +} + +func TestFlattenHardwareConstraints(t *testing.T) { + constraints, err := flattenHardwareConstraints("", map[string]any{ + "memory": ">= 16 GB", + "cpu": map[string]any{ + "cores": ">= 4", + "threads": ">=8", + }, + "disk": []any{ + map[string]any{"size": ">= 512 GB"}, + }, + }) + + require.NoError(t, err) + assert.Equal(t, []string{ + "cpu.cores >= 4", + "cpu.threads >=8", + "disk[0].size >= 512 GB", + "memory >= 16 GB", + }, constraints) +} + +func TestFlattenHardwareConstraintsRejectsBooleanGroups(t *testing.T) { + _, err := flattenHardwareConstraints("", map[string]any{ + "and": []any{map[string]any{"memory": ">= 16 GB"}}, + }) + + require.ErrorContains(t, err, "cannot be safely overridden") +} + +func TestResolvedPlanHardwareArgsDryRunSkipsPlanExport(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + dryRunOptions := azldev.NewEnvOptions() + dryRunOptions.ProjectDir = "/project" + dryRunOptions.Config = testEnv.Config + dryRunOptions.DryRunnable = azldev.NewAppDryRunnable(true) + dryRunOptions.EventListener = testEnv.EventListener + dryRunOptions.Interfaces = testEnv.TestInterfaces + dryRunEnv := azldev.NewEnv(t.Context(), dryRunOptions) + + called := false + testEnv.CmdFactory.RunAndGetOutputHandler = func(*exec.Cmd) (string, error) { + called = true + + return "", errors.New("plan export must not run during dry-run") + } + + args, err := resolvedPlanHardwareArgs( + dryRunEnv, "/work/repo", "/work/tmt", "/plans/smoke", + ) + + require.NoError(t, err) + assert.False(t, called) + assert.Equal(t, []string{"boot.method = uefi"}, args) +} + +func TestAbsoluteProjectPath(t *testing.T) { + path, err := absoluteProjectPath("/projects/azurelinux", "base/out/image.qcow2") + + require.NoError(t, err) + assert.Equal(t, "/projects/azurelinux/base/out/image.qcow2", path) +} + +func TestResolveTMTImagePath(t *testing.T) { + t.Run("accepts a regular file", func(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + require.NoError(t, fileutils.WriteFile( + testEnv.TestFS, "/project/out/image.qcow2", []byte("qcow2"), fileperms.PrivateFile, + )) + + imagePath, err := resolveTMTImagePath(testEnv.Env, tmtProvisionVirtual, "out/image.qcow2") + + require.NoError(t, err) + assert.Equal(t, "/project/out/image.qcow2", imagePath) + }) + + t.Run("rejects a directory", func(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + require.NoError(t, testEnv.TestFS.MkdirAll("/project/out/image.qcow2", fileperms.PublicDir)) + + _, err := resolveTMTImagePath(testEnv.Env, tmtProvisionVirtual, "out/image.qcow2") + + require.ErrorContains(t, err, "must be a regular file") + }) + + t.Run("rejects a missing path", func(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + + _, err := resolveTMTImagePath(testEnv.Env, tmtProvisionVirtual, "out/image.qcow2") + + require.ErrorIs(t, err, fs.ErrNotExist) + }) + + t.Run("local provisioner ignores the image path", func(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + + imagePath, err := resolveTMTImagePath(testEnv.Env, tmtProvisionLocal, "") + + require.NoError(t, err) + assert.Empty(t, imagePath) + }) +} + +func TestAbsoluteRegularFilesExpandsGlobsInSortedOrder(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + + for _, rpm := range []string{"z-last.rpm", "a-first.rpm"} { + path := filepath.Join("/project/out", rpm) + require.NoError(t, fileutils.WriteFile(testEnv.TestFS, path, []byte("rpm"), fileperms.PrivateFile)) + } + + paths, err := absoluteRegularFiles(testEnv.Env, []string{"out/*.rpm"}, "rpm") + + require.NoError(t, err) + assert.Equal(t, []string{"/project/out/a-first.rpm", "/project/out/z-last.rpm"}, paths) +} + +func TestComponentTMTWorkDirDefaultsToCurrentDirectory(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + require.NoError(t, testEnv.TestOSEnv.Chdir("/artifacts")) + + workDir, err := componentTMTWorkDir(testEnv.Env, "") + + require.NoError(t, err) + assert.Equal(t, "/artifacts", workDir) +} + +func TestPrepareTMTEnvironmentDryRunAvoidsFilesystemChanges(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + dryRunOptions := azldev.NewEnvOptions() + dryRunOptions.ProjectDir = "/project" + dryRunOptions.Config = testEnv.Config + dryRunOptions.DryRunnable = azldev.NewAppDryRunnable(true) + dryRunOptions.EventListener = testEnv.EventListener + dryRunOptions.Interfaces = testEnv.TestInterfaces + dryRunEnv := azldev.NewEnv(t.Context(), dryRunOptions) + + workDir, tmtProgramPath, err := prepareTMTEnvironment(dryRunEnv, "artifacts", tmtProvisionVirtual) + + require.NoError(t, err) + assert.Equal(t, "/project/artifacts", workDir) + assert.Equal(t, "/project/artifacts/tmt/venv/bin/tmt", tmtProgramPath) + + _, err = testEnv.TestFS.Stat(workDir) + assert.ErrorIs(t, err, fs.ErrNotExist) +} + +func TestNewComponentTestCmd(t *testing.T) { + cmd := NewComponentTestCmd() + + require.NotNil(t, cmd) + assert.Equal(t, "test COMPONENT", cmd.Use) + assert.NotNil(t, cmd.RunE) + + for _, name := range []string{ + "image-path", "rpm", "test", "work-dir", "provision", + } { + assert.NotNil(t, cmd.Flags().Lookup(name), "%s flag should be registered", name) + } + + // Removed flags: memory, firmware, connection, user + for _, name := range []string{"memory", "firmware", "connection", "user"} { + assert.Nil(t, cmd.Flags().Lookup(name), "%s flag should not be registered", name) + } +} + +func TestComponentTestCmdNoMatch(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + imagePath := "/project/image.qcow2" + rpmPath := "/project/component.rpm" + + require.NoError(t, fileutils.WriteFile(testEnv.TestFS, imagePath, []byte("image"), fileperms.PrivateFile)) + require.NoError(t, fileutils.WriteFile(testEnv.TestFS, rpmPath, []byte("rpm"), fileperms.PrivateFile)) + + cmd := NewComponentTestCmd() + cmd.SetArgs([]string{"missing-component", "--image-path", imagePath, "--rpm", rpmPath}) + + err := cmd.ExecuteContext(testEnv.Env) + + require.Error(t, err) + assert.Contains(t, err.Error(), "component not found") +} + +func TestRemovePreviousTMTRepositoryRemovesOnlyRepository(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + testDir := "/project/tmt-test" + repoDir := filepath.Join(testDir, "repo") + artifactPath := filepath.Join(testDir, "tmt", "results.yaml") + staleMetadataPath := filepath.Join(repoDir, "stale-metadata") + + require.NoError(t, testEnv.TestFS.MkdirAll(repoDir, fileperms.PublicDir)) + require.NoError(t, fileutils.WriteFile(testEnv.TestFS, staleMetadataPath, []byte("stale"), fileperms.PrivateFile)) + require.NoError(t, testEnv.TestFS.MkdirAll(filepath.Dir(artifactPath), fileperms.PublicDir)) + require.NoError(t, fileutils.WriteFile(testEnv.TestFS, artifactPath, []byte("preserve"), fileperms.PrivateFile)) + + require.NoError(t, removePreviousTMTRepository(testEnv.Env, repoDir)) + _, err := testEnv.TestFS.Stat(repoDir) + require.ErrorIs(t, err, fs.ErrNotExist) + _, err = testEnv.TestFS.Stat(artifactPath) + assert.NoError(t, err) +} + +func TestRunHostCommandOutputIncludesStderr(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + testEnv.CmdFactory.RunAndGetOutputHandler = func(command *exec.Cmd) (string, error) { + require.NotNil(t, command.Stderr) + _, err := command.Stderr.Write([]byte("tmt export diagnostic")) + require.NoError(t, err) + + return "", errors.New("command failed") + } + + _, err := runHostCommandOutput(testEnv.Env, "/project", "tmt", "plan", "export") + + require.Error(t, err) + require.ErrorContains(t, err, "tmt export diagnostic") + require.ErrorContains(t, err, "command failed") +} + +func TestRunHostCommandOutputOmitsEmptyStderr(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + testEnv.CmdFactory.RunAndGetOutputHandler = func(*exec.Cmd) (string, error) { + return "", errors.New("command failed") + } + + _, err := runHostCommandOutput(testEnv.Env, "/project", "tmt", "plan", "export") + + require.EqualError(t, err, "run host command `tmt`:\ncommand failed") +} + +func TestComponentTMTArgsPreservesNativePlanSteps(t *testing.T) { + args := componentTMTArgs( + tmtConfig{Plan: "/plans/smoke"}, + "/work/tmt", + tmtProvisionVirtual, + "/path/to/image.qcow2", + []string{"memory >= 4 GB", "boot.method = uefi"}, + []string{"/rpms/component.rpm", "/rpms/component-tests.rpm"}, + ) + + assert.Equal(t, []string{ + "run", "--all", "--keep", "--workdir-root", "/work/tmt", + "plan", "--name", "/plans/smoke", + "provision", "--how", "virtual", "--become", "--image", "/path/to/image.qcow2", + "--hardware", "memory >= 4 GB", "--hardware", "boot.method = uefi", + "prepare", "--insert", "--how", "install", "--name", tmtCandidateRPMPrepareStepName, + "--package", "/rpms/component.rpm", + "--package", "/rpms/component-tests.rpm", + }, args) + assert.NotContains(t, args, "discover") + assert.NotContains(t, args, "execute") + assert.NotContains(t, args, "report") + assert.NotContains(t, args, "finish") +} + +func TestComponentTMTArgsVirtualProvisioner(t *testing.T) { + args := componentTMTArgs( + tmtConfig{Plan: "/plans/smoke"}, + "/work/tmt", + tmtProvisionVirtual, + "/path/to/image.qcow2", + []string{"boot.method = uefi"}, + []string{"/rpms/component.rpm"}, + ) + + assert.Equal(t, []string{ + "run", "--all", "--keep", "--workdir-root", "/work/tmt", + "plan", "--name", "/plans/smoke", + "provision", "--how", "virtual", "--become", "--image", "/path/to/image.qcow2", + "--hardware", "boot.method = uefi", + "prepare", "--insert", "--how", "install", "--name", tmtCandidateRPMPrepareStepName, + "--package", "/rpms/component.rpm", + }, args) +} + +func TestComponentTMTArgsLocalProvisioner(t *testing.T) { + // Pass an image path and hardware constraints the local provisioner cannot + // use, so the assertions below prove they are suppressed rather than merely + // absent from the input. + args := componentTMTArgs( + tmtConfig{Plan: "/plans/smoke"}, + "/work/tmt", + tmtProvisionLocal, + "/path/to/image.qcow2", + []string{"memory >= 4 GB", "boot.method = uefi"}, + []string{"/rpms/component.rpm"}, + ) + + assert.Equal(t, []string{ + "run", "--all", "--keep", "--workdir-root", "/work/tmt", + "plan", "--name", "/plans/smoke", + "provision", "--how", "local", "--become", + "prepare", "--insert", "--how", "install", "--name", tmtCandidateRPMPrepareStepName, + "--package", "/rpms/component.rpm", + }, args) + assert.NotContains(t, args, "--image") + assert.NotContains(t, args, "--hardware") +} + +func TestTMTCommandEnv(t *testing.T) { + testCases := map[string]struct { + pluginDir string + provision string + bootTimeoutSet string + expected []string + }{ + "local provisioner acknowledges host modification": { + provision: tmtProvisionLocal, + expected: []string{"TMT_BOOT_TIMEOUT=300", "TMT_FEELING_SAFE=1"}, + }, + "virtual provisioner omits the acknowledgement": { + pluginDir: "/work/test/tmt-plugins", + provision: tmtProvisionVirtual, + expected: []string{"TMT_PLUGINS=/work/test/tmt-plugins", "TMT_BOOT_TIMEOUT=300"}, + }, + "explicit boot timeout is preserved": { + provision: tmtProvisionLocal, + bootTimeoutSet: "900", + expected: []string{"TMT_FEELING_SAFE=1"}, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + if testCase.bootTimeoutSet != "" { + osEnv, ok := testEnv.TestOSEnv.(*testctx.TestOSEnv) + require.True(t, ok) + osEnv.SetEnv("TMT_BOOT_TIMEOUT", testCase.bootTimeoutSet) + } + + vars := tmtCommandEnv(testEnv.Env, testCase.pluginDir, testCase.provision) + + assert.Equal(t, testCase.expected, vars) + }) + } +} + +func TestWriteTestcloudPluginRepairsPermissionsBeforePull(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + pluginDir, err := writeTestcloudPlugin(testEnv.Env, "/project/test") + + require.NoError(t, err) + plugin, err := fileutils.ReadFile(testEnv.TestFS, filepath.Join(pluginDir, "azldev_testcloud.py")) + require.NoError(t, err) + + pluginText := string(plugin) + assert.True(t, strings.HasPrefix(pluginText, "import tmt.steps.provision.testcloud as testcloud\n")) + assert.Contains(t, pluginText, "\nfrom tmt.guest import GuestSsh\n") + assert.Contains(t, pluginText, "_azldev_original_pull = GuestSsh.pull") + assert.Contains(t, pluginText, "GuestSsh.pull = _azldev_pull") + assert.Less(t, + strings.Index(pluginText, "Command(\"sudo\", \"chmod\", \"-R\", \"a+rX\", path)"), + strings.Index(pluginText, "return _azldev_original_pull("), + ) + // System libvirt connection: SSH only + assert.Contains(t, pluginText, "firewall-cmd --add-service=ssh || :") + assert.NotContains(t, pluginText, "firewall-cmd --add-port=10022/tcp || :") +} diff --git a/internal/utils/prereqs/prereqs.go b/internal/utils/prereqs/prereqs.go index c2b1c8cfc..3cfdd2e16 100644 --- a/internal/utils/prereqs/prereqs.go +++ b/internal/utils/prereqs/prereqs.go @@ -9,6 +9,7 @@ import ( "log/slog" "os" "os/exec" + "strings" "github.com/acobaugh/osrelease" "github.com/microsoft/azure-linux-dev-tools/internal/global/opctx" @@ -29,6 +30,8 @@ const ( OSIDAzureLinux = "azurelinux" // The OS ID of Fedora. OSIDFedora = "fedora" + // The major version of Azure Linux 4, as reported by VERSION_ID. + azureLinux4MajorVersion = "4" ) // ErrMissingExecutable is returned when a required executable cannot be found or acquired. @@ -124,25 +127,63 @@ func (p *PackagePrereq) selectInstallerAndPackagesForHost( } func getHostOSID(ctx opctx.Ctx) (osid string, err error) { + osRelease, err := getHostOSRelease(ctx) + if err != nil { + return osid, err + } + + if osid, found := osRelease["ID"]; found { + return osid, nil + } + + return osid, errors.New("failed to find OS ID in host's os-release file") +} + +// RequireAzureLinux4 verifies that commands which modify the current host run +// only on Azure Linux 4. +func RequireAzureLinux4(ctx opctx.Ctx) error { + osRelease, err := getHostOSRelease(ctx) + if err != nil { + return err + } + + osid, found := osRelease["ID"] + if !found { + return errors.New("failed to find OS ID in host's os-release file") + } + + if osid != OSIDAzureLinux { + return fmt.Errorf("requires Azure Linux 4, found host OS %#q", osid) + } + + version, found := osRelease["VERSION_ID"] + if !found { + return errors.New("failed to find VERSION_ID in host's os-release file") + } + + if major, _, _ := strings.Cut(version, "."); major != azureLinux4MajorVersion { + return fmt.Errorf("requires Azure Linux 4, found host version %#q", version) + } + + return nil +} + +func getHostOSRelease(ctx opctx.Ctx) (map[string]string, error) { osReleaseBytes, readErr := fileutils.ReadFile(ctx.FS(), osrelease.EtcOsRelease) if readErr != nil && errors.Is(readErr, os.ErrNotExist) { osReleaseBytes, readErr = fileutils.ReadFile(ctx.FS(), osrelease.UsrLibOsRelease) } if readErr != nil { - return osid, fmt.Errorf("failed to read os-release file to detect host OS:\n%w", readErr) + return nil, fmt.Errorf("failed to read os-release file to detect host OS:\n%w", readErr) } osRelease, err := osrelease.ReadString(string(osReleaseBytes)) if err != nil { - return osid, fmt.Errorf("failed to parse os-release file to detect host OS:\n%w", err) + return nil, fmt.Errorf("failed to parse os-release file to detect host OS:\n%w", err) } - if osid, found := osRelease["ID"]; found { - return osid, nil - } - - return osid, errors.New("failed to find OS ID in host's os-release file") + return osRelease, nil } func makePackageInstallCmd(ctx opctx.Ctx, installer string, packageNames []string) (cmd *exec.Cmd, err error) { diff --git a/internal/utils/prereqs/prereqs_test.go b/internal/utils/prereqs/prereqs_test.go index 778268bc9..7abcaaa66 100644 --- a/internal/utils/prereqs/prereqs_test.go +++ b/internal/utils/prereqs/prereqs_test.go @@ -87,6 +87,33 @@ func TestRequireExecutable_Existent(t *testing.T) { require.NoError(t, err) } +func TestRequireAzureLinux4(t *testing.T) { + for name, testCase := range map[string]struct { + contents string + expectedError string + }{ + "Azure Linux 4": {contents: "ID=azurelinux\nVERSION_ID=4.0\n"}, + "Azure Linux 3": {contents: "ID=azurelinux\nVERSION_ID=3.0\n", expectedError: "requires Azure Linux 4"}, + "Azure Linux 40": {contents: "ID=azurelinux\nVERSION_ID=40\n", expectedError: "requires Azure Linux 4"}, + "Fedora": {contents: "ID=fedora\nVERSION_ID=43\n", expectedError: "requires Azure Linux 4"}, + "missing ID": {contents: "VERSION_ID=4.0\n", expectedError: "failed to find OS ID"}, + "missing VERSION_ID": {contents: "ID=azurelinux\n", expectedError: "failed to find VERSION_ID"}, + } { + t.Run(name, func(t *testing.T) { + ctx := newTestCtx() + require.NoError(t, fileutils.WriteFile(ctx.FS(), osrelease.EtcOsRelease, + []byte(testCase.contents), fileperms.PublicFile)) + + err := prereqs.RequireAzureLinux4(ctx) + if testCase.expectedError == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, testCase.expectedError) + } + }) + } +} + func TestPrereqInstall_UnsupportedHost(t *testing.T) { prereq := &prereqs.PackagePrereq{ AzureLinuxPackages: []string{"test-package"},