From 0b70a10e961397f6f6de9b8d5d6beb12340284c8 Mon Sep 17 00:00:00 2001 From: bhagyapathak Date: Tue, 8 Sep 2026 20:32:13 +0530 Subject: [PATCH] feat(component test): add --from-spec to run tmt from the rendered spec dir Add a --from-spec flag to 'azldev component test' that runs the mapped tmt plan directly from the component's rendered spec directory (specs///) instead of cloning the catalog source@ref. Requires render.skip-file-filter = true and a prior 'azldev component render' so the loose fmf tree is preserved. Local inner-loop convenience; cloud/TEE runs still clone. --- .../reference/cli/azldev_component_test.md | 9 +- internal/app/azldev/cmds/component/test.go | 147 ++++++++++++++---- .../cmds/component/test_internal_test.go | 97 +++++++++++- 3 files changed, 219 insertions(+), 34 deletions(-) diff --git a/docs/user/reference/cli/azldev_component_test.md b/docs/user/reference/cli/azldev_component_test.md index 9f45d8fd..16df683a 100644 --- a/docs/user/reference/cli/azldev_component_test.md +++ b/docs/user/reference/cli/azldev_component_test.md @@ -36,13 +36,17 @@ AZURE LINUX 4 PREREQUISITES: To run with --provision local, install the host dependencies: sudo tdnf install -y python3 python3-pip git sudo + ('git' is only needed for the default clone path; omit it when using + '--from-spec', which runs from the rendered spec directory.) + azldev creates a per-work-directory Python environment and installs the pinned TMT version there. The local provisioner uses sudo to install the supplied candidate RPMs and execute the plan, so it modifies the host. azldev creates or reuses an isolated Python environment under --work-dir and -installs TMT with virtual-provisioner support there. python3 and git must be -available on the host. +installs TMT with virtual-provisioner support there. python3 must be available +on the host; git is also required unless '--from-spec' is used (which runs the +plan from the rendered spec directory instead of cloning). ``` azldev component test COMPONENT [flags] @@ -67,6 +71,7 @@ azldev component test COMPONENT [flags] ### Options ``` + --from-spec Run the plan from the component's rendered spec directory (under the configured 'project.rendered-specs-dir', e.g. 'SPECS/c/curl') instead of cloning the catalog 'source'. Requires 'render.skip-file-filter = true' and a prior 'azldev component render'. Local inner-loop convenience; not used by cloud (TEE) runs. -h, --help help for test -i, --image-path string Path to the qcow2 image under test --provision string TMT provisioner mode: 'virtual' (default) runs tests in QEMU; 'local' runs on this machine (must be Azure Linux 4) (default "virtual") diff --git a/internal/app/azldev/cmds/component/test.go b/internal/app/azldev/cmds/component/test.go index 28327345..1d08da9c 100644 --- a/internal/app/azldev/cmds/component/test.go +++ b/internal/app/azldev/cmds/component/test.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "io" + "io/fs" "log/slog" "os" "os/exec" @@ -34,6 +35,7 @@ type ComponentTestOptions struct { Tests []string WorkDir string Provision string + FromSpec bool } type tmtSource struct { @@ -58,6 +60,10 @@ type tmtRunSettings struct { WorkDir string TMTProgramPath string Provision string + // FromSpec runs the plan from the component's rendered spec directory + // instead of cloning the catalog 'source'. SpecDir is that directory. + FromSpec bool + SpecDir string } const ( @@ -137,13 +143,17 @@ AZURE LINUX 4 PREREQUISITES: To run with --provision local, install the host dependencies: sudo tdnf install -y python3 python3-pip git sudo + ('git' is only needed for the default clone path; omit it when using + '--from-spec', which runs from the rendered spec directory.) + azldev creates a per-work-directory Python environment and installs the pinned TMT version there. The local provisioner uses sudo to install the supplied candidate RPMs and execute the plan, so it modifies the host. azldev creates or reuses an isolated Python environment under --work-dir and -installs TMT with virtual-provisioner support there. python3 and git must be -available on the host.`, +installs TMT with virtual-provisioner support there. python3 must be available +on the host; git is also required unless '--from-spec' is used (which runs the +plan from the rendered spec directory instead of cloning).`, Example: ` # Build the component first azldev component build buildah @@ -161,6 +171,12 @@ available on the host.`, }), } + registerComponentTestFlags(cmd, options) + + return cmd +} + +func registerComponentTestFlags(cmd *cobra.Command, options *ComponentTestOptions) { cmd.Flags().StringVarP(&options.ImagePath, "image-path", "i", "", "Path to the qcow2 image under test") _ = cmd.MarkFlagFilename("image-path") cmd.Flags().StringSliceVarP( @@ -177,9 +193,12 @@ available on the host.`, _ = cmd.MarkFlagDirname("work-dir") cmd.Flags().StringVar(&options.Provision, "provision", tmtProvisionVirtual, "TMT provisioner mode: 'virtual' (default) runs tests in QEMU; 'local' runs on this machine (must be Azure Linux 4)") + cmd.Flags().BoolVar(&options.FromSpec, "from-spec", false, + "Run the plan from the component's rendered spec directory (under the configured "+ + "'project.rendered-specs-dir', e.g. 'SPECS/c/curl') instead of cloning the catalog "+ + "'source'. Requires 'render.skip-file-filter = true' and a prior "+ + "'azldev component render'. Local inner-loop convenience; not used by cloud (TEE) runs.") _ = cmd.MarkFlagRequired("rpm") - - return cmd } func runComponentTMTTests(env *azldev.Env, componentName string, options *ComponentTestOptions) error { @@ -197,12 +216,20 @@ func runComponentTMTTests(env *azldev.Env, componentName string, options *Compon return err } - resolved, err := resolveComponentTMTTests(env, componentName, options.Tests) + resolved, specDir, err := resolveComponentTMTTests(env, componentName, options.Tests) if err != nil { return err } - workDir, tmtProgramPath, err := prepareTMTEnvironment(env, options.WorkDir, options.Provision) + // Validate the rendered spec dir up front so '--from-spec' fails fast, before + // prepareTMTEnvironment creates a venv and pip-installs TMT under --work-dir. + if options.FromSpec { + if _, err := resolveSpecRunDir(env, specDir); err != nil { + return err + } + } + + workDir, tmtProgramPath, err := prepareTMTEnvironment(env, options.WorkDir, options.Provision, options.FromSpec) if err != nil { return err } @@ -213,6 +240,8 @@ func runComponentTMTTests(env *azldev.Env, componentName string, options *Compon WorkDir: workDir, TMTProgramPath: tmtProgramPath, Provision: options.Provision, + FromSpec: options.FromSpec, + SpecDir: specDir, } for _, test := range resolved { @@ -275,32 +304,36 @@ func resolveTMTImagePath(env *azldev.Env, provision string, configuredImagePath func resolveComponentTMTTests( env *azldev.Env, componentName string, selectors []string, -) ([]projectconfig.ResolvedTest, error) { +) ([]projectconfig.ResolvedTest, string, 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) + 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()) + return nil, "", fmt.Errorf("expected exactly one component named %#q, found %d", componentName, set.Len()) } - resolved, err := env.Config().ResolveComponentTests(set.Components()[0].GetConfig()) + componentConfig := set.Components()[0].GetConfig() + + resolved, err := env.Config().ResolveComponentTests(componentConfig) if err != nil { - return nil, fmt.Errorf("resolve tests for component %#q:\n%w", componentName, err) + return nil, "", fmt.Errorf("resolve tests for component %#q:\n%w", componentName, err) } resolved = selectTMTTests(resolved, selectors) if len(resolved) == 0 { - return nil, fmt.Errorf("component %#q has no selected TMT tests", componentName) + return nil, "", fmt.Errorf("component %#q has no selected TMT tests", componentName) } - return resolved, nil + return resolved, componentConfig.RenderedSpecDir, nil } -func prepareTMTEnvironment(env *azldev.Env, configuredWorkDir string, provision string) (string, string, error) { +func prepareTMTEnvironment( + env *azldev.Env, configuredWorkDir string, provision string, fromSpec bool, +) (string, string, error) { workDir, err := componentTMTWorkDir(env, configuredWorkDir) if err != nil { return "", "", fmt.Errorf("resolve work directory:\n%w", err) @@ -315,7 +348,7 @@ func prepareTMTEnvironment(env *azldev.Env, configuredWorkDir string, provision return "", "", fmt.Errorf("create work directory:\n%w", err) } - tmtProgramPath, err = ensureTMTVenv(env, workDir, provision) + tmtProgramPath, err = ensureTMTVenv(env, workDir, provision, fromSpec) if err != nil { return "", "", err } @@ -324,18 +357,23 @@ func prepareTMTEnvironment(env *azldev.Env, configuredWorkDir string, provision } // ensureTMTVenv creates or reuses an isolated TMT installation. This follows -// the local LISA runner pattern: Python and git are explicit host -// prerequisites, while the test framework itself is installed in a venv under -// the selected work directory rather than assumed to be packaged by the host -// distribution. For virtual provisioning, the testcloud plugin supplies -// provisioner support. For local provisioning, only base TMT is required. -func ensureTMTVenv(env *azldev.Env, workDir string, provision string) (string, error) { +// the local LISA runner pattern: Python (and git, unless --from-spec 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, fromSpec 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) } - if err := prereqs.RequireExecutable(env, "git", nil); err != nil { - return "", fmt.Errorf("git is required to clone TMT test metadata:\n%w", err) + // --from-spec runs the plan from the rendered spec directory and never clones, + // so git is only a prerequisite for the default (clone) path. + if !fromSpec { + 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) @@ -468,18 +506,28 @@ func runOneTMTTest(env *azldev.Env, test projectconfig.ResolvedTest, settings tm return err } - if err := runHostCommand(env, testDir, "git", "clone", "--no-checkout", config.Source.GitURL, repoDir); err != nil { - return fmt.Errorf("clone test metadata:\n%w", err) - } + // runDir is the fmf tree tmt runs against: either the freshly cloned catalog + // source (default) or the component's rendered spec directory (--from-spec). + runDir := repoDir + if settings.FromSpec { + runDir, err = resolveSpecRunDir(env, settings.SpecDir) + 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) + if err := runHostCommand(env, repoDir, "git", "checkout", "--detach", config.Source.Ref); err != nil { + return fmt.Errorf("checkout test metadata:\n%w", err) + } } var hardwareArgs []string if settings.Provision == tmtProvisionVirtual { hardwareArgs, err = resolvedPlanHardwareArgs( - env, repoDir, settings.TMTProgramPath, config.Plan, + env, runDir, settings.TMTProgramPath, config.Plan, ) if err != nil { return fmt.Errorf("resolve hardware for TMT plan %#q:\n%w", config.Plan, err) @@ -488,13 +536,52 @@ func runOneTMTTest(env *azldev.Env, test projectconfig.ResolvedTest, settings tm args := componentTMTArgs(config, tmtWorkDir, settings.Provision, settings.ImagePath, hardwareArgs, settings.RPMs) - if err := runTMTCommand(env, repoDir, pluginDir, settings.TMTProgramPath, settings.Provision, args...); err != nil { + if err := runTMTCommand(env, runDir, pluginDir, settings.TMTProgramPath, settings.Provision, args...); err != nil { return fmt.Errorf("run TMT plan %#q (artifacts: %#q):\n%w", config.Plan, tmtWorkDir, err) } return nil } +// resolveSpecRunDir validates that the component's rendered spec directory +// exists and carries an fmf root, returning it as the tmt run directory. It +// backs the --from-spec flow, which runs a plan straight from the component's +// rendered spec directory (derived from the project 'rendered-specs-dir' +// setting, e.g. 'SPECS/c/curl') instead of cloning the catalog 'source'. tmt +// writes its run artifacts under a separate --workdir-root, so the rendered +// tree is only read. +func resolveSpecRunDir(env *azldev.Env, specDir string) (string, error) { + if specDir == "" { + return "", errors.New( + "'--from-spec' requires a rendered spec directory; ensure 'project.rendered-specs-dir' is set") + } + + fmfVersion := filepath.Join(specDir, ".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 rendered spec dir %#q (missing %#q); set "+ + "'render.skip-file-filter = true' on the component and run "+ + "'azldev component render' before using '--from-spec'", + specDir, 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 rendered spec dir %#q: %#q must be a regular file", + specDir, filepath.Join(".fmf", "version")) + } + + return specDir, 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. diff --git a/internal/app/azldev/cmds/component/test_internal_test.go b/internal/app/azldev/cmds/component/test_internal_test.go index 03f95c6f..5673fd1b 100644 --- a/internal/app/azldev/cmds/component/test_internal_test.go +++ b/internal/app/azldev/cmds/component/test_internal_test.go @@ -21,6 +21,8 @@ import ( "github.com/stretchr/testify/require" ) +const testRenderedSpecDir = "/project/specs/u/util-linux" + func TestDecodeTMTConfig(t *testing.T) { config, err := decodeTMTConfig(map[string]any{ "source": map[string]any{ @@ -246,7 +248,7 @@ func TestPrepareTMTEnvironmentDryRunAvoidsFilesystemChanges(t *testing.T) { dryRunOptions.Interfaces = testEnv.TestInterfaces dryRunEnv := azldev.NewEnv(t.Context(), dryRunOptions) - workDir, tmtProgramPath, err := prepareTMTEnvironment(dryRunEnv, "artifacts", tmtProvisionVirtual) + workDir, tmtProgramPath, err := prepareTMTEnvironment(dryRunEnv, "artifacts", tmtProvisionVirtual, false) require.NoError(t, err) assert.Equal(t, "/project/artifacts", workDir) @@ -264,7 +266,7 @@ func TestNewComponentTestCmd(t *testing.T) { assert.NotNil(t, cmd.RunE) for _, name := range []string{ - "image-path", "rpm", "test", "work-dir", "provision", + "image-path", "rpm", "test", "work-dir", "provision", "from-spec", } { assert.NotNil(t, cmd.Flags().Lookup(name), "%s flag should be registered", name) } @@ -275,6 +277,97 @@ func TestNewComponentTestCmd(t *testing.T) { } } +func TestResolveSpecRunDir(t *testing.T) { + t.Run("returns the spec dir when an fmf root is present", func(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + specDir := testRenderedSpecDir + require.NoError(t, fileutils.WriteFile( + testEnv.TestFS, filepath.Join(specDir, ".fmf", "version"), []byte("1\n"), fileperms.PrivateFile, + )) + + runDir, err := resolveSpecRunDir(testEnv.Env, specDir) + + require.NoError(t, err) + assert.Equal(t, specDir, runDir) + }) + + t.Run("rejects a spec dir without an fmf root", func(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + specDir := testRenderedSpecDir + require.NoError(t, testEnv.TestFS.MkdirAll(specDir, fileperms.PublicDir)) + + _, err := resolveSpecRunDir(testEnv.Env, specDir) + + require.ErrorContains(t, err, "no fmf metadata") + assert.ErrorContains(t, err, "skip-file-filter") + }) + + t.Run("rejects a non-regular fmf version marker", func(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + specDir := testRenderedSpecDir + require.NoError(t, testEnv.TestFS.MkdirAll(filepath.Join(specDir, ".fmf", "version"), fileperms.PublicDir)) + + _, err := resolveSpecRunDir(testEnv.Env, specDir) + + require.ErrorContains(t, err, "must be a regular file") + }) + + t.Run("rejects an empty spec dir", func(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + + _, err := resolveSpecRunDir(testEnv.Env, "") + + require.ErrorContains(t, err, "rendered-specs-dir") + }) +} + +func TestRunOneTMTTestFromSpecDoesNotClone(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + + specDir := testRenderedSpecDir + require.NoError(t, fileutils.WriteFile( + testEnv.TestFS, filepath.Join(specDir, ".fmf", "version"), []byte("1\n"), fileperms.PrivateFile, + )) + + test := projectconfig.ResolvedTest{ + Name: "tmt-util-linux-ci", + Definition: projectconfig.TestDefinition{ + Type: "tmt", + Tmt: map[string]any{ + "source": map[string]any{ + "git-url": "https://example.test/util-linux.git", + "ref": "0123456789012345678901234567890123456789", + }, + "plan": "/plans/ci", + }, + }, + } + + settings := tmtRunSettings{ + WorkDir: "/project/work", + TMTProgramPath: "/project/work/tmt/venv/bin/tmt", + Provision: tmtProvisionLocal, + FromSpec: true, + SpecDir: specDir, + } + + require.NoError(t, runOneTMTTest(testEnv.Env, test, settings)) + + ranTMT := false + + for _, args := range testEnv.CommandsExecuted { + require.NotEmpty(t, args) + assert.NotEqual(t, "git", filepath.Base(args[0]), + "--from-spec must not invoke git, but ran: %v", args) + + if filepath.Base(args[0]) == tmtProgram { + ranTMT = true + } + } + + assert.True(t, ranTMT, "expected the tmt run command to be invoked from the spec dir") +} + func TestComponentTestCmdNoMatch(t *testing.T) { testEnv := testutils.NewTestEnv(t) imagePath := "/project/image.qcow2"