Skip to content

Commit 9d33ed3

Browse files
committed
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/<x>/<name>/) 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.
1 parent c51ddcb commit 9d33ed3

3 files changed

Lines changed: 186 additions & 34 deletions

File tree

docs/user/reference/cli/azldev_component_test.md

Lines changed: 4 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/app/azldev/cmds/component/test.go

Lines changed: 97 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ type ComponentTestOptions struct {
3434
Tests []string
3535
WorkDir string
3636
Provision string
37+
FromSpec bool
3738
}
3839

3940
type tmtSource struct {
@@ -58,6 +59,10 @@ type tmtRunSettings struct {
5859
WorkDir string
5960
TMTProgramPath string
6061
Provision string
62+
// FromSpec runs the plan from the component's rendered spec directory
63+
// instead of cloning the catalog 'source'. SpecDir is that directory.
64+
FromSpec bool
65+
SpecDir string
6166
}
6267

6368
const (
@@ -142,8 +147,9 @@ AZURE LINUX 4 PREREQUISITES:
142147
supplied candidate RPMs and execute the plan, so it modifies the host.
143148
144149
azldev creates or reuses an isolated Python environment under --work-dir and
145-
installs TMT with virtual-provisioner support there. python3 and git must be
146-
available on the host.`,
150+
installs TMT with virtual-provisioner support there. python3 must be available
151+
on the host; git is also required unless '--from-spec' is used (which runs the
152+
plan from the rendered spec directory instead of cloning).`,
147153
Example: ` # Build the component first
148154
azldev component build buildah
149155
@@ -161,6 +167,12 @@ available on the host.`,
161167
}),
162168
}
163169

170+
registerComponentTestFlags(cmd, options)
171+
172+
return cmd
173+
}
174+
175+
func registerComponentTestFlags(cmd *cobra.Command, options *ComponentTestOptions) {
164176
cmd.Flags().StringVarP(&options.ImagePath, "image-path", "i", "", "Path to the qcow2 image under test")
165177
_ = cmd.MarkFlagFilename("image-path")
166178
cmd.Flags().StringSliceVarP(
@@ -177,9 +189,12 @@ available on the host.`,
177189
_ = cmd.MarkFlagDirname("work-dir")
178190
cmd.Flags().StringVar(&options.Provision, "provision", tmtProvisionVirtual,
179191
"TMT provisioner mode: 'virtual' (default) runs tests in QEMU; 'local' runs on this machine (must be Azure Linux 4)")
192+
cmd.Flags().BoolVar(&options.FromSpec, "from-spec", false,
193+
"Run the plan from the component's rendered spec directory (under the configured "+
194+
"'project.rendered-specs-dir', e.g. 'SPECS/c/curl') instead of cloning the catalog "+
195+
"'source'. Requires 'render.skip-file-filter = true' and a prior "+
196+
"'azldev component render'. Local inner-loop convenience; not used by cloud (TEE) runs.")
180197
_ = cmd.MarkFlagRequired("rpm")
181-
182-
return cmd
183198
}
184199

185200
func runComponentTMTTests(env *azldev.Env, componentName string, options *ComponentTestOptions) error {
@@ -197,12 +212,12 @@ func runComponentTMTTests(env *azldev.Env, componentName string, options *Compon
197212
return err
198213
}
199214

200-
resolved, err := resolveComponentTMTTests(env, componentName, options.Tests)
215+
resolved, specDir, err := resolveComponentTMTTests(env, componentName, options.Tests)
201216
if err != nil {
202217
return err
203218
}
204219

205-
workDir, tmtProgramPath, err := prepareTMTEnvironment(env, options.WorkDir, options.Provision)
220+
workDir, tmtProgramPath, err := prepareTMTEnvironment(env, options.WorkDir, options.Provision, options.FromSpec)
206221
if err != nil {
207222
return err
208223
}
@@ -213,6 +228,8 @@ func runComponentTMTTests(env *azldev.Env, componentName string, options *Compon
213228
WorkDir: workDir,
214229
TMTProgramPath: tmtProgramPath,
215230
Provision: options.Provision,
231+
FromSpec: options.FromSpec,
232+
SpecDir: specDir,
216233
}
217234

218235
for _, test := range resolved {
@@ -275,32 +292,36 @@ func resolveTMTImagePath(env *azldev.Env, provision string, configuredImagePath
275292

276293
func resolveComponentTMTTests(
277294
env *azldev.Env, componentName string, selectors []string,
278-
) ([]projectconfig.ResolvedTest, error) {
295+
) ([]projectconfig.ResolvedTest, string, error) {
279296
resolver := components.NewResolver(env)
280297

281298
set, err := resolver.FindComponents(&components.ComponentFilter{ComponentNamePatterns: []string{componentName}})
282299
if err != nil {
283-
return nil, fmt.Errorf("resolve component %#q:\n%w", componentName, err)
300+
return nil, "", fmt.Errorf("resolve component %#q:\n%w", componentName, err)
284301
}
285302

286303
if set.Len() != 1 {
287-
return nil, fmt.Errorf("expected exactly one component named %#q, found %d", componentName, set.Len())
304+
return nil, "", fmt.Errorf("expected exactly one component named %#q, found %d", componentName, set.Len())
288305
}
289306

290-
resolved, err := env.Config().ResolveComponentTests(set.Components()[0].GetConfig())
307+
componentConfig := set.Components()[0].GetConfig()
308+
309+
resolved, err := env.Config().ResolveComponentTests(componentConfig)
291310
if err != nil {
292-
return nil, fmt.Errorf("resolve tests for component %#q:\n%w", componentName, err)
311+
return nil, "", fmt.Errorf("resolve tests for component %#q:\n%w", componentName, err)
293312
}
294313

295314
resolved = selectTMTTests(resolved, selectors)
296315
if len(resolved) == 0 {
297-
return nil, fmt.Errorf("component %#q has no selected TMT tests", componentName)
316+
return nil, "", fmt.Errorf("component %#q has no selected TMT tests", componentName)
298317
}
299318

300-
return resolved, nil
319+
return resolved, componentConfig.RenderedSpecDir, nil
301320
}
302321

303-
func prepareTMTEnvironment(env *azldev.Env, configuredWorkDir string, provision string) (string, string, error) {
322+
func prepareTMTEnvironment(
323+
env *azldev.Env, configuredWorkDir string, provision string, fromSpec bool,
324+
) (string, string, error) {
304325
workDir, err := componentTMTWorkDir(env, configuredWorkDir)
305326
if err != nil {
306327
return "", "", fmt.Errorf("resolve work directory:\n%w", err)
@@ -315,7 +336,7 @@ func prepareTMTEnvironment(env *azldev.Env, configuredWorkDir string, provision
315336
return "", "", fmt.Errorf("create work directory:\n%w", err)
316337
}
317338

318-
tmtProgramPath, err = ensureTMTVenv(env, workDir, provision)
339+
tmtProgramPath, err = ensureTMTVenv(env, workDir, provision, fromSpec)
319340
if err != nil {
320341
return "", "", err
321342
}
@@ -324,18 +345,23 @@ func prepareTMTEnvironment(env *azldev.Env, configuredWorkDir string, provision
324345
}
325346

326347
// ensureTMTVenv creates or reuses an isolated TMT installation. This follows
327-
// the local LISA runner pattern: Python and git are explicit host
328-
// prerequisites, while the test framework itself is installed in a venv under
329-
// the selected work directory rather than assumed to be packaged by the host
330-
// distribution. For virtual provisioning, the testcloud plugin supplies
331-
// provisioner support. For local provisioning, only base TMT is required.
332-
func ensureTMTVenv(env *azldev.Env, workDir string, provision string) (string, error) {
348+
// the local LISA runner pattern: Python (and git, unless --from-spec avoids
349+
// cloning) are explicit host prerequisites, while the test framework itself is
350+
// installed in a venv under the selected work directory rather than assumed to
351+
// be packaged by the host distribution. For virtual provisioning, the testcloud
352+
// plugin supplies provisioner support. For local provisioning, only base TMT is
353+
// required.
354+
func ensureTMTVenv(env *azldev.Env, workDir string, provision string, fromSpec bool) (string, error) {
333355
if err := prereqs.RequireExecutable(env, tmtPythonProgram, nil); err != nil {
334356
return "", fmt.Errorf("python3 is required to run TMT tests:\n%w", err)
335357
}
336358

337-
if err := prereqs.RequireExecutable(env, "git", nil); err != nil {
338-
return "", fmt.Errorf("git is required to clone TMT test metadata:\n%w", err)
359+
// --from-spec runs the plan from the rendered spec directory and never clones,
360+
// so git is only a prerequisite for the default (clone) path.
361+
if !fromSpec {
362+
if err := prereqs.RequireExecutable(env, "git", nil); err != nil {
363+
return "", fmt.Errorf("git is required to clone TMT test metadata:\n%w", err)
364+
}
339365
}
340366

341367
venvDir := filepath.Join(workDir, "tmt", tmtVenvDirName)
@@ -468,18 +494,28 @@ func runOneTMTTest(env *azldev.Env, test projectconfig.ResolvedTest, settings tm
468494
return err
469495
}
470496

471-
if err := runHostCommand(env, testDir, "git", "clone", "--no-checkout", config.Source.GitURL, repoDir); err != nil {
472-
return fmt.Errorf("clone test metadata:\n%w", err)
473-
}
497+
// runDir is the fmf tree tmt runs against: either the freshly cloned catalog
498+
// source (default) or the component's rendered spec directory (--from-spec).
499+
runDir := repoDir
500+
if settings.FromSpec {
501+
runDir, err = resolveSpecRunDir(env, settings.SpecDir)
502+
if err != nil {
503+
return err
504+
}
505+
} else {
506+
if err := runHostCommand(env, testDir, "git", "clone", "--no-checkout", config.Source.GitURL, repoDir); err != nil {
507+
return fmt.Errorf("clone test metadata:\n%w", err)
508+
}
474509

475-
if err := runHostCommand(env, repoDir, "git", "checkout", "--detach", config.Source.Ref); err != nil {
476-
return fmt.Errorf("checkout test metadata:\n%w", err)
510+
if err := runHostCommand(env, repoDir, "git", "checkout", "--detach", config.Source.Ref); err != nil {
511+
return fmt.Errorf("checkout test metadata:\n%w", err)
512+
}
477513
}
478514

479515
var hardwareArgs []string
480516
if settings.Provision == tmtProvisionVirtual {
481517
hardwareArgs, err = resolvedPlanHardwareArgs(
482-
env, repoDir, settings.TMTProgramPath, config.Plan,
518+
env, runDir, settings.TMTProgramPath, config.Plan,
483519
)
484520
if err != nil {
485521
return fmt.Errorf("resolve hardware for TMT plan %#q:\n%w", config.Plan, err)
@@ -488,13 +524,44 @@ func runOneTMTTest(env *azldev.Env, test projectconfig.ResolvedTest, settings tm
488524

489525
args := componentTMTArgs(config, tmtWorkDir, settings.Provision, settings.ImagePath, hardwareArgs, settings.RPMs)
490526

491-
if err := runTMTCommand(env, repoDir, pluginDir, settings.TMTProgramPath, settings.Provision, args...); err != nil {
527+
if err := runTMTCommand(env, runDir, pluginDir, settings.TMTProgramPath, settings.Provision, args...); err != nil {
492528
return fmt.Errorf("run TMT plan %#q (artifacts: %#q):\n%w", config.Plan, tmtWorkDir, err)
493529
}
494530

495531
return nil
496532
}
497533

534+
// resolveSpecRunDir validates that the component's rendered spec directory
535+
// exists and carries an fmf root, returning it as the tmt run directory. It
536+
// backs the --from-spec flow, which runs a plan straight from the component's
537+
// rendered spec directory (derived from the project 'rendered-specs-dir'
538+
// setting, e.g. 'SPECS/c/curl') instead of cloning the catalog 'source'. tmt
539+
// writes its run artifacts under a separate --workdir-root, so the rendered
540+
// tree is only read.
541+
func resolveSpecRunDir(env *azldev.Env, specDir string) (string, error) {
542+
if specDir == "" {
543+
return "", errors.New(
544+
"'--from-spec' requires a rendered spec directory; ensure 'project.rendered-specs-dir' is set")
545+
}
546+
547+
fmfVersion := filepath.Join(specDir, ".fmf", "version")
548+
549+
exists, err := fileutils.Exists(env.FS(), fmfVersion)
550+
if err != nil {
551+
return "", fmt.Errorf("check fmf root %#q:\n%w", fmfVersion, err)
552+
}
553+
554+
if !exists {
555+
return "", fmt.Errorf(
556+
"no fmf metadata in rendered spec dir %#q (missing %#q); set "+
557+
"'render.skip-file-filter = true' on the component and run "+
558+
"'azldev component render' before using '--from-spec'",
559+
specDir, filepath.Join(".fmf", "version"))
560+
}
561+
562+
return specDir, nil
563+
}
564+
498565
// prepareTMTTestDir creates the per-test directory and, for virtual runs, the
499566
// testcloud plugin. It returns the plugin directory, which is empty when no
500567
// plugin is needed.

internal/app/azldev/cmds/component/test_internal_test.go

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import (
2121
"github.com/stretchr/testify/require"
2222
)
2323

24+
const testRenderedSpecDir = "/project/specs/u/util-linux"
25+
2426
func TestDecodeTMTConfig(t *testing.T) {
2527
config, err := decodeTMTConfig(map[string]any{
2628
"source": map[string]any{
@@ -246,7 +248,7 @@ func TestPrepareTMTEnvironmentDryRunAvoidsFilesystemChanges(t *testing.T) {
246248
dryRunOptions.Interfaces = testEnv.TestInterfaces
247249
dryRunEnv := azldev.NewEnv(t.Context(), dryRunOptions)
248250

249-
workDir, tmtProgramPath, err := prepareTMTEnvironment(dryRunEnv, "artifacts", tmtProvisionVirtual)
251+
workDir, tmtProgramPath, err := prepareTMTEnvironment(dryRunEnv, "artifacts", tmtProvisionVirtual, false)
250252

251253
require.NoError(t, err)
252254
assert.Equal(t, "/project/artifacts", workDir)
@@ -264,7 +266,7 @@ func TestNewComponentTestCmd(t *testing.T) {
264266
assert.NotNil(t, cmd.RunE)
265267

266268
for _, name := range []string{
267-
"image-path", "rpm", "test", "work-dir", "provision",
269+
"image-path", "rpm", "test", "work-dir", "provision", "from-spec",
268270
} {
269271
assert.NotNil(t, cmd.Flags().Lookup(name), "%s flag should be registered", name)
270272
}
@@ -275,6 +277,87 @@ func TestNewComponentTestCmd(t *testing.T) {
275277
}
276278
}
277279

280+
func TestResolveSpecRunDir(t *testing.T) {
281+
t.Run("returns the spec dir when an fmf root is present", func(t *testing.T) {
282+
testEnv := testutils.NewTestEnv(t)
283+
specDir := testRenderedSpecDir
284+
require.NoError(t, fileutils.WriteFile(
285+
testEnv.TestFS, filepath.Join(specDir, ".fmf", "version"), []byte("1\n"), fileperms.PrivateFile,
286+
))
287+
288+
runDir, err := resolveSpecRunDir(testEnv.Env, specDir)
289+
290+
require.NoError(t, err)
291+
assert.Equal(t, specDir, runDir)
292+
})
293+
294+
t.Run("rejects a spec dir without an fmf root", func(t *testing.T) {
295+
testEnv := testutils.NewTestEnv(t)
296+
specDir := testRenderedSpecDir
297+
require.NoError(t, testEnv.TestFS.MkdirAll(specDir, fileperms.PublicDir))
298+
299+
_, err := resolveSpecRunDir(testEnv.Env, specDir)
300+
301+
require.ErrorContains(t, err, "no fmf metadata")
302+
assert.ErrorContains(t, err, "skip-file-filter")
303+
})
304+
305+
t.Run("rejects an empty spec dir", func(t *testing.T) {
306+
testEnv := testutils.NewTestEnv(t)
307+
308+
_, err := resolveSpecRunDir(testEnv.Env, "")
309+
310+
require.ErrorContains(t, err, "rendered-specs-dir")
311+
})
312+
}
313+
314+
func TestRunOneTMTTestFromSpecDoesNotClone(t *testing.T) {
315+
testEnv := testutils.NewTestEnv(t)
316+
317+
specDir := testRenderedSpecDir
318+
require.NoError(t, fileutils.WriteFile(
319+
testEnv.TestFS, filepath.Join(specDir, ".fmf", "version"), []byte("1\n"), fileperms.PrivateFile,
320+
))
321+
322+
test := projectconfig.ResolvedTest{
323+
Name: "tmt-util-linux-ci",
324+
Definition: projectconfig.TestDefinition{
325+
Type: "tmt",
326+
Tmt: map[string]any{
327+
"source": map[string]any{
328+
"git-url": "https://example.test/util-linux.git",
329+
"ref": "0123456789012345678901234567890123456789",
330+
},
331+
"plan": "/plans/ci",
332+
},
333+
},
334+
}
335+
336+
settings := tmtRunSettings{
337+
WorkDir: "/project/work",
338+
TMTProgramPath: "/project/work/tmt/venv/bin/tmt",
339+
Provision: tmtProvisionLocal,
340+
FromSpec: true,
341+
SpecDir: specDir,
342+
}
343+
344+
require.NoError(t, runOneTMTTest(testEnv.Env, test, settings))
345+
346+
ranTMT := false
347+
348+
for _, args := range testEnv.CommandsExecuted {
349+
require.NotEmpty(t, args)
350+
assert.NotEqual(t, "git", filepath.Base(args[0]),
351+
"--from-spec must not invoke git, but ran: %v", args)
352+
353+
if filepath.Base(args[0]) == tmtProgram {
354+
ranTMT = true
355+
}
356+
}
357+
358+
assert.True(t, ranTMT, "expected the tmt run command to be invoked from the spec dir")
359+
}
360+
278361
func TestComponentTestCmdNoMatch(t *testing.T) {
279362
testEnv := testutils.NewTestEnv(t)
280363
imagePath := "/project/image.qcow2"

0 commit comments

Comments
 (0)