From 4ce5bc907166551a06b3b9075ae08488f1dece6d Mon Sep 17 00:00:00 2001 From: reuben olinsky Date: Thu, 29 Oct 2026 19:47:36 +0000 Subject: [PATCH] refactor(tmt): extract component test helper package Move the existing component TMT options, configuration and source resolution, preflight, setup, native invocation, plugin and tests into internal/tmt. Keep command wiring and command-level tests in component, and relocate the unchanged runtime constraints together with their Dependabot and E2E consumers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/dependabot.yml | 2 +- docs/developer/how-to/testing.md | 4 +- internal/app/azldev/cmds/component/test.go | 1149 +---------------- .../app/azldev/cmds/component/test_test.go | 128 ++ internal/tmt/component.go | 1149 +++++++++++++++++ .../component_internal_test.go} | 128 +- .../cmds/component => tmt}/requirements.txt | 0 scenario/tmt_virtual_e2e_test.go | 2 +- 8 files changed, 1303 insertions(+), 1259 deletions(-) create mode 100644 internal/app/azldev/cmds/component/test_test.go create mode 100644 internal/tmt/component.go rename internal/{app/azldev/cmds/component/test_internal_test.go => tmt/component_internal_test.go} (85%) rename internal/{app/azldev/cmds/component => tmt}/requirements.txt (100%) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 29c9f715..e28a5c30 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -108,7 +108,7 @@ updates: # TMT component-test runtime - package-ecosystem: "pip" - directory: "/internal/app/azldev/cmds/component" + directory: "/internal/tmt" schedule: interval: "weekly" cooldown: diff --git a/docs/developer/how-to/testing.md b/docs/developer/how-to/testing.md index ccb977b6..987d18c2 100644 --- a/docs/developer/how-to/testing.md +++ b/docs/developer/how-to/testing.md @@ -73,11 +73,11 @@ image-construction test; ordinary scenario and e2e containers remain unchanged. ### Virtual TMT provisioning regression `mage e2e -target=tmt` builds the current Azure Linux image, then runs the real -`azldev component test` flow with TMT 1.78.0, testcloud 0.12.0, and a tiny +`azldev component test` flow with the pinned TMT/testcloud runtime and a tiny test-only probe RPM. The non-privileged TMT runner maps `/dev/kvm`, uses private session-libvirt state, and retains diagnostics under `out/tmt-virtual-e2e/`. Production and test dependency pins share -`internal/app/azldev/cmds/component/requirements.txt`, which Dependabot tracks. +`internal/tmt/requirements.txt`, which Dependabot tracks. This regression is temporarily quarantined while the known testcloud readiness failure is fixed. The normal `mage e2e` suite compiles the test but skips it; diff --git a/internal/app/azldev/cmds/component/test.go b/internal/app/azldev/cmds/component/test.go index cf50bff2..62fe5a36 100644 --- a/internal/app/azldev/cmds/component/test.go +++ b/internal/app/azldev/cmds/component/test.go @@ -4,128 +4,18 @@ package component import ( - "bytes" - _ "embed" - "encoding/json" - "errors" - "fmt" - "io" - "io/fs" - "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/microsoft/azure-linux-dev-tools/internal/tmt" "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 - // SourceDir points tmt straight at a local fmf tree instead of cloning the - // catalog 'source' — any directory with an fmf root works, whether produced - // by 'azldev component render' (with render.skip-file-filter=true) or - // 'azldev component prepare-sources'. - SourceDir string - // Plan overrides 'tmt.plan' from the catalog. Empty means tmt itself - // discovers and runs every enabled plan it finds, instead of a fixed name - // (the virtual provisioner is the one exception: it requires an explicit - // plan, since hardware export is resolved per-plan). - Plan string - // FromSpec is deprecated: use SourceDir instead. When set, it resolves to - // the component's rendered spec directory, preserving the pre-rename - // '--from-spec' behavior for existing invocations. - FromSpec bool -} - -type tmtSource struct { - GitURL string `toml:"git-url"` - Ref string `toml:"ref"` -} - -type tmtConfig struct { - Source tmtSource `toml:"source"` - // Plan is optional: empty means tmt discovers and runs every enabled plan - // (mirrors Fedora Testing Farm's own default of no pinned plan name). - 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 - // SourceDir, when set, runs tmt straight from this local fmf tree instead of - // cloning the catalog 'source' (see ComponentTestOptions.SourceDir). - SourceDir string -} - -const ( - tmtPythonProgram = "python3" - tmtVenvDirName = "venv" - tmtProgram = "tmt" - tmtProvisionLocal = "local" - tmtProvisionVirtual = "virtual" -) - -const ( - tmtCandidateRPMPrepareStepName = "azldev-candidate-rpms" - tmtRequirementsFileName = "requirements.txt" -) - -//go:embed requirements.txt -var tmtRequirements []byte - -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{} + options := &tmt.Options{} cmd := &cobra.Command{ Use: "test COMPONENT", @@ -196,7 +86,7 @@ invoke git itself regardless of '--source-dir'.`, --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) + return nil, tmt.RunComponentTests(env, args[0], options) }), } @@ -205,7 +95,7 @@ invoke git itself regardless of '--source-dir'.`, return cmd } -func registerComponentTestFlags(cmd *cobra.Command, options *ComponentTestOptions) { +func registerComponentTestFlags(cmd *cobra.Command, options *tmt.Options) { cmd.Flags().StringVarP(&options.ImagePath, "image-path", "i", "", "Path to the qcow2 image under test") _ = cmd.MarkFlagFilename("image-path") cmd.Flags().StringSliceVarP( @@ -220,7 +110,7 @@ func registerComponentTestFlags(cmd *cobra.Command, options *ComponentTestOption &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, + cmd.Flags().StringVar(&options.Provision, "provision", tmt.ProvisionVirtual, "TMT provisioner mode: 'virtual' (default) runs tests in QEMU; 'local' runs on this machine (must be Azure Linux 4)") cmd.Flags().StringVar(&options.SourceDir, "source-dir", "", "Run tmt straight from this local fmf tree instead of cloning the catalog 'source'. Accepts the "+ @@ -239,1032 +129,3 @@ func registerComponentTestFlags(cmd *cobra.Command, options *ComponentTestOption "rendered spec directory") _ = cmd.MarkFlagRequired("rpm") } - -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 - } - - sourceDir, err := effectiveSourceDir(env, componentName, options) - if err != nil { - return err - } - - if sourceDir != "" { - preflightWorkDir, err := componentTMTWorkDir(env, options.WorkDir) - if err != nil { - return fmt.Errorf("resolve work directory:\n%w", err) - } - - if dirsOverlap(sourceDir, preflightWorkDir) { - return fmt.Errorf( - "'--source-dir' (%#q) and the work directory (%#q) must not overlap: azldev creates "+ - "per-test directories and TMT writes run artifacts under the work directory, which "+ - "would otherwise modify the supplied source tree", sourceDir, preflightWorkDir) - } - } - - resolved, err := resolveComponentTMTTests(env, componentName, options.Tests, sourceDir, options.Plan) - if err != nil { - return err - } - - // Preflight every selected test's decoded config against sourceDir and the - // provisioner before any environment setup begins (venv creation, pip - // install, cloning), so these deterministic input errors surface - // immediately instead of after network work and on-disk artifacts. - if err := preflightTMTTests(resolved, sourceDir, options.Provision); err != nil { - return err - } - - // Validate the source dir up front so '--source-dir'/'--from-spec' fail fast, - // before prepareTMTEnvironment creates a venv and pip-installs TMT under - // --work-dir. - if sourceDir != "" { - if _, err := resolveSourceRunDir(env, sourceDir); err != nil { - return err - } - } - - workDir, tmtProgramPath, err := prepareTMTEnvironment(env, options.WorkDir, options.Provision, sourceDir != "") - if err != nil { - return err - } - - settings := tmtRunSettings{ - ImagePath: imagePath, - RPMs: rpms, - WorkDir: workDir, - TMTProgramPath: tmtProgramPath, - Provision: options.Provision, - SourceDir: sourceDir, - } - - 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 -} - -// effectiveSourceDir resolves the local fmf tree to run tmt from, honoring the -// deprecated '--from-spec' alongside '--source-dir'. '--from-spec' resolves to -// the component's rendered spec directory, matching its pre-rename behavior. -func effectiveSourceDir(env *azldev.Env, componentName string, options *ComponentTestOptions) (string, error) { - if !options.FromSpec { - if options.SourceDir == "" { - return "", nil - } - - // Resolve relative to the project directory, like --image-path, --rpm, - // and --work-dir: 'azldev -C' selects the project without changing the - // process working directory, so a relative path must not be read as - // relative to the caller's own directory. - sourceDir, err := absoluteProjectPath(env.ProjectDir(), options.SourceDir) - if err != nil { - return "", fmt.Errorf("resolve source dir:\n%w", err) - } - - return sourceDir, nil - } - - if options.SourceDir != "" { - return "", errors.New("'--from-spec' is deprecated and cannot be combined with '--source-dir'; " + - "pass only '--source-dir'") - } - - renderedSpecDir, err := resolveComponentRenderedSpecDir(env, componentName) - if err != nil { - return "", err - } - - if renderedSpecDir == "" { - return "", errors.New( - "'--from-spec' requires a rendered spec directory; ensure 'project.rendered-specs-dir' is set") - } - - return renderedSpecDir, nil -} - -// resolveComponentRenderedSpecDir looks up the component's rendered spec -// directory (e.g. 'SPECS/c/curl'), as populated by the component resolver. -func resolveComponentRenderedSpecDir(env *azldev.Env, componentName string) (string, error) { - resolver := components.NewResolver(env) - - set, err := resolver.FindComponents(&components.ComponentFilter{ComponentNamePatterns: []string{componentName}}) - if err != nil { - return "", fmt.Errorf("resolve component %#q:\n%w", componentName, err) - } - - if set.Len() != 1 { - return "", fmt.Errorf("expected exactly one component named %#q, found %d", componentName, set.Len()) - } - - return set.Components()[0].GetConfig().RenderedSpecDir, nil -} - -func resolveComponentTMTTests( - env *azldev.Env, componentName string, selectors []string, sourceDir string, planOverride 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()) - } - - componentConfig := set.Components()[0].GetConfig() - - allTests, err := env.Config().ResolveComponentTests(componentConfig) - if err != nil { - return nil, fmt.Errorf("resolve tests for component %#q:\n%w", componentName, err) - } - - // A selector (--test) that matches nothing is always an error, whether or - // not the component has any catalog tmt tests at all: silently falling - // through to ad-hoc synthesis would mask a typo'd --test name. - resolved := selectTMTTests(allTests, selectors) - - switch { - case len(resolved) > 0: - // Use the selected catalog test(s) as-is. - case len(selectors) > 0: - return nil, fmt.Errorf("component %#q has no selected TMT tests", componentName) - case sourceDir == "": - return nil, fmt.Errorf( - "component %#q has no TMT tests in the catalog; pass '--source-dir' to run an ad-hoc test", componentName) - default: - // No catalog [tests.X] entry at all is fine when the caller supplies the - // test source directly: synthesize one ad-hoc test named after the - // component itself, with no 'source' (unused once a source dir is given). - resolved = []projectconfig.ResolvedTest{{ - Name: componentName, - Definition: projectconfig.TestDefinition{Type: "tmt", Tmt: map[string]any{"plan": planOverride}}, - }} - } - - if planOverride != "" { - resolved = overrideTMTPlan(resolved, planOverride) - } - - return resolved, nil -} - -// overrideTMTPlan returns a copy of tests with 'tmt.plan' set to plan, -// regardless of whatever the catalog declared. Copies each test's Tmt map -// before mutating it, since it may be shared with the loaded project config. -func overrideTMTPlan(tests []projectconfig.ResolvedTest, plan string) []projectconfig.ResolvedTest { - result := make([]projectconfig.ResolvedTest, len(tests)) - - for index, test := range tests { - tmt := make(map[string]any, len(test.Definition.Tmt)+1) - for key, value := range test.Definition.Tmt { - tmt[key] = value - } - - tmt["plan"] = plan - test.Definition.Tmt = tmt - result[index] = test - } - - return result -} - -func prepareTMTEnvironment( - env *azldev.Env, configuredWorkDir string, provision string, fromSourceDir bool, -) (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, fromSourceDir) - 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, unless --source-dir avoids -// cloning) 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, fromSourceDir bool) (string, error) { - if err := prereqs.RequireExecutable(env, tmtPythonProgram, nil); err != nil { - return "", fmt.Errorf("python3 is required to run TMT tests:\n%w", err) - } - - // --source-dir runs the plan from a local fmf tree and never clones, so git - // is only a prerequisite for the default (clone) path. - if !fromSourceDir { - 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 := tmtInstallRequirement(provision) - - requirementsPath := filepath.Join(filepath.Dir(venvDir), tmtRequirementsFileName) - if err := fileutils.WriteFile(env.FS(), requirementsPath, tmtRequirements, fileperms.PrivateFile); err != nil { - return "", fmt.Errorf("write TMT requirements:\n%w", err) - } - - slog.Info( - "Installing TMT", - slog.String("venv", venvDir), - slog.String("requirement", requirement), - slog.String("constraints", requirementsPath), - ) - - if err := runHostCommand( - env, "", venvPython, "-m", "pip", "install", "--quiet", - "--constraint", requirementsPath, requirement, - ); err != nil { - return "", fmt.Errorf("install TMT:\n%w", err) - } - - return tmtProgramPath, nil -} - -func tmtInstallRequirement(provision string) string { - if provision == tmtProvisionVirtual { - return "tmt[provision-virtual]" - } - - return "tmt" -} - -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 -} - -// dirsOverlap reports whether a and b are the same directory, or one contains -// the other. Both must already be absolute. -func dirsOverlap(a string, b string) bool { - return isAncestorOrSame(a, b) || isAncestorOrSame(b, a) -} - -// isAncestorOrSame reports whether path is ancestor itself, or nested under it. -func isAncestorOrSame(ancestor string, path string) bool { - ancestor = filepath.Clean(ancestor) - path = filepath.Clean(path) - - if ancestor == path { - return true - } - - rel, err := filepath.Rel(ancestor, path) - if err != nil { - return false - } - - return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) -} - -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 -} - -// preflightTMTTests validates every selected test's decoded tmt config against -// sourceDir and the provisioner before runComponentTMTTests prepares the TMT -// environment (venv creation, pip install) or runOneTMTTest clones metadata. -// Mirrors the equivalent checks in runOneTMTTest, run here up front so these -// deterministic input errors (missing source, missing plan for the virtual -// provisioner) surface immediately rather than after network work and -// on-disk artifacts. -func preflightTMTTests(resolved []projectconfig.ResolvedTest, sourceDir string, provision string) error { - for _, test := range resolved { - config, err := decodeTMTConfig(test.Definition.Tmt) - if err != nil { - return err - } - - if err := validateTMTTestName(test.Name); err != nil { - return err - } - - if sourceDir == "" && (config.Source.GitURL == "" || config.Source.Ref == "") { - return fmt.Errorf("test %#q: 'tmt.source' (git-url, ref) is required unless '--source-dir' is set", test.Name) - } - - if provision == tmtProvisionVirtual && config.Plan == "" { - return fmt.Errorf( - "test %#q: 'tmt.plan' (or '--plan') is required with '--provision virtual'; "+ - "hardware constraints are resolved per-plan", test.Name) - } - } - - 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 - } - - if settings.SourceDir == "" && (config.Source.GitURL == "" || config.Source.Ref == "") { - return fmt.Errorf("test %#q: 'tmt.source' (git-url, ref) is required unless '--source-dir' is set", test.Name) - } - - 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 - } - - // runDir is the fmf tree tmt runs against: either the freshly cloned catalog - // source (default) or a local fmf tree (--source-dir). - runDir := repoDir - if settings.SourceDir != "" { - runDir, err = resolveSourceRunDir(env, settings.SourceDir) - if err != nil { - return err - } - } else { - 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 { - if config.Plan == "" { - return fmt.Errorf( - "test %#q: 'tmt.plan' (or '--plan') is required with '--provision virtual'; "+ - "hardware constraints are resolved per-plan", test.Name) - } - - hardwareArgs, err = resolvedPlanHardwareArgs( - env, runDir, 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, runDir, pluginDir, settings.TMTProgramPath, settings.Provision, args...); err != nil { - return fmt.Errorf("run TMT plan %#q (artifacts: %#q):\n%w", tmtPlanDescription(config.Plan), tmtWorkDir, err) - } - - return nil -} - -// tmtPlanDescription returns a human-readable plan selection for error -// messages, since config.Plan may be empty (every enabled plan runs). -func tmtPlanDescription(plan string) string { - if plan == "" { - return "" - } - - return plan -} - -// resolveSourceRunDir validates that the given directory carries an fmf root, -// returning it as the tmt run directory. It backs the --source-dir flow, -// which runs a plan straight from a local fmf tree — the output of either -// 'azldev component render' (with 'render.skip-file-filter = true') or -// 'azldev component prepare-sources' — instead of cloning the catalog -// 'source'. tmt writes its run artifacts under a separate --workdir-root, so -// the given tree is only read. -func resolveSourceRunDir(env *azldev.Env, sourceDir string) (string, error) { - fmfVersion := filepath.Join(sourceDir, ".fmf", "version") - - // Require a regular file: fmf's version marker is a file, and a directory (or - // other node) at this path is not valid fmf metadata. - info, err := env.FS().Stat(fmfVersion) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - return "", fmt.Errorf( - "no fmf metadata in %#q (missing %#q); produce it with 'azldev component render' "+ - "(with 'render.skip-file-filter = true') or 'azldev component prepare-sources' first", - sourceDir, filepath.Join(".fmf", "version")) - } - - return "", fmt.Errorf("check fmf root %#q:\n%w", fmfVersion, err) - } - - if !info.Mode().IsRegular() { - return "", fmt.Errorf( - "invalid fmf metadata in %#q: %#q must be a regular file", - sourceDir, filepath.Join(".fmf", "version")) - } - - return sourceDir, 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. repoDir is - // only ever populated by our own clone step, so this is skipped entirely for - // '--source-dir': that flow never clones into repoDir, and the caller's - // supplied tree must never be touched, even if it happens to coincide with - // (or contain) the managed repoDir path. - if settings.SourceDir == "" { - 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) - - // Omitting '--name' entirely (plan empty) makes tmt discover and run every - // enabled plan it finds — the same default Fedora's own Testing Farm uses, - // instead of a fixed guessed name. - if config.Plan != "" { - args = append(args, "plan", "--name", config.Plan) - } else { - args = append(args, "plan") - } - - args = append(args, "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) { - var sourceConfig projectconfig.GitSourceConfig - - // Optional means absent, not invalid: a present-but-malformed 'source' (e.g. - // a string instead of a table) is rejected rather than silently treated the - // same as an omitted one. - if rawSourceValue, hasSource := raw["source"]; hasSource { - rawSource, ok := rawSourceValue.(map[string]any) - if !ok { - return tmtConfig{}, fmt.Errorf("'tmt.source' must be a table with 'git-url' and 'ref', got %T", rawSourceValue) - } - - gitURL, _ := rawSource["git-url"].(string) - ref, _ := rawSource["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 is optional: empty means tmt discovers and runs every enabled plan - // (see tmtConfig.Plan). As with 'source' above, a present-but-malformed - // value (not a string) is rejected rather than silently treated as absent. - var plan string - - if rawPlanValue, hasPlan := raw["plan"]; hasPlan { - planStr, ok := rawPlanValue.(string) - if !ok { - return tmtConfig{}, fmt.Errorf("'tmt.plan' must be a string, got %T", rawPlanValue) - } - - plan = planStr - if plan != "" && !strings.HasPrefix(plan, "/") { - return tmtConfig{}, errors.New("'tmt.plan' must be an absolute plan name") - } - } - - return tmtConfig{Source: tmtSource{GitURL: sourceConfig.GitURL, Ref: sourceConfig.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_test.go b/internal/app/azldev/cmds/component/test_test.go new file mode 100644 index 00000000..ac861efc --- /dev/null +++ b/internal/app/azldev/cmds/component/test_test.go @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package component_test + +import ( + "path/filepath" + "testing" + + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/cmds/component" + "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" +) + +const ( + testTMTComponentName = "hostname" + testImagePath = "/project/image.qcow2" + testRPMPath = "/project/component.rpm" +) + +func newTMTTestEnv(t *testing.T, tests *projectconfig.ComponentTestsConfig) *testutils.TestEnv { + t.Helper() + + testEnv := testutils.NewTestEnv(t) + testEnv.Config.Components[testTMTComponentName] = projectconfig.ComponentConfig{ + Name: testTMTComponentName, + Spec: projectconfig.SpecSource{ + SourceType: projectconfig.SpecSourceTypeLocal, + Path: "/project/" + testTMTComponentName + ".spec", + }, + Tests: tests, + } + + return testEnv +} + +func TestNewComponentTestCmd(t *testing.T) { + cmd := component.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", "source-dir", "plan", "from-spec", + } { + assert.NotNil(t, cmd.Flags().Lookup(name), "%s flag should be registered", name) + } + + fromSpec := cmd.Flags().Lookup("from-spec") + require.NotNil(t, fromSpec) + assert.NotEmpty(t, fromSpec.Deprecated, "--from-spec should be marked deprecated") + + // 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) + + require.NoError(t, fileutils.WriteFile(testEnv.TestFS, testImagePath, []byte("image"), fileperms.PrivateFile)) + require.NoError(t, fileutils.WriteFile(testEnv.TestFS, testRPMPath, []byte("rpm"), fileperms.PrivateFile)) + + cmd := component.NewComponentTestCmd() + cmd.SetArgs([]string{"missing-component", "--image-path", testImagePath, "--rpm", testRPMPath}) + + err := cmd.ExecuteContext(testEnv.Env) + + require.Error(t, err) + assert.Contains(t, err.Error(), "component not found") +} + +// TestComponentTestCmdPreflightsBeforePreparingEnvironment proves the fix for +// the deterministic-input-error-after-side-effects issue: a catalog test +// missing 'tmt.source' (and no --source-dir) must fail before azldev creates a +// venv or runs pip install, not partway through runOneTMTTest. +func TestComponentTestCmdPreflightsBeforePreparingEnvironment(t *testing.T) { + testEnv := newTMTTestEnv(t, &projectconfig.ComponentTestsConfig{ + Tests: []projectconfig.TestRef{{Name: "ci"}}, + }) + testEnv.Config.Tests["ci"] = projectconfig.TestDefinition{Type: "tmt", Tmt: map[string]any{}} + + require.NoError(t, fileutils.WriteFile(testEnv.TestFS, testImagePath, []byte("image"), fileperms.PrivateFile)) + require.NoError(t, fileutils.WriteFile(testEnv.TestFS, testRPMPath, []byte("rpm"), fileperms.PrivateFile)) + + cmd := component.NewComponentTestCmd() + cmd.SetArgs([]string{ + testTMTComponentName, "--image-path", testImagePath, "--rpm", testRPMPath, "--plan", "/plans/all", + }) + + err := cmd.ExecuteContext(testEnv.Env) + + require.Error(t, err) + assert.Contains(t, err.Error(), "'tmt.source'") + assert.Empty(t, testEnv.CommandsExecuted, "no venv/pip commands should run before the preflight check") +} + +// TestComponentTestCmdRejectsOverlappingSourceAndWorkDir covers the scenario +// from the review finding: '--source-dir .' from the project root with the +// default (cwd) work directory makes the managed per-test directory a child +// of the supplied source tree. +func TestComponentTestCmdRejectsOverlappingSourceAndWorkDir(t *testing.T) { + testEnv := newTMTTestEnv(t, nil) + require.NoError(t, fileutils.WriteFile( + testEnv.TestFS, filepath.Join("/project", ".fmf", "version"), []byte("1\n"), fileperms.PrivateFile, + )) + + require.NoError(t, fileutils.WriteFile(testEnv.TestFS, testImagePath, []byte("image"), fileperms.PrivateFile)) + require.NoError(t, fileutils.WriteFile(testEnv.TestFS, testRPMPath, []byte("rpm"), fileperms.PrivateFile)) + + cmd := component.NewComponentTestCmd() + cmd.SetArgs([]string{ + testTMTComponentName, "--image-path", testImagePath, "--rpm", testRPMPath, + "--plan", "/plans/all", "--source-dir", ".", + }) + + err := cmd.ExecuteContext(testEnv.Env) + + require.Error(t, err) + assert.Contains(t, err.Error(), "must not overlap") + assert.Empty(t, testEnv.CommandsExecuted, "no venv/pip commands should run before the overlap check") +} diff --git a/internal/tmt/component.go b/internal/tmt/component.go new file mode 100644 index 00000000..e8458477 --- /dev/null +++ b/internal/tmt/component.go @@ -0,0 +1,1149 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package tmt + +import ( + "bytes" + _ "embed" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "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" +) + +// Options holds options for running component TMT tests locally. +type Options struct { + ImagePath string + RPMPaths []string + Tests []string + WorkDir string + Provision string + // SourceDir points tmt straight at a local fmf tree instead of cloning the + // catalog 'source' — any directory with an fmf root works, whether produced + // by 'azldev component render' (with render.skip-file-filter=true) or + // 'azldev component prepare-sources'. + SourceDir string + // Plan overrides 'tmt.plan' from the catalog. Empty means tmt itself + // discovers and runs every enabled plan it finds, instead of a fixed name + // (the virtual provisioner is the one exception: it requires an explicit + // plan, since hardware export is resolved per-plan). + Plan string + // FromSpec is deprecated: use SourceDir instead. When set, it resolves to + // the component's rendered spec directory, preserving the pre-rename + // '--from-spec' behavior for existing invocations. + FromSpec bool +} + +type tmtSource struct { + GitURL string `toml:"git-url"` + Ref string `toml:"ref"` +} + +type tmtConfig struct { + Source tmtSource `toml:"source"` + // Plan is optional: empty means tmt discovers and runs every enabled plan + // (mirrors Fedora Testing Farm's own default of no pinned plan name). + 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 + // SourceDir, when set, runs tmt straight from this local fmf tree instead of + // cloning the catalog 'source' (see Options.SourceDir). + SourceDir string +} + +const ( + tmtPythonProgram = "python3" + tmtVenvDirName = "venv" + tmtProgram = "tmt" + tmtProvisionLocal = "local" + ProvisionVirtual = "virtual" +) + +const ( + tmtCandidateRPMPrepareStepName = "azldev-candidate-rpms" + tmtRequirementsFileName = "requirements.txt" +) + +//go:embed requirements.txt +var tmtRequirements []byte + +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 +` + +// RunComponentTests runs the component's selected TMT tests with the supplied inputs. +func RunComponentTests(env *azldev.Env, componentName string, options *Options) 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 + } + + sourceDir, err := effectiveSourceDir(env, componentName, options) + if err != nil { + return err + } + + if sourceDir != "" { + preflightWorkDir, err := componentTMTWorkDir(env, options.WorkDir) + if err != nil { + return fmt.Errorf("resolve work directory:\n%w", err) + } + + if dirsOverlap(sourceDir, preflightWorkDir) { + return fmt.Errorf( + "'--source-dir' (%#q) and the work directory (%#q) must not overlap: azldev creates "+ + "per-test directories and TMT writes run artifacts under the work directory, which "+ + "would otherwise modify the supplied source tree", sourceDir, preflightWorkDir) + } + } + + resolved, err := resolveComponentTMTTests(env, componentName, options.Tests, sourceDir, options.Plan) + if err != nil { + return err + } + + // Preflight every selected test's decoded config against sourceDir and the + // provisioner before any environment setup begins (venv creation, pip + // install, cloning), so these deterministic input errors surface + // immediately instead of after network work and on-disk artifacts. + if err := preflightTMTTests(resolved, sourceDir, options.Provision); err != nil { + return err + } + + // Validate the source dir up front so '--source-dir'/'--from-spec' fail fast, + // before prepareTMTEnvironment creates a venv and pip-installs TMT under + // --work-dir. + if sourceDir != "" { + if _, err := resolveSourceRunDir(env, sourceDir); err != nil { + return err + } + } + + workDir, tmtProgramPath, err := prepareTMTEnvironment(env, options.WorkDir, options.Provision, sourceDir != "") + if err != nil { + return err + } + + settings := tmtRunSettings{ + ImagePath: imagePath, + RPMs: rpms, + WorkDir: workDir, + TMTProgramPath: tmtProgramPath, + Provision: options.Provision, + SourceDir: sourceDir, + } + + 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 *Options) 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 != ProvisionVirtual { + 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 +} + +// effectiveSourceDir resolves the local fmf tree to run tmt from, honoring the +// deprecated '--from-spec' alongside '--source-dir'. '--from-spec' resolves to +// the component's rendered spec directory, matching its pre-rename behavior. +func effectiveSourceDir(env *azldev.Env, componentName string, options *Options) (string, error) { + if !options.FromSpec { + if options.SourceDir == "" { + return "", nil + } + + // Resolve relative to the project directory, like --image-path, --rpm, + // and --work-dir: 'azldev -C' selects the project without changing the + // process working directory, so a relative path must not be read as + // relative to the caller's own directory. + sourceDir, err := absoluteProjectPath(env.ProjectDir(), options.SourceDir) + if err != nil { + return "", fmt.Errorf("resolve source dir:\n%w", err) + } + + return sourceDir, nil + } + + if options.SourceDir != "" { + return "", errors.New("'--from-spec' is deprecated and cannot be combined with '--source-dir'; " + + "pass only '--source-dir'") + } + + renderedSpecDir, err := resolveComponentRenderedSpecDir(env, componentName) + if err != nil { + return "", err + } + + if renderedSpecDir == "" { + return "", errors.New( + "'--from-spec' requires a rendered spec directory; ensure 'project.rendered-specs-dir' is set") + } + + return renderedSpecDir, nil +} + +// resolveComponentRenderedSpecDir looks up the component's rendered spec +// directory (e.g. 'SPECS/c/curl'), as populated by the component resolver. +func resolveComponentRenderedSpecDir(env *azldev.Env, componentName string) (string, error) { + resolver := components.NewResolver(env) + + set, err := resolver.FindComponents(&components.ComponentFilter{ComponentNamePatterns: []string{componentName}}) + if err != nil { + return "", fmt.Errorf("resolve component %#q:\n%w", componentName, err) + } + + if set.Len() != 1 { + return "", fmt.Errorf("expected exactly one component named %#q, found %d", componentName, set.Len()) + } + + return set.Components()[0].GetConfig().RenderedSpecDir, nil +} + +func resolveComponentTMTTests( + env *azldev.Env, componentName string, selectors []string, sourceDir string, planOverride 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()) + } + + componentConfig := set.Components()[0].GetConfig() + + allTests, err := env.Config().ResolveComponentTests(componentConfig) + if err != nil { + return nil, fmt.Errorf("resolve tests for component %#q:\n%w", componentName, err) + } + + // A selector (--test) that matches nothing is always an error, whether or + // not the component has any catalog tmt tests at all: silently falling + // through to ad-hoc synthesis would mask a typo'd --test name. + resolved := selectTMTTests(allTests, selectors) + + switch { + case len(resolved) > 0: + // Use the selected catalog test(s) as-is. + case len(selectors) > 0: + return nil, fmt.Errorf("component %#q has no selected TMT tests", componentName) + case sourceDir == "": + return nil, fmt.Errorf( + "component %#q has no TMT tests in the catalog; pass '--source-dir' to run an ad-hoc test", componentName) + default: + // No catalog [tests.X] entry at all is fine when the caller supplies the + // test source directly: synthesize one ad-hoc test named after the + // component itself, with no 'source' (unused once a source dir is given). + resolved = []projectconfig.ResolvedTest{{ + Name: componentName, + Definition: projectconfig.TestDefinition{Type: "tmt", Tmt: map[string]any{"plan": planOverride}}, + }} + } + + if planOverride != "" { + resolved = overrideTMTPlan(resolved, planOverride) + } + + return resolved, nil +} + +// overrideTMTPlan returns a copy of tests with 'tmt.plan' set to plan, +// regardless of whatever the catalog declared. Copies each test's Tmt map +// before mutating it, since it may be shared with the loaded project config. +func overrideTMTPlan(tests []projectconfig.ResolvedTest, plan string) []projectconfig.ResolvedTest { + result := make([]projectconfig.ResolvedTest, len(tests)) + + for index, test := range tests { + tmt := make(map[string]any, len(test.Definition.Tmt)+1) + for key, value := range test.Definition.Tmt { + tmt[key] = value + } + + tmt["plan"] = plan + test.Definition.Tmt = tmt + result[index] = test + } + + return result +} + +func prepareTMTEnvironment( + env *azldev.Env, configuredWorkDir string, provision string, fromSourceDir bool, +) (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, fromSourceDir) + 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, unless --source-dir avoids +// cloning) 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, fromSourceDir bool) (string, error) { + if err := prereqs.RequireExecutable(env, tmtPythonProgram, nil); err != nil { + return "", fmt.Errorf("python3 is required to run TMT tests:\n%w", err) + } + + // --source-dir runs the plan from a local fmf tree and never clones, so git + // is only a prerequisite for the default (clone) path. + if !fromSourceDir { + 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 := tmtInstallRequirement(provision) + + requirementsPath := filepath.Join(filepath.Dir(venvDir), tmtRequirementsFileName) + if err := fileutils.WriteFile(env.FS(), requirementsPath, tmtRequirements, fileperms.PrivateFile); err != nil { + return "", fmt.Errorf("write TMT requirements:\n%w", err) + } + + slog.Info( + "Installing TMT", + slog.String("venv", venvDir), + slog.String("requirement", requirement), + slog.String("constraints", requirementsPath), + ) + + if err := runHostCommand( + env, "", venvPython, "-m", "pip", "install", "--quiet", + "--constraint", requirementsPath, requirement, + ); err != nil { + return "", fmt.Errorf("install TMT:\n%w", err) + } + + return tmtProgramPath, nil +} + +func tmtInstallRequirement(provision string) string { + if provision == ProvisionVirtual { + return "tmt[provision-virtual]" + } + + return "tmt" +} + +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 +} + +// dirsOverlap reports whether a and b are the same directory, or one contains +// the other. Both must already be absolute. +func dirsOverlap(a string, b string) bool { + return isAncestorOrSame(a, b) || isAncestorOrSame(b, a) +} + +// isAncestorOrSame reports whether path is ancestor itself, or nested under it. +func isAncestorOrSame(ancestor string, path string) bool { + ancestor = filepath.Clean(ancestor) + path = filepath.Clean(path) + + if ancestor == path { + return true + } + + rel, err := filepath.Rel(ancestor, path) + if err != nil { + return false + } + + return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +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 +} + +// preflightTMTTests validates every selected test's decoded tmt config against +// sourceDir and the provisioner before RunComponentTests prepares the TMT +// environment (venv creation, pip install) or runOneTMTTest clones metadata. +// Mirrors the equivalent checks in runOneTMTTest, run here up front so these +// deterministic input errors (missing source, missing plan for the virtual +// provisioner) surface immediately rather than after network work and +// on-disk artifacts. +func preflightTMTTests(resolved []projectconfig.ResolvedTest, sourceDir string, provision string) error { + for _, test := range resolved { + config, err := decodeTMTConfig(test.Definition.Tmt) + if err != nil { + return err + } + + if err := validateTMTTestName(test.Name); err != nil { + return err + } + + if sourceDir == "" && (config.Source.GitURL == "" || config.Source.Ref == "") { + return fmt.Errorf("test %#q: 'tmt.source' (git-url, ref) is required unless '--source-dir' is set", test.Name) + } + + if provision == ProvisionVirtual && config.Plan == "" { + return fmt.Errorf( + "test %#q: 'tmt.plan' (or '--plan') is required with '--provision virtual'; "+ + "hardware constraints are resolved per-plan", test.Name) + } + } + + 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 + } + + if settings.SourceDir == "" && (config.Source.GitURL == "" || config.Source.Ref == "") { + return fmt.Errorf("test %#q: 'tmt.source' (git-url, ref) is required unless '--source-dir' is set", test.Name) + } + + 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 + } + + // runDir is the fmf tree tmt runs against: either the freshly cloned catalog + // source (default) or a local fmf tree (--source-dir). + runDir := repoDir + if settings.SourceDir != "" { + runDir, err = resolveSourceRunDir(env, settings.SourceDir) + if err != nil { + return err + } + } else { + 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 == ProvisionVirtual { + if config.Plan == "" { + return fmt.Errorf( + "test %#q: 'tmt.plan' (or '--plan') is required with '--provision virtual'; "+ + "hardware constraints are resolved per-plan", test.Name) + } + + hardwareArgs, err = resolvedPlanHardwareArgs( + env, runDir, 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, runDir, pluginDir, settings.TMTProgramPath, settings.Provision, args...); err != nil { + return fmt.Errorf("run TMT plan %#q (artifacts: %#q):\n%w", tmtPlanDescription(config.Plan), tmtWorkDir, err) + } + + return nil +} + +// tmtPlanDescription returns a human-readable plan selection for error +// messages, since config.Plan may be empty (every enabled plan runs). +func tmtPlanDescription(plan string) string { + if plan == "" { + return "" + } + + return plan +} + +// resolveSourceRunDir validates that the given directory carries an fmf root, +// returning it as the tmt run directory. It backs the --source-dir flow, +// which runs a plan straight from a local fmf tree — the output of either +// 'azldev component render' (with 'render.skip-file-filter = true') or +// 'azldev component prepare-sources' — instead of cloning the catalog +// 'source'. tmt writes its run artifacts under a separate --workdir-root, so +// the given tree is only read. +func resolveSourceRunDir(env *azldev.Env, sourceDir string) (string, error) { + fmfVersion := filepath.Join(sourceDir, ".fmf", "version") + + // Require a regular file: fmf's version marker is a file, and a directory (or + // other node) at this path is not valid fmf metadata. + info, err := env.FS().Stat(fmfVersion) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return "", fmt.Errorf( + "no fmf metadata in %#q (missing %#q); produce it with 'azldev component render' "+ + "(with 'render.skip-file-filter = true') or 'azldev component prepare-sources' first", + sourceDir, filepath.Join(".fmf", "version")) + } + + return "", fmt.Errorf("check fmf root %#q:\n%w", fmfVersion, err) + } + + if !info.Mode().IsRegular() { + return "", fmt.Errorf( + "invalid fmf metadata in %#q: %#q must be a regular file", + sourceDir, filepath.Join(".fmf", "version")) + } + + return sourceDir, 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. repoDir is + // only ever populated by our own clone step, so this is skipped entirely for + // '--source-dir': that flow never clones into repoDir, and the caller's + // supplied tree must never be touched, even if it happens to coincide with + // (or contain) the managed repoDir path. + if settings.SourceDir == "" { + if err := removePreviousTMTRepository(env, repoDir); err != nil { + return "", fmt.Errorf("remove previous test metadata checkout:\n%w", err) + } + } + + if settings.Provision != ProvisionVirtual { + 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) + + // Omitting '--name' entirely (plan empty) makes tmt discover and run every + // enabled plan it finds — the same default Fedora's own Testing Farm uses, + // instead of a fixed guessed name. + if config.Plan != "" { + args = append(args, "plan", "--name", config.Plan) + } else { + args = append(args, "plan") + } + + args = append(args, "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 == ProvisionVirtual { + 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) { + var sourceConfig projectconfig.GitSourceConfig + + // Optional means absent, not invalid: a present-but-malformed 'source' (e.g. + // a string instead of a table) is rejected rather than silently treated the + // same as an omitted one. + if rawSourceValue, hasSource := raw["source"]; hasSource { + rawSource, ok := rawSourceValue.(map[string]any) + if !ok { + return tmtConfig{}, fmt.Errorf("'tmt.source' must be a table with 'git-url' and 'ref', got %T", rawSourceValue) + } + + gitURL, _ := rawSource["git-url"].(string) + ref, _ := rawSource["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 is optional: empty means tmt discovers and runs every enabled plan + // (see tmtConfig.Plan). As with 'source' above, a present-but-malformed + // value (not a string) is rejected rather than silently treated as absent. + var plan string + + if rawPlanValue, hasPlan := raw["plan"]; hasPlan { + planStr, ok := rawPlanValue.(string) + if !ok { + return tmtConfig{}, fmt.Errorf("'tmt.plan' must be a string, got %T", rawPlanValue) + } + + plan = planStr + if plan != "" && !strings.HasPrefix(plan, "/") { + return tmtConfig{}, errors.New("'tmt.plan' must be an absolute plan name") + } + } + + return tmtConfig{Source: tmtSource{GitURL: sourceConfig.GitURL, Ref: sourceConfig.Ref}, Plan: plan}, nil +} + +func validateTMTProvision(provision string) error { + if provision != tmtProvisionLocal && provision != ProvisionVirtual { + return fmt.Errorf("'--provision' must be either %q or %q", tmtProvisionLocal, ProvisionVirtual) + } + + 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/tmt/component_internal_test.go similarity index 85% rename from internal/app/azldev/cmds/component/test_internal_test.go rename to internal/tmt/component_internal_test.go index 449e76bf..36fc5ce1 100644 --- a/internal/app/azldev/cmds/component/test_internal_test.go +++ b/internal/tmt/component_internal_test.go @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -package component +package tmt import ( "errors" @@ -24,11 +24,6 @@ import ( const testRenderedSpecDir = "/project/specs/u/util-linux" -const ( - testImagePath = "/project/image.qcow2" - testRPMPath = "/project/component.rpm" -) - func TestDecodeTMTConfig(t *testing.T) { config, err := decodeTMTConfig(map[string]any{ "source": map[string]any{ @@ -85,7 +80,7 @@ func TestDecodeTMTConfigRejectsInvalidRefAndPlan(t *testing.T) { } func TestValidateTMTProvisionOptions(t *testing.T) { - require.NoError(t, validateTMTProvisionOptions(tmtProvisionVirtual, "/images/azure.qcow2")) + require.NoError(t, validateTMTProvisionOptions(ProvisionVirtual, "/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") @@ -93,7 +88,7 @@ func TestValidateTMTProvisionOptions(t *testing.T) { func TestTMTInstallRequirementUsesProvisionerExtra(t *testing.T) { assert.Equal(t, "tmt", tmtInstallRequirement(tmtProvisionLocal)) - assert.Equal(t, "tmt[provision-virtual]", tmtInstallRequirement(tmtProvisionVirtual)) + assert.Equal(t, "tmt[provision-virtual]", tmtInstallRequirement(ProvisionVirtual)) } func TestSelectTMTTests(t *testing.T) { @@ -190,7 +185,7 @@ func TestResolveTMTImagePath(t *testing.T) { testEnv.TestFS, "/project/out/image.qcow2", []byte("qcow2"), fileperms.PrivateFile, )) - imagePath, err := resolveTMTImagePath(testEnv.Env, tmtProvisionVirtual, "out/image.qcow2") + imagePath, err := resolveTMTImagePath(testEnv.Env, ProvisionVirtual, "out/image.qcow2") require.NoError(t, err) assert.Equal(t, "/project/out/image.qcow2", imagePath) @@ -200,7 +195,7 @@ func TestResolveTMTImagePath(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") + _, err := resolveTMTImagePath(testEnv.Env, ProvisionVirtual, "out/image.qcow2") require.ErrorContains(t, err, "must be a regular file") }) @@ -208,7 +203,7 @@ func TestResolveTMTImagePath(t *testing.T) { t.Run("rejects a missing path", func(t *testing.T) { testEnv := testutils.NewTestEnv(t) - _, err := resolveTMTImagePath(testEnv.Env, tmtProvisionVirtual, "out/image.qcow2") + _, err := resolveTMTImagePath(testEnv.Env, ProvisionVirtual, "out/image.qcow2") require.ErrorIs(t, err, fs.ErrNotExist) }) @@ -257,7 +252,7 @@ func TestPrepareTMTEnvironmentDryRunAvoidsFilesystemChanges(t *testing.T) { dryRunOptions.Interfaces = testEnv.TestInterfaces dryRunEnv := azldev.NewEnv(t.Context(), dryRunOptions) - workDir, tmtProgramPath, err := prepareTMTEnvironment(dryRunEnv, "artifacts", tmtProvisionVirtual, false) + workDir, tmtProgramPath, err := prepareTMTEnvironment(dryRunEnv, "artifacts", ProvisionVirtual, false) require.NoError(t, err) assert.Equal(t, "/project/artifacts", workDir) @@ -267,29 +262,6 @@ func TestPrepareTMTEnvironmentDryRunAvoidsFilesystemChanges(t *testing.T) { 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", "source-dir", "plan", "from-spec", - } { - assert.NotNil(t, cmd.Flags().Lookup(name), "%s flag should be registered", name) - } - - fromSpec := cmd.Flags().Lookup("from-spec") - require.NotNil(t, fromSpec) - assert.NotEmpty(t, fromSpec.Deprecated, "--from-spec should be marked deprecated") - - // 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 TestResolveSourceRunDir(t *testing.T) { t.Run("returns the source dir when an fmf root is present", func(t *testing.T) { testEnv := testutils.NewTestEnv(t) @@ -444,7 +416,7 @@ func TestEffectiveSourceDir(t *testing.T) { t.Run("returns an absolute --source-dir unchanged when --from-spec is not set", func(t *testing.T) { testEnv := newTMTTestEnv(t, nil) - sourceDir, err := effectiveSourceDir(testEnv.Env, testTMTComponentName, &ComponentTestOptions{ + sourceDir, err := effectiveSourceDir(testEnv.Env, testTMTComponentName, &Options{ SourceDir: "/project/source", }) @@ -455,7 +427,7 @@ func TestEffectiveSourceDir(t *testing.T) { t.Run("resolves a relative --source-dir against the project directory", func(t *testing.T) { testEnv := newTMTTestEnv(t, nil) - sourceDir, err := effectiveSourceDir(testEnv.Env, testTMTComponentName, &ComponentTestOptions{ + sourceDir, err := effectiveSourceDir(testEnv.Env, testTMTComponentName, &Options{ SourceDir: "prepared/pkg", }) @@ -466,7 +438,7 @@ func TestEffectiveSourceDir(t *testing.T) { t.Run("leaves --source-dir empty when neither flag is set", func(t *testing.T) { testEnv := newTMTTestEnv(t, nil) - sourceDir, err := effectiveSourceDir(testEnv.Env, testTMTComponentName, &ComponentTestOptions{}) + sourceDir, err := effectiveSourceDir(testEnv.Env, testTMTComponentName, &Options{}) require.NoError(t, err) assert.Empty(t, sourceDir) @@ -476,7 +448,7 @@ func TestEffectiveSourceDir(t *testing.T) { testEnv := newTMTTestEnv(t, nil) testEnv.Config.Project.RenderedSpecsDir = "/project/specs" - sourceDir, err := effectiveSourceDir(testEnv.Env, testTMTComponentName, &ComponentTestOptions{ + sourceDir, err := effectiveSourceDir(testEnv.Env, testTMTComponentName, &Options{ FromSpec: true, }) @@ -488,7 +460,7 @@ func TestEffectiveSourceDir(t *testing.T) { testEnv := newTMTTestEnv(t, nil) testEnv.Config.Project.RenderedSpecsDir = "/project/specs" - _, err := effectiveSourceDir(testEnv.Env, testTMTComponentName, &ComponentTestOptions{ + _, err := effectiveSourceDir(testEnv.Env, testTMTComponentName, &Options{ FromSpec: true, SourceDir: "/project/source", }) @@ -502,7 +474,7 @@ func TestEffectiveSourceDir(t *testing.T) { testEnv := newTMTTestEnv(t, nil) // Project.RenderedSpecsDir intentionally left unset. - _, err := effectiveSourceDir(testEnv.Env, testTMTComponentName, &ComponentTestOptions{FromSpec: true}) + _, err := effectiveSourceDir(testEnv.Env, testTMTComponentName, &Options{FromSpec: true}) require.Error(t, err) assert.Contains(t, err.Error(), "rendered-specs-dir") @@ -620,7 +592,7 @@ func TestPreflightTMTTests(t *testing.T) { Definition: projectconfig.TestDefinition{Type: "tmt", Tmt: map[string]any{}}, }} - err := preflightTMTTests(resolved, "/project/source", tmtProvisionVirtual) + err := preflightTMTTests(resolved, "/project/source", ProvisionVirtual) require.Error(t, err) assert.Contains(t, err.Error(), "'tmt.plan'") @@ -628,46 +600,6 @@ func TestPreflightTMTTests(t *testing.T) { }) } -func TestComponentTestCmdNoMatch(t *testing.T) { - testEnv := testutils.NewTestEnv(t) - - require.NoError(t, fileutils.WriteFile(testEnv.TestFS, testImagePath, []byte("image"), fileperms.PrivateFile)) - require.NoError(t, fileutils.WriteFile(testEnv.TestFS, testRPMPath, []byte("rpm"), fileperms.PrivateFile)) - - cmd := NewComponentTestCmd() - cmd.SetArgs([]string{"missing-component", "--image-path", testImagePath, "--rpm", testRPMPath}) - - err := cmd.ExecuteContext(testEnv.Env) - - require.Error(t, err) - assert.Contains(t, err.Error(), "component not found") -} - -// TestComponentTestCmdPreflightsBeforePreparingEnvironment proves the fix for -// the deterministic-input-error-after-side-effects issue: a catalog test -// missing 'tmt.source' (and no --source-dir) must fail before azldev creates a -// venv or runs pip install, not partway through runOneTMTTest. -func TestComponentTestCmdPreflightsBeforePreparingEnvironment(t *testing.T) { - testEnv := newTMTTestEnv(t, &projectconfig.ComponentTestsConfig{ - Tests: []projectconfig.TestRef{{Name: "ci"}}, - }) - testEnv.Config.Tests["ci"] = projectconfig.TestDefinition{Type: "tmt", Tmt: map[string]any{}} - - require.NoError(t, fileutils.WriteFile(testEnv.TestFS, testImagePath, []byte("image"), fileperms.PrivateFile)) - require.NoError(t, fileutils.WriteFile(testEnv.TestFS, testRPMPath, []byte("rpm"), fileperms.PrivateFile)) - - cmd := NewComponentTestCmd() - cmd.SetArgs([]string{ - testTMTComponentName, "--image-path", testImagePath, "--rpm", testRPMPath, "--plan", "/plans/all", - }) - - err := cmd.ExecuteContext(testEnv.Env) - - require.Error(t, err) - assert.Contains(t, err.Error(), "'tmt.source'") - assert.Empty(t, testEnv.CommandsExecuted, "no venv/pip commands should run before the preflight check") -} - func TestDirsOverlap(t *testing.T) { for name, test := range map[string]struct { a string @@ -687,32 +619,6 @@ func TestDirsOverlap(t *testing.T) { } } -// TestComponentTestCmdRejectsOverlappingSourceAndWorkDir covers the scenario -// from the review finding: '--source-dir .' from the project root with the -// default (cwd) work directory makes the managed per-test directory a child -// of the supplied source tree. -func TestComponentTestCmdRejectsOverlappingSourceAndWorkDir(t *testing.T) { - testEnv := newTMTTestEnv(t, nil) - require.NoError(t, fileutils.WriteFile( - testEnv.TestFS, filepath.Join("/project", ".fmf", "version"), []byte("1\n"), fileperms.PrivateFile, - )) - - require.NoError(t, fileutils.WriteFile(testEnv.TestFS, testImagePath, []byte("image"), fileperms.PrivateFile)) - require.NoError(t, fileutils.WriteFile(testEnv.TestFS, testRPMPath, []byte("rpm"), fileperms.PrivateFile)) - - cmd := NewComponentTestCmd() - cmd.SetArgs([]string{ - testTMTComponentName, "--image-path", testImagePath, "--rpm", testRPMPath, - "--plan", "/plans/all", "--source-dir", ".", - }) - - err := cmd.ExecuteContext(testEnv.Env) - - require.Error(t, err) - assert.Contains(t, err.Error(), "must not overlap") - assert.Empty(t, testEnv.CommandsExecuted, "no venv/pip commands should run before the overlap check") -} - func TestRemovePreviousTMTRepositoryRemovesOnlyRepository(t *testing.T) { testEnv := testutils.NewTestEnv(t) testDir := "/project/tmt-test" @@ -764,7 +670,7 @@ func TestComponentTMTArgsPreservesNativePlanSteps(t *testing.T) { args := componentTMTArgs( tmtConfig{Plan: "/plans/smoke"}, "/work/tmt", - tmtProvisionVirtual, + ProvisionVirtual, "/path/to/image.qcow2", []string{"memory >= 4 GB", "boot.method = uefi"}, []string{"/rpms/component.rpm", "/rpms/component-tests.rpm"}, @@ -789,7 +695,7 @@ func TestComponentTMTArgsVirtualProvisioner(t *testing.T) { args := componentTMTArgs( tmtConfig{Plan: "/plans/smoke"}, "/work/tmt", - tmtProvisionVirtual, + ProvisionVirtual, "/path/to/image.qcow2", []string{"boot.method = uefi"}, []string{"/rpms/component.rpm"}, @@ -875,7 +781,7 @@ func TestTMTCommandEnv(t *testing.T) { }, "virtual provisioner omits the acknowledgement": { pluginDir: "/work/test/tmt-plugins", - provision: tmtProvisionVirtual, + provision: ProvisionVirtual, expected: []string{"TMT_PLUGINS=/work/test/tmt-plugins", "TMT_BOOT_TIMEOUT=300"}, }, "explicit boot timeout is preserved": { diff --git a/internal/app/azldev/cmds/component/requirements.txt b/internal/tmt/requirements.txt similarity index 100% rename from internal/app/azldev/cmds/component/requirements.txt rename to internal/tmt/requirements.txt diff --git a/scenario/tmt_virtual_e2e_test.go b/scenario/tmt_virtual_e2e_test.go index 8b6dd293..4aab109c 100644 --- a/scenario/tmt_virtual_e2e_test.go +++ b/scenario/tmt_virtual_e2e_test.go @@ -78,7 +78,7 @@ func TestTMTVirtualProvisioning(t *testing.T) { test.AddDirRecursive(t, "fixture", filepath.Join("testdata", "tmt-virtual")) requirementsPath, err := filepath.Abs(filepath.Join( - "..", "internal", "app", "azldev", "cmds", "component", "requirements.txt", + "..", "internal", "tmt", "requirements.txt", )) require.NoError(t, err) test.AddSingleFile("tmt-requirements.txt", requirementsPath)