From 8ab6361cc8fc8a3ec634dbd5440b7093276c2d57 Mon Sep 17 00:00:00 2001 From: bhagyapathak Date: Wed, 1 Jul 2026 17:19:45 +0530 Subject: [PATCH 01/20] feat(projectconfig): accept [tests] and [test-groups] schema in config files --- docs/user/reference/config/components.md | 20 ++ docs/user/reference/config/config-file.md | 2 + docs/user/reference/config/images.md | 4 +- docs/user/reference/config/tests.md | 92 +++++++ go.mod | 2 +- internal/app/azldev/cmds/image/list.go | 2 +- internal/app/azldev/cmds/image/list_test.go | 12 +- internal/projectconfig/component.go | 9 + internal/projectconfig/configfile.go | 13 + internal/projectconfig/configfile_test.go | 32 +++ internal/projectconfig/fingerprint_test.go | 3 + internal/projectconfig/image.go | 11 +- internal/projectconfig/loader_test.go | 1 + internal/projectconfig/tests.go | 196 ++++++++++++++ internal/projectconfig/testsuite_test.go | 6 +- ...ainer_config_generate-schema_stdout_1.snap | 250 ++++++++++++++++++ ...shots_config_generate-schema_stdout_1.snap | 250 ++++++++++++++++++ schemas/azldev.schema.json | 250 ++++++++++++++++++ 18 files changed, 1143 insertions(+), 12 deletions(-) create mode 100644 docs/user/reference/config/tests.md create mode 100644 internal/projectconfig/tests.go diff --git a/docs/user/reference/config/components.md b/docs/user/reference/config/components.md index 33e984f41..a08d7dd15 100644 --- a/docs/user/reference/config/components.md +++ b/docs/user/reference/config/components.md @@ -16,6 +16,7 @@ A component definition tells azldev where to find the spec file, how to customiz | Render config | `render` | [RenderConfig](#render-configuration) | No | Options controlling spec rendering behavior | | Source files | `source-files` | array of [SourceFileReference](#source-file-references) | No | Additional source files to download for this component | | Package overrides | `packages` | map of string → [PackageConfig](package-groups.md#package-config) | No | Exact per-package configuration overrides; highest priority in the resolution order | +| Tests | `tests` | [ComponentTests](#component-tests) | No | Test references that apply to this component (see [Tests and Test Groups](tests.md)) | ### Bare Components @@ -300,6 +301,24 @@ rpm-channel = "rpm-devel" rpm-channel = "none" ``` +## Component Tests + +The `[components..tests]` subtable lists test or test-group +references that apply to the component. Each entry is a [TestRef](tests.md#test-reference) +with exactly one of `name` or `group`. + +| Field | TOML Key | Type | Required | Description | +|-------|----------|------|----------|-------------| +| Tests | `tests` | array of [TestRef](tests.md#test-reference) | No | References to `[tests.]` entries or `[test-groups.]` entries | + +```toml +[components.kernel.tests] +tests = [ + { group = "kernel-bvt" }, + { name = "kdump-smoke" }, +] +``` + ## Source File References The `[[components..source-files]]` array defines additional source files that azldev should download before building. These are files not available in the dist-git repository or lookaside cache — typically binaries, pre-built artifacts, or files from custom hosting. @@ -461,5 +480,6 @@ lines = ["cp -vf %{shimdirx64}/$(basename %{shimefix64}) %{shimefix64} ||:"] - [Distros](distros.md) — distro definitions and `default-component-config` inheritance - [Component Groups](component-groups.md) — grouping components with shared defaults - [Package Groups](package-groups.md) — project-level package groups and full resolution order +- [Tests and Test Groups](tests.md) — definitions referenced by `[components..tests]` - [Configuration System](../../explanation/config-system.md) — inheritance and merge behavior - [JSON Schema](../../../../schemas/azldev.schema.json) — machine-readable schema diff --git a/docs/user/reference/config/config-file.md b/docs/user/reference/config/config-file.md index e1ae30bb1..19abd9722 100644 --- a/docs/user/reference/config/config-file.md +++ b/docs/user/reference/config/config-file.md @@ -15,6 +15,8 @@ All config files share the same schema — there is no distinction between a "ro | `component-groups` | map of objects | Named groups of components with shared defaults | [Component Groups](component-groups.md) | | `images` | map of objects | Image definitions (VMs, containers) | [Images](images.md) | | `test-suites` | map of objects | Named test suite definitions referenced by images | [Test Suites](test-suites.md) | +| `tests` | map of objects | Named test definitions (new-shape, parse-only) | [Tests and Test Groups](tests.md) | +| `test-groups` | map of objects | Named bundles of test references (new-shape, parse-only) | [Tests and Test Groups](tests.md) | | `tools` | object | Configuration for external tools used by azldev | [Tools](tools.md) | | `default-package-config` | object | Project-wide default applied to all binary packages | [Package Groups — Resolution Order](package-groups.md#resolution-order) | | `package-groups` | map of objects | Named groups of binary packages with shared config | [Package Groups](package-groups.md) | diff --git a/docs/user/reference/config/images.md b/docs/user/reference/config/images.md index ebbd5e531..34dd9cb94 100644 --- a/docs/user/reference/config/images.md +++ b/docs/user/reference/config/images.md @@ -35,11 +35,12 @@ The `capabilities` subtable describes what the image supports. All fields are op ## Image Tests -The `tests` subtable links an image to one or more test suites defined in the top-level [`[test-suites]`](test-suites.md) section. +The `tests` subtable links an image to one or more test suites defined in the top-level [`[test-suites]`](test-suites.md) section, and/or to entries from the new-shape [`[tests]` / `[test-groups]`](tests.md) sections. | Field | TOML Key | Type | Required | Description | |-------|----------|------|----------|-------------| | Test Suites | `test-suites` | array of inline tables | No | List of test suite references. Each entry must have a `name` field matching a key in `[test-suites]`. | +| Tests | `tests` | array of [TestRef](tests.md#test-reference) | No | References to `[tests.]` entries or `[test-groups.]` entries (parse-only; see [Tests and Test Groups](tests.md)). | ## Image Publish @@ -118,4 +119,5 @@ channels = ["registry-prod", "registry-staging"] - [Config File Structure](config-file.md) — top-level config file layout - [Test Suites](test-suites.md) — test suite definitions +- [Tests and Test Groups](tests.md) — new-shape test/group definitions referenced by `[images..tests]` - [Tools](tools.md) — Image Customizer tool configuration diff --git a/docs/user/reference/config/tests.md b/docs/user/reference/config/tests.md new file mode 100644 index 000000000..990e34aa6 --- /dev/null +++ b/docs/user/reference/config/tests.md @@ -0,0 +1,92 @@ +# Tests and Test Groups + +The `[tests]` and `[test-groups]` sections declare framework-agnostic test +metadata that components and images can target by name. Each test entry +binds a single test (a pytest run, a LISA case, or a TMT plan) +to a named identifier; each group entry bundles tests (and +nested groups) under one name so callers can reference a curated set +without enumerating every member. + +## Test Definition + +Each entry under `[tests.]` describes one configuration of one +runner. Framework-specific options live in a typed subtable +(`pytest`, `lisa`, `tmt`) whose contents are passed through +to the runner; their internal schemas are intentionally not validated +by azldev so frameworks can evolve independently. + +| Field | TOML Key | Type | Required | Description | +|-------|----------|------|----------|-------------| +| Type | `type` | string | Yes | Test framework: `pytest`, `lisa`, or `tmt` | +| Description | `description` | string | No | Human-readable description | +| Kind | `kind` | string array | No | Free-form hints (e.g. `functional`, `performance`, `bvt`) | +| Long running | `long-running` | boolean | No | Hints that this test may run for hours | +| Required capabilities | `required-capabilities` | string array | No | Capability tokens the image must declare for this test to be applicable | +| Lisa | `lisa` | table | No | LISA-specific configuration (opaque to azldev) | +| Tmt | `tmt` | table | No | TMT-specific configuration (opaque to azldev) | +| Pytest | `pytest` | table | No | pytest-specific configuration (opaque to azldev) | + +## Test Group + +Each entry under `[test-groups.]` names an ordered list of test or +nested-group references that callers can target as a single unit. + +| Field | TOML Key | Type | Required | Description | +|-------|----------|------|----------|-------------| +| Description | `description` | string | No | Human-readable description | +| Tests | `tests` | array of [TestRef](#test-reference) | No | Ordered members of the group | + +## Test Reference + +`TestRef` is an inline table with exactly one of `name` or `group`: + +| Field | TOML Key | Type | Description | +|-------|----------|------|-------------| +| Name | `name` | string | References a `[tests.]` entry | +| Group | `group` | string | References a `[test-groups.]` entry | + +## Referencing from Components and Images + +Components and images both expose a `tests` subtable that holds a list +of `TestRef`s: + +```toml +[components.kernel.tests] +tests = [{ group = "kernel-bvt" }, { name = "kdump-smoke" }] + +[images.vm-base.tests] +tests = [{ group = "bvt" }] +``` + +## Example + +```toml +[tests.bvt-ssh] +type = "pytest" +description = "Basic SSH boot verification" +kind = ["functional", "bvt"] +required-capabilities = ["ssh"] +pytest = { working-dir = "tests/bvt", test-paths = ["test_ssh.py"] } + +[tests.kdump-smoke] +type = "lisa" +description = "Smoke test for kdump" +lisa = { case = "kdump.smoke" } + +[test-groups.bvt] +description = "Build verification tests" +tests = [ + { name = "bvt-ssh" }, + { group = "bvt-extras" }, +] + +[test-groups.bvt-extras] +tests = [{ name = "kdump-smoke" }] +``` + +## Related Resources + +- [Test Suites](test-suites.md) - legacy test suite definitions +- [Components](components.md#component-tests) — per-component `tests` field +- [Images](images.md#image-tests) — per-image `tests` field +- [Config File Structure](config-file.md) — top-level config layout diff --git a/go.mod b/go.mod index b3eaa94d9..a3c97d6bc 100644 --- a/go.mod +++ b/go.mod @@ -40,6 +40,7 @@ require ( github.com/muesli/termenv v0.16.0 github.com/nxadm/tail v1.4.11 github.com/opencontainers/selinux v1.15.1 + github.com/pb33f/ordered-map/v2 v2.3.1 github.com/pelletier/go-toml/v2 v2.4.2 github.com/pmezard/go-difflib v1.0.0 github.com/samber/lo v1.53.0 @@ -131,7 +132,6 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect - github.com/pb33f/ordered-map/v2 v2.3.1 // indirect github.com/pjbgf/sha1cd v0.6.0 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/rivo/uniseg v0.4.7 // indirect diff --git a/internal/app/azldev/cmds/image/list.go b/internal/app/azldev/cmds/image/list.go index d0d6bc78e..0c42af0fd 100644 --- a/internal/app/azldev/cmds/image/list.go +++ b/internal/app/azldev/cmds/image/list.go @@ -38,7 +38,7 @@ type ImageListResult struct { // Tests holds the test configuration for this image, matching the original config // structure. - Tests projectconfig.ImageTestsConfig `json:"tests" table:"-"` + Tests *projectconfig.ImageTestsConfig `json:"tests,omitempty" table:"-"` // TestsSummary is a comma-separated summary of test suite names for table display. TestsSummary string `json:"-" table:"Tests"` diff --git a/internal/app/azldev/cmds/image/list_test.go b/internal/app/azldev/cmds/image/list_test.go index 6c572ac38..6289c08f5 100644 --- a/internal/app/azldev/cmds/image/list_test.go +++ b/internal/app/azldev/cmds/image/list_test.go @@ -89,7 +89,7 @@ func TestListImages_WithCapabilitiesAndTests(t *testing.T) { MachineBootable: lo.ToPtr(true), Systemd: lo.ToPtr(true), }, - Tests: projectconfig.ImageTestsConfig{ + Tests: &projectconfig.ImageTestsConfig{ TestSuites: []projectconfig.TestSuiteRef{ {Name: "smoke"}, {Name: "integration"}, @@ -105,7 +105,7 @@ func TestListImages_WithCapabilitiesAndTests(t *testing.T) { Capabilities: projectconfig.ImageCapabilities{ Container: lo.ToPtr(true), }, - Tests: projectconfig.ImageTestsConfig{ + Tests: &projectconfig.ImageTestsConfig{ TestSuites: []projectconfig.TestSuiteRef{ {Name: "smoke"}, }, @@ -131,9 +131,10 @@ func TestListImages_WithCapabilitiesAndTests(t *testing.T) { assert.Equal(t, lo.ToPtr(true), results[0].Capabilities.Container) assert.Nil(t, results[0].Capabilities.MachineBootable) assert.Equal(t, "container", results[0].CapabilitiesSummary) + require.NotNil(t, results[0].Tests) assert.Equal(t, projectconfig.ImageTestsConfig{ TestSuites: []projectconfig.TestSuiteRef{{Name: "smoke"}}, - }, results[0].Tests) + }, *results[0].Tests) assert.Equal(t, "smoke", results[0].TestsSummary) assert.Equal(t, projectconfig.ImagePublishConfig{ Channels: []string{"registry-prod"}, @@ -144,7 +145,7 @@ func TestListImages_WithCapabilitiesAndTests(t *testing.T) { assert.Nil(t, results[1].Capabilities.MachineBootable) assert.Nil(t, results[1].Capabilities.Container) assert.Empty(t, results[1].CapabilitiesSummary) - assert.Empty(t, results[1].Tests.TestSuites) + assert.Nil(t, results[1].Tests) assert.Empty(t, results[1].TestsSummary) assert.Empty(t, results[1].Publish.Channels) assert.Empty(t, results[1].PublishSummary) @@ -154,9 +155,10 @@ func TestListImages_WithCapabilitiesAndTests(t *testing.T) { assert.Equal(t, lo.ToPtr(true), results[2].Capabilities.Systemd) assert.Nil(t, results[2].Capabilities.Container) assert.Equal(t, "machine-bootable, systemd", results[2].CapabilitiesSummary) + require.NotNil(t, results[2].Tests) assert.Equal(t, projectconfig.ImageTestsConfig{ TestSuites: []projectconfig.TestSuiteRef{{Name: "smoke"}, {Name: "integration"}}, - }, results[2].Tests) + }, *results[2].Tests) assert.Equal(t, "smoke, integration", results[2].TestsSummary) assert.Equal(t, projectconfig.ImagePublishConfig{ Channels: []string{"registry-prod", "registry-staging"}, diff --git a/internal/projectconfig/component.go b/internal/projectconfig/component.go index dce2f264c..d9dcd9251 100644 --- a/internal/projectconfig/component.go +++ b/internal/projectconfig/component.go @@ -305,6 +305,14 @@ type ComponentConfig struct { // all packages produced by this component. Overridden by package-group and per-package settings // for binary and debuginfo channels. Publish ComponentPublishConfig `toml:"publish,omitempty" json:"publish,omitempty" table:"-" jsonschema:"title=Publish settings,description=Component-level publish channel settings" fingerprint:"-"` + + // Tests holds the new-shape per-component tests block: + // + // tests.tests = [{ name = "..." }, { group = "..." }] + // + // References must resolve to entries in the project-level [tests] or + // [test-groups] maps; resolution is the responsibility of the test layer. + Tests *ComponentTestsConfig `toml:"tests,omitempty" json:"tests,omitempty" table:"-" jsonschema:"title=Tests,description=Per-component test or test-group references" fingerprint:"-"` } // AllowedSourceFilesHashTypes defines the set of hash types that are supported @@ -408,6 +416,7 @@ func (c *ComponentConfig) WithAbsolutePaths(referenceDir string) *ComponentConfi // here so inherited patterns can be interpreted relative to the concrete component // config file. OverlayFiles: slices.Clone(c.OverlayFiles), + Tests: deep.MustCopy(c.Tests), } // Fix up paths. diff --git a/internal/projectconfig/configfile.go b/internal/projectconfig/configfile.go index e16b79b70..6eab18acc 100644 --- a/internal/projectconfig/configfile.go +++ b/internal/projectconfig/configfile.go @@ -66,6 +66,12 @@ type ConfigFile struct { // Definitions of test suites. TestSuites map[string]TestSuiteConfig `toml:"test-suites,omitempty" validate:"dive" jsonschema:"title=Test Suites,description=Definitions of test suites for this project"` + // Definitions of individual tests (new schema, [tests.X]). + Tests map[string]TestDefinition `toml:"tests,omitempty" validate:"dive" jsonschema:"title=Tests,description=Definitions of individual tests"` + + // Definitions of test groups (new schema, [test-groups.X]). + TestGroups map[string]TestGroup `toml:"test-groups,omitempty" validate:"dive" jsonschema:"title=Test Groups,description=Definitions of named bundles of tests"` + // Internal fields used to track the origin of the config file; `dir` is the directory // that the config file's relative paths are based from. sourcePath string `toml:"-"` @@ -147,6 +153,13 @@ func (f ConfigFile) Validate() error { } } + // Validate individual test definitions and ensure framework subtable/type match. + for testName, testDef := range f.Tests { + if err := testDef.Validate(testName); err != nil { + return fmt.Errorf("invalid test %#q:\n%w", testName, err) + } + } + return nil } diff --git a/internal/projectconfig/configfile_test.go b/internal/projectconfig/configfile_test.go index d61f2866c..b234b9c32 100644 --- a/internal/projectconfig/configfile_test.go +++ b/internal/projectconfig/configfile_test.go @@ -17,6 +17,38 @@ func TestProjectConfigFileValidation_EmptyFile(t *testing.T) { assert.NoError(t, file.Validate()) } +func TestProjectConfigFileValidation_TestDefinitionMismatchedSubtable(t *testing.T) { + file := projectconfig.ConfigFile{ + Tests: map[string]projectconfig.TestDefinition{ + "smoke": { + Type: "pytest", + Pytest: map[string]any{"working-dir": "tests"}, + Lisa: map[string]any{"suite": "vm"}, + }, + }, + } + + err := file.Validate() + require.Error(t, err) + require.ErrorIs(t, err, projectconfig.ErrMismatchedTestSubtable) + assert.Contains(t, err.Error(), "invalid test") + assert.Contains(t, err.Error(), "smoke") + assert.Contains(t, err.Error(), "lisa") +} + +func TestProjectConfigFileValidation_TestDefinitionMatchingSubtable(t *testing.T) { + file := projectconfig.ConfigFile{ + Tests: map[string]projectconfig.TestDefinition{ + "smoke": { + Type: "pytest", + Pytest: map[string]any{"working-dir": "tests"}, + }, + }, + } + + assert.NoError(t, file.Validate()) +} + func TestProjectConfigFileValidation_DefaultProjectInfo(t *testing.T) { file := projectconfig.ConfigFile{ Project: &projectconfig.ProjectInfo{}, diff --git a/internal/projectconfig/fingerprint_test.go b/internal/projectconfig/fingerprint_test.go index 9fe568703..d8b733155 100644 --- a/internal/projectconfig/fingerprint_test.go +++ b/internal/projectconfig/fingerprint_test.go @@ -65,6 +65,9 @@ func TestAllFingerprintedFieldsHaveDecision(t *testing.T) { // ComponentConfig.Publish — post-build routing (where to publish), not a build input. "ComponentConfig.Publish": true, + // ComponentConfig.Tests — test selection metadata (new schema), not a build input. + "ComponentConfig.Tests": true, + // ComponentOverlay.Description — human-readable documentation for the overlay. "ComponentOverlay.Description": true, // ComponentOverlay.Source — absolute path that varies by checkout location. diff --git a/internal/projectconfig/image.go b/internal/projectconfig/image.go index f20008519..b8033dc63 100644 --- a/internal/projectconfig/image.go +++ b/internal/projectconfig/image.go @@ -30,7 +30,7 @@ type ImageConfig struct { // Tests holds the test configuration for this image, including which test suites // apply to it. - Tests ImageTestsConfig `toml:"tests,omitempty" json:"tests,omitempty" jsonschema:"title=Tests,description=Test configuration for this image"` + Tests *ImageTestsConfig `toml:"tests,omitempty" json:"tests,omitempty" jsonschema:"title=Tests,description=Test configuration for this image"` // Publish holds the publish settings for this image. Publish ImagePublishConfig `toml:"publish,omitempty" json:"publish,omitempty" jsonschema:"title=Publish settings,description=Publishing settings for this image"` @@ -115,6 +115,11 @@ type ImageTestsConfig struct { // reference identifies a test suite defined in the top-level [test-suites] section // and may carry per-test metadata in the future (e.g., required vs optional). TestSuites []TestSuiteRef `toml:"test-suites,omitempty" json:"testSuites,omitempty" jsonschema:"title=Test Suites,description=List of test suite references that apply to this image"` + + // Tests is the new-shape list of test or test-group references that apply to this + // image. References must resolve to entries in the project-level [tests] or + // [test-groups] maps; resolution is the responsibility of the test layer. + Tests []TestRef `toml:"tests,omitempty" json:"tests,omitempty" jsonschema:"title=Tests,description=List of test or test-group references that apply to this image"` } // TestSuiteRef is a reference to a named test suite. Using a structured type (rather than @@ -126,6 +131,10 @@ type TestSuiteRef struct { // TestNames returns the test suite names referenced by this image. func (i *ImageConfig) TestNames() []string { + if i.Tests == nil { + return nil + } + names := make([]string, len(i.Tests.TestSuites)) for idx, ref := range i.Tests.TestSuites { names[idx] = ref.Name diff --git a/internal/projectconfig/loader_test.go b/internal/projectconfig/loader_test.go index f825d55b4..afdc3cca3 100644 --- a/internal/projectconfig/loader_test.go +++ b/internal/projectconfig/loader_test.go @@ -985,6 +985,7 @@ test-suites = [{ name = "smoke" }] require.NoError(t, err) if assert.Contains(t, config.Images, "myimage") { + require.NotNil(t, config.Images["myimage"].Tests) assert.Equal(t, []TestSuiteRef{{Name: "smoke"}}, config.Images["myimage"].Tests.TestSuites) } } diff --git a/internal/projectconfig/tests.go b/internal/projectconfig/tests.go new file mode 100644 index 000000000..601a92a0e --- /dev/null +++ b/internal/projectconfig/tests.go @@ -0,0 +1,196 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package projectconfig + +import ( + "fmt" + + "github.com/invopop/jsonschema" + orderedmap "github.com/pb33f/ordered-map/v2" +) + +func orderedMapWithConst(key string, value any) *orderedmap.OrderedMap[string, *jsonschema.Schema] { + m := orderedmap.New[string, *jsonschema.Schema]() + m.Set(key, &jsonschema.Schema{Const: value}) + + return m +} + +// TestDefinition is the new-shape [tests.X] declaration: one configuration of one +// runner/harness with framework-specific options. Framework subtables are kept as +// loosely-typed maps so the resolver can evolve their schemas without requiring +// matching struct changes here. +type TestDefinition struct { + // Type identifies the framework/runner. Required, and constrained to the + // closed enum in the schema tag at the schema layer. The loader still + // accepts unknown values permissively; the resolver is the source of truth. + Type string `toml:"type" json:"type" jsonschema:"required,title=Type,description=Test framework type,enum=pytest,enum=lisa,enum=tmt"` + + // Human-readable description. + Description string `toml:"description,omitempty" json:"description,omitempty" jsonschema:"title=Description,description=Description of this test"` + + // Kind hints at what the test exercises (e.g. functional, performance). + Kind []string `toml:"kind,omitempty" json:"kind,omitempty" jsonschema:"title=Kind,description=Test kind hints (e.g. functional or performance)"` + + // LongRunning hints to schedulers/policy that this test may take a long time. + LongRunning bool `toml:"long-running,omitempty" json:"longRunning,omitempty" jsonschema:"title=Long running,description=Hints that this test may run for hours"` + + // RequiredCapabilities lists capability tokens an image must declare to be a + // valid target for this test. Tokens are matched against [ImageCapabilities]. + RequiredCapabilities []string `toml:"required-capabilities,omitempty" json:"requiredCapabilities,omitempty" jsonschema:"title=Required capabilities,description=Capability tokens the image must declare"` + + // Framework-specific subtables. Kept untyped so framework schema can evolve + // independently of the dev-tools type definitions. + Lisa map[string]any `toml:"lisa,omitempty" json:"lisa,omitempty" jsonschema:"title=LISA config,description=LISA-specific configuration"` + Tmt map[string]any `toml:"tmt,omitempty" json:"tmt,omitempty" jsonschema:"title=TMT config,description=TMT-specific configuration"` + Pytest map[string]any `toml:"pytest,omitempty" json:"pytest,omitempty" jsonschema:"title=Pytest config,description=pytest-specific configuration"` +} + +// TestGroup is a [test-groups.X] declaration: a named bundle of test references that +// images or components can target via a single name. +type TestGroup struct { + // Human-readable description. + Description string `toml:"description,omitempty" json:"description,omitempty" jsonschema:"title=Description,description=Description of this test group"` + + // Tests is the ordered list of test or nested-group references that make up + // the group's membership. + Tests []TestRef `toml:"tests,omitempty" json:"tests,omitempty" jsonschema:"title=Tests,description=Member references (each is either {name=...} or {group=...})"` +} + +// TestRef is a reference to either a test (by name) or another group (by name). +// Exactly one of Name or Group should be set; semantic validation is the resolver's +// responsibility. +type TestRef struct { + // Name references a [tests.X] entry. + Name string `toml:"name,omitempty" json:"name,omitempty" jsonschema:"title=Name,description=Name of a test (mutually exclusive with group)"` + + // Group references a [test-groups.X] entry. + Group string `toml:"group,omitempty" json:"group,omitempty" jsonschema:"title=Group,description=Name of a test group (mutually exclusive with name)"` +} + +// ComponentTestsConfig holds the new-shape per-component tests block: +// +// tests.tests = [{ name = "..." }, { group = "..." }] +type ComponentTestsConfig struct { + // Tests is the list of test or test-group references that apply to the component. + Tests []TestRef `toml:"tests,omitempty" json:"tests,omitempty" jsonschema:"title=Tests,description=Per-component test or test-group references"` +} + +// Validate checks that exactly one framework subtable is set and it matches Type. +func (t TestDefinition) Validate(testName string) error { + if t.Type == "" { + return fmt.Errorf("%w: test %#q is missing required field 'type'", ErrMissingTestField, testName) + } + + type testTypeRule struct { + required string + disallowed []string + } + + typeRules := map[string]testTypeRule{ + "pytest": {required: "pytest", disallowed: []string{"lisa", "tmt"}}, + "lisa": {required: "lisa", disallowed: []string{"pytest", "tmt"}}, + "tmt": {required: "tmt", disallowed: []string{"pytest", "lisa"}}, + } + + rule, ok := typeRules[t.Type] + if !ok { + return fmt.Errorf("%w: %#q (test: %#q)", ErrUnknownTestType, t.Type, testName) + } + + subtableLengths := map[string]int{ + "pytest": len(t.Pytest), + "lisa": len(t.Lisa), + "tmt": len(t.Tmt), + } + + if subtableLengths[rule.required] == 0 { + return fmt.Errorf( + "%w: test %#q of type %#q requires a [%s] subtable", + ErrMissingTestField, + testName, + t.Type, + rule.required, + ) + } + + for _, subtable := range rule.disallowed { + if subtableLengths[subtable] > 0 { + return fmt.Errorf( + "%w: test %#q of type %#q cannot include subtable '%s'", + ErrMismatchedTestSubtable, + testName, + t.Type, + subtable, + ) + } + } + + return nil +} + +// JSONSchemaExtend tightens [TestDefinition] so the framework-specific subtable +// must match the declared type. +func (TestDefinition) JSONSchemaExtend(schema *jsonschema.Schema) { + if schema == nil { + return + } + + onlyPytest := &jsonschema.Schema{ + Required: []string{"pytest"}, + Not: &jsonschema.Schema{AnyOf: []*jsonschema.Schema{ + {Required: []string{"lisa"}}, + {Required: []string{"tmt"}}, + }}, + } + + onlyLisa := &jsonschema.Schema{ + Required: []string{"lisa"}, + Not: &jsonschema.Schema{AnyOf: []*jsonschema.Schema{ + {Required: []string{"pytest"}}, + {Required: []string{"tmt"}}, + }}, + } + + onlyTmt := &jsonschema.Schema{ + Required: []string{"tmt"}, + Not: &jsonschema.Schema{AnyOf: []*jsonschema.Schema{ + {Required: []string{"pytest"}}, + {Required: []string{"lisa"}}, + }}, + } + + schema.AllOf = append(schema.AllOf, + &jsonschema.Schema{If: &jsonschema.Schema{Properties: orderedMapWithConst("type", "pytest")}, Then: onlyPytest}, + &jsonschema.Schema{If: &jsonschema.Schema{Properties: orderedMapWithConst("type", "lisa")}, Then: onlyLisa}, + &jsonschema.Schema{If: &jsonschema.Schema{Properties: orderedMapWithConst("type", "tmt")}, Then: onlyTmt}, + ) +} + +// JSONSchemaExtend tightens the generated schema for [TestRef] so editors can +// flag refs that set neither or both of name/group, or empty/whitespace-only values. +// The runtime resolver ([ErrInvalidTestRef]) is the source of truth; this keeps the schema in sync. +func (TestRef) JSONSchemaExtend(schema *jsonschema.Schema) { + // Exactly one of name|group: encoded as oneOf with `required` on each and + // `not` excluding the other, which forbids both `{}` and `{name, group}`. + schema.OneOf = []*jsonschema.Schema{ + {Required: []string{"name"}, Not: &jsonschema.Schema{Required: []string{"group"}}}, + {Required: []string{"group"}, Not: &jsonschema.Schema{Required: []string{"name"}}}, + } + + // Prevent empty or leading-whitespace identifiers in both name and group fields. + minLen := uint64(1) + + if schema.Properties != nil { + if nameProp, ok := schema.Properties.Get("name"); ok && nameProp != nil { + nameProp.MinLength = &minLen + nameProp.Pattern = "^\\S" + } + + if groupProp, ok := schema.Properties.Get("group"); ok && groupProp != nil { + groupProp.MinLength = &minLen + groupProp.Pattern = "^\\S" + } + } +} diff --git a/internal/projectconfig/testsuite_test.go b/internal/projectconfig/testsuite_test.go index e24f758f8..88b51a6ef 100644 --- a/internal/projectconfig/testsuite_test.go +++ b/internal/projectconfig/testsuite_test.go @@ -50,7 +50,7 @@ func TestImageCapabilities_EnabledNames(t *testing.T) { func TestImageConfig_TestNames(t *testing.T) { t.Run("with tests", func(t *testing.T) { img := projectconfig.ImageConfig{ - Tests: projectconfig.ImageTestsConfig{ + Tests: &projectconfig.ImageTestsConfig{ TestSuites: []projectconfig.TestSuiteRef{ {Name: "smoke"}, {Name: "integration"}, @@ -294,7 +294,7 @@ func TestValidateTestSuiteReferences(t *testing.T) { Images: map[string]projectconfig.ImageConfig{ "myimage": { Name: "myimage", - Tests: projectconfig.ImageTestsConfig{TestSuites: []projectconfig.TestSuiteRef{{Name: "smoke"}}}, + Tests: &projectconfig.ImageTestsConfig{TestSuites: []projectconfig.TestSuiteRef{{Name: "smoke"}}}, }, }, TestSuites: map[string]projectconfig.TestSuiteConfig{ @@ -320,7 +320,7 @@ func TestValidateTestSuiteReferences(t *testing.T) { Images: map[string]projectconfig.ImageConfig{ "myimage": { Name: "myimage", - Tests: projectconfig.ImageTestsConfig{TestSuites: []projectconfig.TestSuiteRef{{Name: "nonexistent"}}}, + Tests: &projectconfig.ImageTestsConfig{TestSuites: []projectconfig.TestSuiteRef{{Name: "nonexistent"}}}, }, }, TestSuites: make(map[string]projectconfig.TestSuiteConfig), diff --git a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap index d665b45f9..95a0fe536 100755 --- a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap @@ -173,6 +173,11 @@ "$ref": "#/$defs/ComponentPublishConfig", "title": "Publish settings", "description": "Component-level publish channel settings" + }, + "tests": { + "$ref": "#/$defs/ComponentTestsConfig", + "title": "Tests", + "description": "Per-component test or test-group references" } }, "additionalProperties": false, @@ -347,6 +352,20 @@ "additionalProperties": false, "type": "object" }, + "ComponentTestsConfig": { + "properties": { + "tests": { + "items": { + "$ref": "#/$defs/TestRef" + }, + "type": "array", + "title": "Tests", + "description": "Per-component test or test-group references" + } + }, + "additionalProperties": false, + "type": "object" + }, "ConfigFile": { "properties": { "$schema": { @@ -432,6 +451,22 @@ "type": "object", "title": "Test Suites", "description": "Definitions of test suites for this project" + }, + "tests": { + "additionalProperties": { + "$ref": "#/$defs/TestDefinition" + }, + "type": "object", + "title": "Tests", + "description": "Definitions of individual tests" + }, + "test-groups": { + "additionalProperties": { + "$ref": "#/$defs/TestGroup" + }, + "type": "object", + "title": "Test Groups", + "description": "Definitions of named bundles of tests" } }, "additionalProperties": false, @@ -710,6 +745,14 @@ "type": "array", "title": "Test Suites", "description": "List of test suite references that apply to this image" + }, + "tests": { + "items": { + "$ref": "#/$defs/TestRef" + }, + "type": "array", + "title": "Tests", + "description": "List of test or test-group references that apply to this image" } }, "additionalProperties": false, @@ -1288,6 +1331,213 @@ "subpath" ] }, + "TestDefinition": { + "allOf": [ + { + "if": { + "properties": { + "type": { + "const": "pytest" + } + } + }, + "then": { + "not": { + "anyOf": [ + { + "required": [ + "lisa" + ] + }, + { + "required": [ + "tmt" + ] + } + ] + }, + "required": [ + "pytest" + ] + } + }, + { + "if": { + "properties": { + "type": { + "const": "lisa" + } + } + }, + "then": { + "not": { + "anyOf": [ + { + "required": [ + "pytest" + ] + }, + { + "required": [ + "tmt" + ] + } + ] + }, + "required": [ + "lisa" + ] + } + }, + { + "if": { + "properties": { + "type": { + "const": "tmt" + } + } + }, + "then": { + "not": { + "anyOf": [ + { + "required": [ + "pytest" + ] + }, + { + "required": [ + "lisa" + ] + } + ] + }, + "required": [ + "tmt" + ] + } + } + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "pytest", + "lisa", + "tmt" + ], + "title": "Type", + "description": "Test framework type" + }, + "description": { + "type": "string", + "title": "Description", + "description": "Description of this test" + }, + "kind": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Kind", + "description": "Test kind hints (e.g. functional or performance)" + }, + "long-running": { + "type": "boolean", + "title": "Long running", + "description": "Hints that this test may run for hours" + }, + "required-capabilities": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Required capabilities", + "description": "Capability tokens the image must declare" + }, + "lisa": { + "type": "object", + "title": "LISA config", + "description": "LISA-specific configuration" + }, + "tmt": { + "type": "object", + "title": "TMT config", + "description": "TMT-specific configuration" + }, + "pytest": { + "type": "object", + "title": "Pytest config", + "description": "pytest-specific configuration" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "type" + ] + }, + "TestGroup": { + "properties": { + "description": { + "type": "string", + "title": "Description", + "description": "Description of this test group" + }, + "tests": { + "items": { + "$ref": "#/$defs/TestRef" + }, + "type": "array", + "title": "Tests", + "description": "Member references (each is either {name=...} or {group=...})" + } + }, + "additionalProperties": false, + "type": "object" + }, + "TestRef": { + "oneOf": [ + { + "not": { + "required": [ + "group" + ] + }, + "required": [ + "name" + ] + }, + { + "not": { + "required": [ + "name" + ] + }, + "required": [ + "group" + ] + } + ], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "pattern": "^\\S", + "title": "Name", + "description": "Name of a test (mutually exclusive with group)" + }, + "group": { + "type": "string", + "minLength": 1, + "pattern": "^\\S", + "title": "Group", + "description": "Name of a test group (mutually exclusive with name)" + } + }, + "additionalProperties": false, + "type": "object" + }, "TestSuiteConfig": { "properties": { "description": { diff --git a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap index d665b45f9..95a0fe536 100755 --- a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap @@ -173,6 +173,11 @@ "$ref": "#/$defs/ComponentPublishConfig", "title": "Publish settings", "description": "Component-level publish channel settings" + }, + "tests": { + "$ref": "#/$defs/ComponentTestsConfig", + "title": "Tests", + "description": "Per-component test or test-group references" } }, "additionalProperties": false, @@ -347,6 +352,20 @@ "additionalProperties": false, "type": "object" }, + "ComponentTestsConfig": { + "properties": { + "tests": { + "items": { + "$ref": "#/$defs/TestRef" + }, + "type": "array", + "title": "Tests", + "description": "Per-component test or test-group references" + } + }, + "additionalProperties": false, + "type": "object" + }, "ConfigFile": { "properties": { "$schema": { @@ -432,6 +451,22 @@ "type": "object", "title": "Test Suites", "description": "Definitions of test suites for this project" + }, + "tests": { + "additionalProperties": { + "$ref": "#/$defs/TestDefinition" + }, + "type": "object", + "title": "Tests", + "description": "Definitions of individual tests" + }, + "test-groups": { + "additionalProperties": { + "$ref": "#/$defs/TestGroup" + }, + "type": "object", + "title": "Test Groups", + "description": "Definitions of named bundles of tests" } }, "additionalProperties": false, @@ -710,6 +745,14 @@ "type": "array", "title": "Test Suites", "description": "List of test suite references that apply to this image" + }, + "tests": { + "items": { + "$ref": "#/$defs/TestRef" + }, + "type": "array", + "title": "Tests", + "description": "List of test or test-group references that apply to this image" } }, "additionalProperties": false, @@ -1288,6 +1331,213 @@ "subpath" ] }, + "TestDefinition": { + "allOf": [ + { + "if": { + "properties": { + "type": { + "const": "pytest" + } + } + }, + "then": { + "not": { + "anyOf": [ + { + "required": [ + "lisa" + ] + }, + { + "required": [ + "tmt" + ] + } + ] + }, + "required": [ + "pytest" + ] + } + }, + { + "if": { + "properties": { + "type": { + "const": "lisa" + } + } + }, + "then": { + "not": { + "anyOf": [ + { + "required": [ + "pytest" + ] + }, + { + "required": [ + "tmt" + ] + } + ] + }, + "required": [ + "lisa" + ] + } + }, + { + "if": { + "properties": { + "type": { + "const": "tmt" + } + } + }, + "then": { + "not": { + "anyOf": [ + { + "required": [ + "pytest" + ] + }, + { + "required": [ + "lisa" + ] + } + ] + }, + "required": [ + "tmt" + ] + } + } + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "pytest", + "lisa", + "tmt" + ], + "title": "Type", + "description": "Test framework type" + }, + "description": { + "type": "string", + "title": "Description", + "description": "Description of this test" + }, + "kind": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Kind", + "description": "Test kind hints (e.g. functional or performance)" + }, + "long-running": { + "type": "boolean", + "title": "Long running", + "description": "Hints that this test may run for hours" + }, + "required-capabilities": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Required capabilities", + "description": "Capability tokens the image must declare" + }, + "lisa": { + "type": "object", + "title": "LISA config", + "description": "LISA-specific configuration" + }, + "tmt": { + "type": "object", + "title": "TMT config", + "description": "TMT-specific configuration" + }, + "pytest": { + "type": "object", + "title": "Pytest config", + "description": "pytest-specific configuration" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "type" + ] + }, + "TestGroup": { + "properties": { + "description": { + "type": "string", + "title": "Description", + "description": "Description of this test group" + }, + "tests": { + "items": { + "$ref": "#/$defs/TestRef" + }, + "type": "array", + "title": "Tests", + "description": "Member references (each is either {name=...} or {group=...})" + } + }, + "additionalProperties": false, + "type": "object" + }, + "TestRef": { + "oneOf": [ + { + "not": { + "required": [ + "group" + ] + }, + "required": [ + "name" + ] + }, + { + "not": { + "required": [ + "name" + ] + }, + "required": [ + "group" + ] + } + ], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "pattern": "^\\S", + "title": "Name", + "description": "Name of a test (mutually exclusive with group)" + }, + "group": { + "type": "string", + "minLength": 1, + "pattern": "^\\S", + "title": "Group", + "description": "Name of a test group (mutually exclusive with name)" + } + }, + "additionalProperties": false, + "type": "object" + }, "TestSuiteConfig": { "properties": { "description": { diff --git a/schemas/azldev.schema.json b/schemas/azldev.schema.json index d665b45f9..95a0fe536 100644 --- a/schemas/azldev.schema.json +++ b/schemas/azldev.schema.json @@ -173,6 +173,11 @@ "$ref": "#/$defs/ComponentPublishConfig", "title": "Publish settings", "description": "Component-level publish channel settings" + }, + "tests": { + "$ref": "#/$defs/ComponentTestsConfig", + "title": "Tests", + "description": "Per-component test or test-group references" } }, "additionalProperties": false, @@ -347,6 +352,20 @@ "additionalProperties": false, "type": "object" }, + "ComponentTestsConfig": { + "properties": { + "tests": { + "items": { + "$ref": "#/$defs/TestRef" + }, + "type": "array", + "title": "Tests", + "description": "Per-component test or test-group references" + } + }, + "additionalProperties": false, + "type": "object" + }, "ConfigFile": { "properties": { "$schema": { @@ -432,6 +451,22 @@ "type": "object", "title": "Test Suites", "description": "Definitions of test suites for this project" + }, + "tests": { + "additionalProperties": { + "$ref": "#/$defs/TestDefinition" + }, + "type": "object", + "title": "Tests", + "description": "Definitions of individual tests" + }, + "test-groups": { + "additionalProperties": { + "$ref": "#/$defs/TestGroup" + }, + "type": "object", + "title": "Test Groups", + "description": "Definitions of named bundles of tests" } }, "additionalProperties": false, @@ -710,6 +745,14 @@ "type": "array", "title": "Test Suites", "description": "List of test suite references that apply to this image" + }, + "tests": { + "items": { + "$ref": "#/$defs/TestRef" + }, + "type": "array", + "title": "Tests", + "description": "List of test or test-group references that apply to this image" } }, "additionalProperties": false, @@ -1288,6 +1331,213 @@ "subpath" ] }, + "TestDefinition": { + "allOf": [ + { + "if": { + "properties": { + "type": { + "const": "pytest" + } + } + }, + "then": { + "not": { + "anyOf": [ + { + "required": [ + "lisa" + ] + }, + { + "required": [ + "tmt" + ] + } + ] + }, + "required": [ + "pytest" + ] + } + }, + { + "if": { + "properties": { + "type": { + "const": "lisa" + } + } + }, + "then": { + "not": { + "anyOf": [ + { + "required": [ + "pytest" + ] + }, + { + "required": [ + "tmt" + ] + } + ] + }, + "required": [ + "lisa" + ] + } + }, + { + "if": { + "properties": { + "type": { + "const": "tmt" + } + } + }, + "then": { + "not": { + "anyOf": [ + { + "required": [ + "pytest" + ] + }, + { + "required": [ + "lisa" + ] + } + ] + }, + "required": [ + "tmt" + ] + } + } + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "pytest", + "lisa", + "tmt" + ], + "title": "Type", + "description": "Test framework type" + }, + "description": { + "type": "string", + "title": "Description", + "description": "Description of this test" + }, + "kind": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Kind", + "description": "Test kind hints (e.g. functional or performance)" + }, + "long-running": { + "type": "boolean", + "title": "Long running", + "description": "Hints that this test may run for hours" + }, + "required-capabilities": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Required capabilities", + "description": "Capability tokens the image must declare" + }, + "lisa": { + "type": "object", + "title": "LISA config", + "description": "LISA-specific configuration" + }, + "tmt": { + "type": "object", + "title": "TMT config", + "description": "TMT-specific configuration" + }, + "pytest": { + "type": "object", + "title": "Pytest config", + "description": "pytest-specific configuration" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "type" + ] + }, + "TestGroup": { + "properties": { + "description": { + "type": "string", + "title": "Description", + "description": "Description of this test group" + }, + "tests": { + "items": { + "$ref": "#/$defs/TestRef" + }, + "type": "array", + "title": "Tests", + "description": "Member references (each is either {name=...} or {group=...})" + } + }, + "additionalProperties": false, + "type": "object" + }, + "TestRef": { + "oneOf": [ + { + "not": { + "required": [ + "group" + ] + }, + "required": [ + "name" + ] + }, + { + "not": { + "required": [ + "name" + ] + }, + "required": [ + "group" + ] + } + ], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "pattern": "^\\S", + "title": "Name", + "description": "Name of a test (mutually exclusive with group)" + }, + "group": { + "type": "string", + "minLength": 1, + "pattern": "^\\S", + "title": "Group", + "description": "Name of a test group (mutually exclusive with name)" + } + }, + "additionalProperties": false, + "type": "object" + }, "TestSuiteConfig": { "properties": { "description": { From c5fb905bbc6897130af866baddee88eab48e7f75 Mon Sep 17 00:00:00 2001 From: bhagyapathak Date: Thu, 2 Jul 2026 20:26:03 +0530 Subject: [PATCH 02/20] Strengthen test metadata validation and reference integrity checks --- docs/user/reference/config/tests.md | 17 +- internal/projectconfig/configfile.go | 196 +++++++++- internal/projectconfig/configfile_test.go | 243 +++++++++++++ internal/projectconfig/tests.go | 340 +++++++++++++++++- internal/projectconfig/testsuite.go | 14 + ...ainer_config_generate-schema_stdout_1.snap | 31 +- ...shots_config_generate-schema_stdout_1.snap | 31 +- schemas/azldev.schema.json | 31 +- 8 files changed, 857 insertions(+), 46 deletions(-) diff --git a/docs/user/reference/config/tests.md b/docs/user/reference/config/tests.md index 990e34aa6..a64424128 100644 --- a/docs/user/reference/config/tests.md +++ b/docs/user/reference/config/tests.md @@ -4,7 +4,7 @@ The `[tests]` and `[test-groups]` sections declare framework-agnostic test metadata that components and images can target by name. Each test entry binds a single test (a pytest run, a LISA case, or a TMT plan) to a named identifier; each group entry bundles tests (and -nested groups) under one name so callers can reference a curated set +named references) under one name so callers can reference a curated set without enumerating every member. ## Test Definition @@ -19,7 +19,7 @@ by azldev so frameworks can evolve independently. |-------|----------|------|----------|-------------| | Type | `type` | string | Yes | Test framework: `pytest`, `lisa`, or `tmt` | | Description | `description` | string | No | Human-readable description | -| Kind | `kind` | string array | No | Free-form hints (e.g. `functional`, `performance`, `bvt`) | +| Kind | `kind` | string | No | Test kind hint: `functional` or `performance` | | Long running | `long-running` | boolean | No | Hints that this test may run for hours | | Required capabilities | `required-capabilities` | string array | No | Capability tokens the image must declare for this test to be applicable | | Lisa | `lisa` | table | No | LISA-specific configuration (opaque to azldev) | @@ -28,13 +28,13 @@ by azldev so frameworks can evolve independently. ## Test Group -Each entry under `[test-groups.]` names an ordered list of test or -nested-group references that callers can target as a single unit. +Each entry under `[test-groups.]` names an ordered list of test +references that callers can target as a single unit. | Field | TOML Key | Type | Required | Description | |-------|----------|------|----------|-------------| | Description | `description` | string | No | Human-readable description | -| Tests | `tests` | array of [TestRef](#test-reference) | No | Ordered members of the group | +| Tests | `tests` | array of [TestRef](#test-reference) | No | Ordered members of the group (name refs only) | ## Test Reference @@ -64,7 +64,7 @@ tests = [{ group = "bvt" }] [tests.bvt-ssh] type = "pytest" description = "Basic SSH boot verification" -kind = ["functional", "bvt"] +kind = "functional" required-capabilities = ["ssh"] pytest = { working-dir = "tests/bvt", test-paths = ["test_ssh.py"] } @@ -77,11 +77,8 @@ lisa = { case = "kdump.smoke" } description = "Build verification tests" tests = [ { name = "bvt-ssh" }, - { group = "bvt-extras" }, + { name = "kdump-smoke" }, ] - -[test-groups.bvt-extras] -tests = [{ name = "kdump-smoke" }] ``` ## Related Resources diff --git a/internal/projectconfig/configfile.go b/internal/projectconfig/configfile.go index 6eab18acc..cb102e5d2 100644 --- a/internal/projectconfig/configfile.go +++ b/internal/projectconfig/configfile.go @@ -137,8 +137,23 @@ func (f ConfigFile) Validate() error { } } - // Validate test suite configurations. - for suiteName, suite := range f.TestSuites { + if err := validateTestSuites(f.TestSuites); err != nil { + return err + } + + if err := validateTestDefinitions(f.Tests); err != nil { + return err + } + + if err := validateNewTestReferences(f); err != nil { + return err + } + + return nil +} + +func validateTestSuites(testSuites map[string]TestSuiteConfig) error { + for suiteName, suite := range testSuites { // Suite names are used as path components (e.g., for the per-suite venv directory), // so reject anything that could escape the intended directory or otherwise be unsafe // across platforms. @@ -153,8 +168,11 @@ func (f ConfigFile) Validate() error { } } - // Validate individual test definitions and ensure framework subtable/type match. - for testName, testDef := range f.Tests { + return nil +} + +func validateTestDefinitions(tests map[string]TestDefinition) error { + for testName, testDef := range tests { if err := testDef.Validate(testName); err != nil { return fmt.Errorf("invalid test %#q:\n%w", testName, err) } @@ -163,6 +181,176 @@ func (f ConfigFile) Validate() error { return nil } +func validateNewTestReferences(cfgFile ConfigFile) error { + for groupName, group := range cfgFile.TestGroups { + scope := fmt.Sprintf("test-group %#q tests", groupName) + if err := validateTestGroupMembers(scope, group.Tests, cfgFile.Tests); err != nil { + return err + } + } + + for componentName, component := range cfgFile.Components { + if component.Tests == nil { + continue + } + + scope := fmt.Sprintf("component %#q tests.tests", componentName) + if err := validateTestRefList(scope, component.Tests.Tests, cfgFile.Tests, cfgFile.TestGroups); err != nil { + return err + } + } + + for imageName, image := range cfgFile.Images { + if image.Tests == nil { + continue + } + + scope := fmt.Sprintf("image %#q tests.tests", imageName) + if err := validateTestRefList(scope, image.Tests.Tests, cfgFile.Tests, cfgFile.TestGroups); err != nil { + return err + } + } + + return nil +} + +func validateTestGroupMembers( + scope string, + refs []TestRef, + tests map[string]TestDefinition, +) error { + seenRefs := make(map[string]int, len(refs)) + + for idx, ref := range refs { + hasName := ref.Name != "" + hasGroup := ref.Group != "" + + if hasName == hasGroup { + return fmt.Errorf( + "%w: %s[%d] must set exactly one of 'name' or 'group'", + ErrInvalidTestRef, + scope, + idx, + ) + } + + if hasGroup { + return fmt.Errorf( + "%w: %s[%d].group is not allowed in [test-groups]; use .name to reference a [tests] entry", + ErrNestedTestGroupReference, + scope, + idx, + ) + } + + if _, ok := tests[ref.Name]; !ok { + return fmt.Errorf( + "%w: %s[%d].name references undefined test %#q", + ErrUndefinedTest, + scope, + idx, + ref.Name, + ) + } + + refKey := "name:" + ref.Name + if firstIdx, exists := seenRefs[refKey]; exists { + return fmt.Errorf( + "%w: %s[%d] duplicates %s[%d] (%#q)", + ErrDuplicateTestRef, + scope, + idx, + scope, + firstIdx, + ref.Name, + ) + } + + seenRefs[refKey] = idx + } + + return nil +} + +func validateTestRefList( + scope string, + refs []TestRef, + tests map[string]TestDefinition, + groups map[string]TestGroup, +) error { + seenRefs := make(map[string]int, len(refs)) + + for idx, ref := range refs { + hasName := ref.Name != "" + hasGroup := ref.Group != "" + + if hasName == hasGroup { + return fmt.Errorf( + "%w: %s[%d] must set exactly one of 'name' or 'group'", + ErrInvalidTestRef, + scope, + idx, + ) + } + + if hasName { + if _, ok := tests[ref.Name]; !ok { + return fmt.Errorf( + "%w: %s[%d].name references undefined test %#q", + ErrUndefinedTest, + scope, + idx, + ref.Name, + ) + } + + refKey := "name:" + ref.Name + if firstIdx, exists := seenRefs[refKey]; exists { + return fmt.Errorf( + "%w: %s[%d] duplicates %s[%d] (%#q)", + ErrDuplicateTestRef, + scope, + idx, + scope, + firstIdx, + ref.Name, + ) + } + + seenRefs[refKey] = idx + + continue + } + + if _, ok := groups[ref.Group]; !ok { + return fmt.Errorf( + "%w: %s[%d].group references undefined test-group %#q", + ErrUndefinedTestGroup, + scope, + idx, + ref.Group, + ) + } + + refKey := "group:" + ref.Group + if firstIdx, exists := seenRefs[refKey]; exists { + return fmt.Errorf( + "%w: %s[%d] duplicates %s[%d] (%#q)", + ErrDuplicateTestRef, + scope, + idx, + scope, + firstIdx, + ref.Group, + ) + } + + seenRefs[refKey] = idx + } + + return nil +} + // validateSourceFiles checks 'source-files' configuration for a component: // - All filenames must be unique. // - Hash type must be a supported algorithm when specified. diff --git a/internal/projectconfig/configfile_test.go b/internal/projectconfig/configfile_test.go index b234b9c32..f09e787f4 100644 --- a/internal/projectconfig/configfile_test.go +++ b/internal/projectconfig/configfile_test.go @@ -49,6 +49,249 @@ func TestProjectConfigFileValidation_TestDefinitionMatchingSubtable(t *testing.T assert.NoError(t, file.Validate()) } +func TestProjectConfigFileValidation_TestDefinitionRequiredSubtablePresentButEmpty(t *testing.T) { + file := projectconfig.ConfigFile{ + Tests: map[string]projectconfig.TestDefinition{ + "smoke": { + Type: "pytest", + Pytest: map[string]any{}, + }, + }, + } + + assert.NoError(t, file.Validate()) +} + +func TestProjectConfigFileValidation_TestDefinitionDisallowedSubtablePresentButEmpty(t *testing.T) { + file := projectconfig.ConfigFile{ + Tests: map[string]projectconfig.TestDefinition{ + "smoke": { + Type: "pytest", + Pytest: map[string]any{"working-dir": "tests"}, + Lisa: map[string]any{}, + }, + }, + } + + err := file.Validate() + require.Error(t, err) + require.ErrorIs(t, err, projectconfig.ErrMismatchedTestSubtable) + assert.Contains(t, err.Error(), "smoke") + assert.Contains(t, err.Error(), "lisa") +} + +func TestProjectConfigFileValidation_TestDefinitionInvalidKind(t *testing.T) { + file := projectconfig.ConfigFile{ + Tests: map[string]projectconfig.TestDefinition{ + "smoke": { + Type: "pytest", + Kind: projectconfig.TestKind("unknown-kind"), + Pytest: map[string]any{"working-dir": "tests"}, + }, + }, + } + + err := file.Validate() + require.Error(t, err) + require.ErrorIs(t, err, projectconfig.ErrUnknownTestKind) + assert.Contains(t, err.Error(), "unknown-kind") +} + +func TestProjectConfigFileValidation_LisaSelectionMissing(t *testing.T) { + file := projectconfig.ConfigFile{ + Tests: map[string]projectconfig.TestDefinition{ + "smoke": { + Type: "lisa", + Lisa: map[string]any{ + "source": map[string]any{"git-url": "https://example.com/lisa.git", "ref": "main"}, + }, + }, + }, + } + + err := file.Validate() + require.Error(t, err) + require.ErrorIs(t, err, projectconfig.ErrInvalidLisaSelection) + assert.Contains(t, err.Error(), "must set at least one LISA selector") +} + +func TestProjectConfigFileValidation_LisaSelectionCriteriaValid(t *testing.T) { + file := projectconfig.ConfigFile{ + Tests: map[string]projectconfig.TestDefinition{ + "smoke": { + Type: "lisa", + Lisa: map[string]any{ + "criteria": map[string]any{"priority": []any{1, 2}, "tags": []any{"vm", "smoke"}}, + }, + }, + "perf": { + Type: "lisa", + Lisa: map[string]any{ + "criteria": []any{ + map[string]any{"area": "network", "category": "performance"}, + map[string]any{"testcaseNames": []any{"case_a", "case_b"}}, + }, + }, + }, + }, + } + + assert.NoError(t, file.Validate()) +} + +func TestProjectConfigFileValidation_LisaSelectionUnsupportedCriteriaKey(t *testing.T) { + file := projectconfig.ConfigFile{ + Tests: map[string]projectconfig.TestDefinition{ + "smoke": { + Type: "lisa", + Lisa: map[string]any{ + "criteria": map[string]any{"suite": "smoke"}, + }, + }, + }, + } + + err := file.Validate() + require.Error(t, err) + require.ErrorIs(t, err, projectconfig.ErrInvalidLisaSelection) + assert.Contains(t, err.Error(), "unsupported selector") +} + +func TestProjectConfigFileValidation_UndefinedTestReferenceInGroup(t *testing.T) { + file := projectconfig.ConfigFile{ + TestGroups: map[string]projectconfig.TestGroup{ + "bvt": { + Tests: []projectconfig.TestRef{{Name: "does-not-exist"}}, + }, + }, + } + + err := file.Validate() + require.Error(t, err) + require.ErrorIs(t, err, projectconfig.ErrUndefinedTest) + assert.Contains(t, err.Error(), "does-not-exist") +} + +func TestProjectConfigFileValidation_UndefinedTestGroupReferenceInComponent(t *testing.T) { + file := projectconfig.ConfigFile{ + Components: map[string]projectconfig.ComponentConfig{ + "openssl": { + Tests: &projectconfig.ComponentTestsConfig{ + Tests: []projectconfig.TestRef{{Group: "missing-group"}}, + }, + }, + }, + } + + err := file.Validate() + require.Error(t, err) + require.ErrorIs(t, err, projectconfig.ErrUndefinedTestGroup) + assert.Contains(t, err.Error(), "missing-group") +} + +func TestProjectConfigFileValidation_InvalidTestReferenceShapeInImage(t *testing.T) { + file := projectconfig.ConfigFile{ + Images: map[string]projectconfig.ImageConfig{ + "base": { + Tests: &projectconfig.ImageTestsConfig{ + Tests: []projectconfig.TestRef{{Name: "smoke", Group: "bvt"}}, + }, + }, + }, + Tests: map[string]projectconfig.TestDefinition{ + "smoke": { + Type: "pytest", + Pytest: map[string]any{"working-dir": "tests"}, + }, + }, + TestGroups: map[string]projectconfig.TestGroup{ + "bvt": {Tests: []projectconfig.TestRef{{Name: "smoke"}}}, + }, + } + + err := file.Validate() + require.Error(t, err) + require.ErrorIs(t, err, projectconfig.ErrInvalidTestRef) + assert.Contains(t, err.Error(), "exactly one") +} + +func TestProjectConfigFileValidation_DuplicateTestReferenceInGroup(t *testing.T) { + file := projectconfig.ConfigFile{ + Tests: map[string]projectconfig.TestDefinition{ + "smoke": { + Type: "pytest", + Pytest: map[string]any{"working-dir": "tests"}, + }, + }, + TestGroups: map[string]projectconfig.TestGroup{ + "bvt": { + Tests: []projectconfig.TestRef{ + {Name: "smoke"}, + {Name: "smoke"}, + }, + }, + }, + } + + err := file.Validate() + require.Error(t, err) + require.ErrorIs(t, err, projectconfig.ErrDuplicateTestRef) + assert.Contains(t, err.Error(), "duplicates") + assert.Contains(t, err.Error(), "smoke") +} + +func TestProjectConfigFileValidation_DuplicateTestGroupReferenceInImage(t *testing.T) { + file := projectconfig.ConfigFile{ + Tests: map[string]projectconfig.TestDefinition{ + "smoke": { + Type: "pytest", + Pytest: map[string]any{"working-dir": "tests"}, + }, + }, + TestGroups: map[string]projectconfig.TestGroup{ + "bvt": { + Tests: []projectconfig.TestRef{{Name: "smoke"}}, + }, + }, + Images: map[string]projectconfig.ImageConfig{ + "base": { + Tests: &projectconfig.ImageTestsConfig{ + Tests: []projectconfig.TestRef{ + {Group: "bvt"}, + {Group: "bvt"}, + }, + }, + }, + }, + } + + err := file.Validate() + require.Error(t, err) + require.ErrorIs(t, err, projectconfig.ErrDuplicateTestRef) + assert.Contains(t, err.Error(), "duplicates") + assert.Contains(t, err.Error(), "bvt") +} + +func TestProjectConfigFileValidation_NestedTestGroupReferenceNotAllowed(t *testing.T) { + file := projectconfig.ConfigFile{ + Tests: map[string]projectconfig.TestDefinition{ + "smoke": { + Type: "pytest", + Pytest: map[string]any{"working-dir": "tests"}, + }, + }, + TestGroups: map[string]projectconfig.TestGroup{ + "a": {Tests: []projectconfig.TestRef{{Group: "b"}}}, + "b": {Tests: []projectconfig.TestRef{{Name: "smoke"}}}, + }, + } + + err := file.Validate() + require.Error(t, err) + require.ErrorIs(t, err, projectconfig.ErrNestedTestGroupReference) + assert.Contains(t, err.Error(), "is not allowed in [test-groups]") +} + func TestProjectConfigFileValidation_DefaultProjectInfo(t *testing.T) { file := projectconfig.ConfigFile{ Project: &projectconfig.ProjectInfo{}, diff --git a/internal/projectconfig/tests.go b/internal/projectconfig/tests.go index 601a92a0e..7e06b0d5f 100644 --- a/internal/projectconfig/tests.go +++ b/internal/projectconfig/tests.go @@ -4,12 +4,35 @@ package projectconfig import ( + "errors" "fmt" + "strconv" + "strings" "github.com/invopop/jsonschema" orderedmap "github.com/pb33f/ordered-map/v2" ) +// TestKind indicates what kind of behavior a test exercises. +type TestKind string + +const ( + TestKindFunctional TestKind = "functional" + TestKindPerformance TestKind = "performance" +) + +func (k TestKind) IsValid() bool { + switch k { + case "": + return true + case TestKindFunctional, + TestKindPerformance: + return true + default: + return false + } +} + func orderedMapWithConst(key string, value any) *orderedmap.OrderedMap[string, *jsonschema.Schema] { m := orderedmap.New[string, *jsonschema.Schema]() m.Set(key, &jsonschema.Schema{Const: value}) @@ -30,8 +53,8 @@ type TestDefinition struct { // Human-readable description. Description string `toml:"description,omitempty" json:"description,omitempty" jsonschema:"title=Description,description=Description of this test"` - // Kind hints at what the test exercises (e.g. functional, performance). - Kind []string `toml:"kind,omitempty" json:"kind,omitempty" jsonschema:"title=Kind,description=Test kind hints (e.g. functional or performance)"` + // Kind hints at what the test exercises. + Kind TestKind `toml:"kind,omitempty" json:"kind,omitempty" jsonschema:"title=Kind,description=Kind hint for the test,enum=functional,enum=performance"` // LongRunning hints to schedulers/policy that this test may take a long time. LongRunning bool `toml:"long-running,omitempty" json:"longRunning,omitempty" jsonschema:"title=Long running,description=Hints that this test may run for hours"` @@ -53,9 +76,9 @@ type TestGroup struct { // Human-readable description. Description string `toml:"description,omitempty" json:"description,omitempty" jsonschema:"title=Description,description=Description of this test group"` - // Tests is the ordered list of test or nested-group references that make up - // the group's membership. - Tests []TestRef `toml:"tests,omitempty" json:"tests,omitempty" jsonschema:"title=Tests,description=Member references (each is either {name=...} or {group=...})"` + // Tests is the ordered list of test references that make up the group's + // membership. Group refs are validated as invalid at load time. + Tests []TestRef `toml:"tests,omitempty" json:"tests,omitempty" jsonschema:"title=Tests,description=Ordered test references for this group (name only)"` } // TestRef is a reference to either a test (by name) or another group (by name). @@ -83,6 +106,10 @@ func (t TestDefinition) Validate(testName string) error { return fmt.Errorf("%w: test %#q is missing required field 'type'", ErrMissingTestField, testName) } + if !t.Kind.IsValid() { + return fmt.Errorf("%w: test %#q has invalid kind %#q", ErrUnknownTestKind, testName, t.Kind) + } + type testTypeRule struct { required string disallowed []string @@ -99,13 +126,13 @@ func (t TestDefinition) Validate(testName string) error { return fmt.Errorf("%w: %#q (test: %#q)", ErrUnknownTestType, t.Type, testName) } - subtableLengths := map[string]int{ - "pytest": len(t.Pytest), - "lisa": len(t.Lisa), - "tmt": len(t.Tmt), + subtablePresence := map[string]bool{ + "pytest": t.Pytest != nil, + "lisa": t.Lisa != nil, + "tmt": t.Tmt != nil, } - if subtableLengths[rule.required] == 0 { + if !subtablePresence[rule.required] { return fmt.Errorf( "%w: test %#q of type %#q requires a [%s] subtable", ErrMissingTestField, @@ -116,7 +143,7 @@ func (t TestDefinition) Validate(testName string) error { } for _, subtable := range rule.disallowed { - if subtableLengths[subtable] > 0 { + if subtablePresence[subtable] { return fmt.Errorf( "%w: test %#q of type %#q cannot include subtable '%s'", ErrMismatchedTestSubtable, @@ -127,9 +154,300 @@ func (t TestDefinition) Validate(testName string) error { } } + if t.Type == "lisa" { + if err := validateLisaSelection(t.Lisa, testName); err != nil { + return err + } + } + return nil } +// JSONSchemaExtend narrows [TestGroup.Tests] to name-only refs so editor-time +// validation matches runtime behavior (group refs in [test-groups] are rejected). +func (TestGroup) JSONSchemaExtend(schema *jsonschema.Schema) { + if schema == nil || schema.Properties == nil { + return + } + + testsProp, ok := schema.Properties.Get("tests") + if !ok || testsProp == nil { + return + } + + minLen := uint64(1) + itemProps := orderedmap.New[string, *jsonschema.Schema]() + itemProps.Set("name", &jsonschema.Schema{ + Type: "string", + MinLength: &minLen, + Pattern: "^\\S", + Description: "Name of a test", + }) + + testsProp.Items = &jsonschema.Schema{ + Type: "object", + Properties: itemProps, + Required: []string{"name"}, + Not: &jsonschema.Schema{ + Required: []string{"group"}, + }, + } +} + +func validateLisaSelection(lisa map[string]any, testName string) error { + hasSelector := false + + if rawCriteria, ok := lisa["criteria"]; ok { + hasSelector = true + + if err := validateLisaCriteria(rawCriteria, testName); err != nil { + return err + } + } + + if rawName, ok := lisa["testcaseName"]; ok { + hasSelector = true + + if !isNonEmptyString(rawName) { + return fmt.Errorf( + "%w: test %#q lisa.testcaseName must be a non-empty string", + ErrInvalidLisaSelection, + testName, + ) + } + } + + if rawName, ok := lisa["name"]; ok { + hasSelector = true + + if !isNonEmptyString(rawName) { + return fmt.Errorf( + "%w: test %#q lisa.name must be a non-empty string", + ErrInvalidLisaSelection, + testName, + ) + } + } + + if rawNames, ok := lisa["testcaseNames"]; ok { + hasSelector = true + + if err := validateStringList(rawNames, "lisa.testcaseNames", testName); err != nil { + return err + } + } + + if !hasSelector { + return fmt.Errorf( + "%w: test %#q of type %#q must set at least one LISA selector: criteria, testcaseName, testcaseNames, or name", + ErrInvalidLisaSelection, + testName, + "lisa", + ) + } + + return nil +} + +func validateLisaCriteria(rawCriteria any, testName string) error { + criteriaList, err := normalizeCriteriaList(rawCriteria) + if err != nil { + return fmt.Errorf("%w: test %#q lisa.criteria %w", ErrInvalidLisaSelection, testName, err) + } + + for idx, criteria := range criteriaList { + if err := validateSingleLisaCriteria(criteria, testName, idx); err != nil { + return err + } + } + + return nil +} + +func normalizeCriteriaList(rawCriteria any) ([]map[string]any, error) { + switch criteriaValue := rawCriteria.(type) { + case map[string]any: + if len(criteriaValue) == 0 { + return nil, errors.New("must not be empty") + } + + return []map[string]any{criteriaValue}, nil + case []any: + if len(criteriaValue) == 0 { + return nil, errors.New("must not be an empty list") + } + + result := make([]map[string]any, 0, len(criteriaValue)) + + for entryIndex, item := range criteriaValue { + criteriaMap, ok := item.(map[string]any) + if !ok { + return nil, fmt.Errorf("entry %d must be a table/object", entryIndex) + } + + if len(criteriaMap) == 0 { + return nil, fmt.Errorf("entry %d must not be empty", entryIndex) + } + + result = append(result, criteriaMap) + } + + return result, nil + default: + return nil, errors.New("must be a table or list of tables") + } +} + +func validateSingleLisaCriteria(criteria map[string]any, testName string, idx int) error { + allowedKeys := map[string]bool{ + "name": true, + "area": true, + "category": true, + "priority": true, + "tags": true, + "testcaseName": true, + "testcaseNames": true, + } + + hasSelector := false + + for key, value := range criteria { + if !allowedKeys[key] { + return fmt.Errorf( + "%w: test %#q lisa.criteria[%d] contains unsupported selector %#q", + ErrInvalidLisaSelection, + testName, + idx, + key, + ) + } + + switch key { + case "name", "area", "category", "testcaseName": + if !isNonEmptyString(value) { + return fmt.Errorf( + "%w: test %#q lisa.criteria[%d].%s must be a non-empty string", + ErrInvalidLisaSelection, + testName, + idx, + key, + ) + } + + hasSelector = true + case "priority": + if err := validateLisaPriority(value, testName, idx); err != nil { + return err + } + + hasSelector = true + case "tags", "testcaseNames": + fieldName := "lisa.criteria[" + strconv.Itoa(idx) + "]." + key + + if err := validateStringList(value, fieldName, testName); err != nil { + return err + } + + hasSelector = true + } + } + + if !hasSelector { + return fmt.Errorf( + "%w: test %#q lisa.criteria[%d] must include at least one selector", + ErrInvalidLisaSelection, + testName, + idx, + ) + } + + return nil +} + +func validateLisaPriority(value any, testName string, idx int) error { + if isLisaPriorityValue(value) { + return nil + } + + return fmt.Errorf( + "%w: test %#q lisa.criteria[%d].priority must be an integer 0..4 or a non-empty list of integers 0..4", + ErrInvalidLisaSelection, + testName, + idx, + ) +} + +func isLisaPriorityValue(value any) bool { + if parsed, ok := parseLisaPriority(value); ok { + return parsed >= 0 && parsed <= 4 + } + + priorityList, ok := value.([]any) + if !ok || len(priorityList) == 0 { + return false + } + + for _, item := range priorityList { + parsed, ok := parseLisaPriority(item) + if !ok || parsed < 0 || parsed > 4 { + return false + } + } + + return true +} + +func parseLisaPriority(value any) (int, bool) { + switch typed := value.(type) { + case int: + return typed, true + case int64: + return int(typed), true + case float64: + if typed != float64(int(typed)) { + return 0, false + } + + return int(typed), true + default: + return 0, false + } +} + +func validateStringList(value any, fieldName string, testName string) error { + items, ok := value.([]any) + if !ok || len(items) == 0 { + return fmt.Errorf( + "%w: test %#q %s must be a non-empty list of non-empty strings", + ErrInvalidLisaSelection, + testName, + fieldName, + ) + } + + for _, item := range items { + if !isNonEmptyString(item) { + return fmt.Errorf( + "%w: test %#q %s must be a non-empty list of non-empty strings", + ErrInvalidLisaSelection, + testName, + fieldName, + ) + } + } + + return nil +} + +func isNonEmptyString(value any) bool { + s, ok := value.(string) + if !ok { + return false + } + + return strings.TrimSpace(s) != "" +} + // JSONSchemaExtend tightens [TestDefinition] so the framework-specific subtable // must match the declared type. func (TestDefinition) JSONSchemaExtend(schema *jsonschema.Schema) { diff --git a/internal/projectconfig/testsuite.go b/internal/projectconfig/testsuite.go index d8424592c..0c19d329c 100644 --- a/internal/projectconfig/testsuite.go +++ b/internal/projectconfig/testsuite.go @@ -29,9 +29,23 @@ var ( ErrMissingTestField = errors.New("missing required test field") // ErrUndefinedTestSuite is returned when an image references a test suite name that is not defined. ErrUndefinedTestSuite = errors.New("undefined test suite reference") + // ErrUndefinedTest is returned when a test reference points to a missing [tests] entry. + ErrUndefinedTest = errors.New("undefined test reference") + // ErrUndefinedTestGroup is returned when a test reference points to a missing [test-groups] entry. + ErrUndefinedTestGroup = errors.New("undefined test group reference") + // ErrInvalidTestRef is returned when a TestRef has neither or both of name/group set. + ErrInvalidTestRef = errors.New("invalid test reference") + // ErrDuplicateTestRef is returned when a list contains the same test ref more than once. + ErrDuplicateTestRef = errors.New("duplicate test reference") + // ErrNestedTestGroupReference is returned when a [test-groups] member uses a group ref. + ErrNestedTestGroupReference = errors.New("nested test group reference") // ErrMismatchedTestSubtable is returned when a test config has a subtable that does not // match its declared type. ErrMismatchedTestSubtable = errors.New("mismatched test subtable") + // ErrUnknownTestKind is returned for unrecognized test kinds. + ErrUnknownTestKind = errors.New("unknown test kind") + // ErrInvalidLisaSelection is returned when a lisa test has invalid or missing selectors. + ErrInvalidLisaSelection = errors.New("invalid lisa selection") // ErrInvalidInstallMode is returned when a [PytestConfig.Install] value is not recognized. ErrInvalidInstallMode = errors.New("invalid install mode") ) diff --git a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap index 95a0fe536..e9ed6d37c 100755 --- a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap @@ -1435,12 +1435,13 @@ "description": "Description of this test" }, "kind": { - "items": { - "type": "string" - }, - "type": "array", + "type": "string", + "enum": [ + "functional", + "performance" + ], "title": "Kind", - "description": "Test kind hints (e.g. functional or performance)" + "description": "Kind hint for the test" }, "long-running": { "type": "boolean", @@ -1486,11 +1487,27 @@ }, "tests": { "items": { - "$ref": "#/$defs/TestRef" + "not": { + "required": [ + "group" + ] + }, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "pattern": "^\\S", + "description": "Name of a test" + } + }, + "type": "object", + "required": [ + "name" + ] }, "type": "array", "title": "Tests", - "description": "Member references (each is either {name=...} or {group=...})" + "description": "Ordered test references for this group (name only)" } }, "additionalProperties": false, diff --git a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap index 95a0fe536..e9ed6d37c 100755 --- a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap @@ -1435,12 +1435,13 @@ "description": "Description of this test" }, "kind": { - "items": { - "type": "string" - }, - "type": "array", + "type": "string", + "enum": [ + "functional", + "performance" + ], "title": "Kind", - "description": "Test kind hints (e.g. functional or performance)" + "description": "Kind hint for the test" }, "long-running": { "type": "boolean", @@ -1486,11 +1487,27 @@ }, "tests": { "items": { - "$ref": "#/$defs/TestRef" + "not": { + "required": [ + "group" + ] + }, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "pattern": "^\\S", + "description": "Name of a test" + } + }, + "type": "object", + "required": [ + "name" + ] }, "type": "array", "title": "Tests", - "description": "Member references (each is either {name=...} or {group=...})" + "description": "Ordered test references for this group (name only)" } }, "additionalProperties": false, diff --git a/schemas/azldev.schema.json b/schemas/azldev.schema.json index 95a0fe536..e9ed6d37c 100644 --- a/schemas/azldev.schema.json +++ b/schemas/azldev.schema.json @@ -1435,12 +1435,13 @@ "description": "Description of this test" }, "kind": { - "items": { - "type": "string" - }, - "type": "array", + "type": "string", + "enum": [ + "functional", + "performance" + ], "title": "Kind", - "description": "Test kind hints (e.g. functional or performance)" + "description": "Kind hint for the test" }, "long-running": { "type": "boolean", @@ -1486,11 +1487,27 @@ }, "tests": { "items": { - "$ref": "#/$defs/TestRef" + "not": { + "required": [ + "group" + ] + }, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "pattern": "^\\S", + "description": "Name of a test" + } + }, + "type": "object", + "required": [ + "name" + ] }, "type": "array", "title": "Tests", - "description": "Member references (each is either {name=...} or {group=...})" + "description": "Ordered test references for this group (name only)" } }, "additionalProperties": false, From 23e544725f0a1d1d13b1d3a53b4bfba61362590f Mon Sep 17 00:00:00 2001 From: Daniel McIlvaney Date: Wed, 1 Jul 2026 11:25:02 -0700 Subject: [PATCH 03/20] chore(release): add release tools to mage (#254) --- .github/dependabot.yml | 14 ++ CHANGELOG.md | 50 +++++++ README.md | 12 +- cliff.toml | 81 ++++++++++++ cmd/azldev/azldev.go | 12 ++ docs/developer/README.md | 1 + docs/developer/how-to/releasing.md | 143 ++++++++++++++++++++ magefiles/magefile.go | 2 + magefiles/magerelease/magerelease.go | 190 +++++++++++++++++++++++++++ pkg/app/azldev_cli/azldev.go | 10 +- tools/README.md | 2 + tools/git-cliff/Cargo.toml | 14 ++ tools/git-cliff/src/lib.rs | 3 + 13 files changed, 532 insertions(+), 2 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 cliff.toml create mode 100644 docs/developer/how-to/releasing.md create mode 100644 magefiles/magerelease/magerelease.go create mode 100644 tools/git-cliff/Cargo.toml create mode 100644 tools/git-cliff/src/lib.rs diff --git a/.github/dependabot.yml b/.github/dependabot.yml index e5704ab87..c716b17df 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -65,3 +65,17 @@ updates: update-types: - "minor" - "patch" + + # git-cliff CLI (used by 'mage changelog'), pinned via a Cargo manifest so the + # version is visible to Dependabot and security scanners. + - package-ecosystem: "cargo" + directory: "/tools/git-cliff" + schedule: + interval: "weekly" + cooldown: + default-days: 7 + groups: + dependabot-cargo-tools: + applies-to: version-updates + patterns: + - "*" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..be9497af7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,50 @@ +# Changelog + + + +All notable changes to `azldev` are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] - 2026-03-18 + +First tagged preview release of `azldev`, the developer CLI for the +[Azure Linux](https://github.com/microsoft/azurelinux) distro. + +### Added + +- **Project and metadata management.** Scaffold a project with `azldev project + init` or `project new`, then parse, resolve, and query the TOML metadata + (`azldev.toml`) that defines Azure Linux. Configuration merges built-in + defaults with project and user-level (XDG) files, is fully validated, and is + published as a JSON Schema via `azldev config generate-schema`. +- **Component inspection and locking.** List and inspect components with `azldev + component list` and `component query`, and import new ones with `component + add`. Deterministic component fingerprints and per-component lock files keep + builds reproducible; `component update` refreshes them with `--check-only`, + `--bump`, freshness-based skipping, a progress bar, and upstream-staleness + detection. `component changed` and `component diff-sources` report what moved. +- **Source preparation and spec rendering.** `component prepare-sources` and + `component render` produce build-ready sources and specs through a + `mock`-based batch pipeline, synthesizing the git history that `rpmautospec` + needs and constructing dist-git from lock-file history. A rich overlay system + (spec search/replace, prepend/append lines, remove section or subpackage, file + and source replacement, per-file overlay files, and inline metadata) + customizes specs, with explicit release-calculation modes (`autorelease`, + `static`, and automatic). Source archives are fetched from lookaside caches. +- **Local package and image builds.** Build individual packages with `mock` + using `component build`, emitting RPMs and SRPMs into structured, + publish-channel-aware output directories. `azldev image` builds, customizes, + injects files into, boots, and runs LISA tests against Azure Linux images on a + local QEMU VM. +- **Package and repository queries.** Inspect binary package configuration with + `azldev package list` (including `--rpm-file`, debug-package synthesis, and + separate package/component group columns), and inspect or manage RPM + repositories with `azldev repo query`, backed by repo resources and repo-set + templates. +- **Command-line experience.** Shell completions for bash, zsh, fish, and + PowerShell; actionable hints on errors; global `--quiet`, `--verbose`, and + `--dry-run` flags with `table`, `json`, `csv`, and `markdown` output formats; + an embedded MCP server (`azldev advanced mcp`); and auto-generated CLI + reference documentation. diff --git a/README.md b/README.md index 588f4ed8a..9227dc4d9 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # Azure Linux Dev Tools +[![Go Reference](https://pkg.go.dev/badge/github.com/microsoft/azure-linux-dev-tools.svg)](https://pkg.go.dev/github.com/microsoft/azure-linux-dev-tools) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE) + Azure Linux Dev Tools is a collection of utilities useful for development of the Azure Linux distro. @@ -28,9 +31,12 @@ It supports: 1. Install `azldev`: ```console - go install github.com/microsoft/azure-linux-dev-tools/cmd/azldev@main + go install github.com/microsoft/azure-linux-dev-tools/cmd/azldev@latest ``` + To pin a specific release instead of tracking the latest, replace `@latest` + with a version tag, e.g. `@v0.1.0`. + 1. To ensure you can build using `mock` you must be a member of the `mock` group, e.g.: ```console @@ -62,6 +68,10 @@ Please see our [Contribution Guidelines](./CONTRIBUTING.md) for our project. For development setup and workflow, please consult our [Developer Guide](./docs/developer). +## Changelog + +Notable changes for each release are recorded in the [changelog](./CHANGELOG.md). + ## Getting Help Have questions, found a bug, or need a new feature? Open an issue in our [GitHub repository](https://github.com/microsoft/azure-linux-dev-tools/issues/new/choose). For guidance on how to file an issue, see [how to report issues](https://aka.ms/azurelinux-reportissues). diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 000000000..e0cb1092b --- /dev/null +++ b/cliff.toml @@ -0,0 +1,81 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# git-cliff configuration for generating azldev's CHANGELOG.md from Conventional +# Commits. Run it via `mage changelog`; see docs/developer/how-to/releasing.md. +# Reference: https://git-cliff.org +# +# The generated entries are a *draft*: a human (or Copilot) prunes and rewords +# them into user-facing notes before a release. Internal commit types (docs, +# test, chore, build, ci, style, refactor, and dependency bumps) are skipped so +# the draft starts close to user-facing. + +[changelog] +# The markdownlint-disable-file comment scopes two relaxations to CHANGELOG.md +# only (no repo-wide markdownlint config): MD013 because auto-generated entries +# are one line per commit and can exceed the line length, and MD024 because the +# Keep a Changelog format repeats '### Added'/'### Fixed' under every version. +header = """ +# Changelog + + + +All notable changes to `azldev` are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +""" +# Per-version section in Keep a Changelog style. `version` is unset when +# rendering unreleased commits, which yields an `## [Unreleased]` heading. The +# leading blank line keeps a blank line above each version heading (markdownlint +# MD022) when the section is prepended below the file header or another version. +body = """ + +{% if version %}\ +## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ +## [Unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} +### {{ group | striptags | trim | upper_first }} +{% for commit in commits %} +- {{ commit.message | split(pat="\n") | first | upper_first | trim }}\ +{% endfor %} +{% endfor %}\ +""" +# Collapse any run of 3+ newlines left by the template into a single blank line +# so the rendered changelog has no double blank lines (markdownlint MD012). +postprocessors = [{ pattern = '\n{3,}', replace = "\n\n" }] +trim = true +footer = "" + +[git] +conventional_commits = true +# Keep non-conventional commits (e.g. early "README.md committed" bootstrap +# commits) instead of treating them as parse errors. They fall through to the +# catch-all `.*` skip parser below, so they're dropped quietly rather than +# emitting a "commit(s) skipped due to parse error(s)" warning during the +# full-history version bump. +filter_unconventional = false +# Drop commits that don't match a kept parser below (cuts internal noise). +filter_commits = true +# ...but never drop a breaking change, even if its type is skipped above. +protect_breaking_commits = true +# Only consider release tags of the form `vX.Y.Z`. +tag_pattern = '^v[0-9]+\.[0-9]+\.[0-9]+$' +sort_commits = "oldest" +commit_parsers = [ + { message = "^feat", group = "Added" }, + { message = "^fix", group = "Fixed" }, + { message = "^perf", group = "Changed" }, + { message = "^revert", group = "Removed" }, + { message = "^refactor", skip = true }, + { message = "^docs", skip = true }, + { message = "^test", skip = true }, + { message = "^chore", skip = true }, + { message = "^build", skip = true }, + { message = "^ci", skip = true }, + { message = "^style", skip = true }, + # Skip anything that didn't match a kept type above. + { message = ".*", skip = true }, +] diff --git a/cmd/azldev/azldev.go b/cmd/azldev/azldev.go index 171c08196..11aaf2e35 100644 --- a/cmd/azldev/azldev.go +++ b/cmd/azldev/azldev.go @@ -1,6 +1,18 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +// Command azldev is a developer tool for working on the Azure Linux distro. +// +// It parses, resolves, and queries the TOML-based metadata that defines Azure +// Linux, prepares component sources for building with mock, fetches source +// archives from lookaside caches, and offers convenience utilities for +// locally building individual packages and images. +// +// Install the latest release with: +// +// go install github.com/microsoft/azure-linux-dev-tools/cmd/azldev@latest +// +// Run "azldev --help" for usage information, or see the user guide under docs/user. package main import ( diff --git a/docs/developer/README.md b/docs/developer/README.md index 1cb735140..a628ce091 100644 --- a/docs/developer/README.md +++ b/docs/developer/README.md @@ -10,6 +10,7 @@ To get started developing `azldev`, review the [Getting Started guide](./how-to/ * [Submitting Pull Requests](./how-to/pull-requests.md) * [Configuration management](./how-to/config-management.md) * [Add go tools](./how-to/add-go-tool.md) +* [Cut a release](./how-to/releasing.md) ## Reference diff --git a/docs/developer/how-to/releasing.md b/docs/developer/how-to/releasing.md new file mode 100644 index 000000000..2a7537632 --- /dev/null +++ b/docs/developer/how-to/releasing.md @@ -0,0 +1,143 @@ +# How to: cut a release + +This guide covers releasing the `azldev` Go module so that +`go install ...@version` and the [pkg.go.dev][pkgsite] reference page work. + +## TL;DR + +```console +# One-time: install the changelog generator (git-cliff) +cargo binstall git-cliff # or: cargo install git-cliff --locked, or: brew install git-cliff + +# 1. Draft the changelog, curate it into user-facing notes, then PR + merge to main +mage changelog # prepends a draft ## [X.Y.Z] section to CHANGELOG.md + +# 2. Tag the release from the changelog version, then publish by pushing the tag +mage release # creates annotated tag vX.Y.Z on HEAD (does not push) + +# Undo a local tag created by mistake (before pushing) +git tag -d vX.Y.Z + +git push origin vX.Y.Z # pushing the tag is what publishes the release +``` + +Each step is explained in full under [Cut a release](#cut-a-release) below. + +## Versioning policy + +We follow [Semantic Versioning][semver] with a `v` prefix on tags +(`vMAJOR.MINOR.PATCH`). + +* **`v0.x.y` (current).** The pre-1.0 phase: the CLI and any exported Go API may + change in breaking ways on a *minor* bump. Use this while the surface is still + settling. +* **`v1.0.0` and later.** Commits to SemVer stability: breaking changes require a + major bump. +* **Major versions `>= 2`** require a `/vN` suffix on the module path + (e.g. `github.com/microsoft/azure-linux-dev-tools/v2`). Staying on `v0`/`v1` + avoids that. Don't cut a `v2.0.0` tag without first updating the module path. + +## How publishing actually works + +There is no separate "upload" step. The public [Go module proxy][proxy] and +pkg.go.dev fetch directly from this repository's Git tags: + +* `go install .../cmd/azldev@vX.Y.Z` resolves the tag through the proxy. +* `azldev version` reports the right version automatically for proxy installs — + Go embeds the module version into the binary's build info. +* pkg.go.dev renders the package doc comments plus this repo's `README.md`. Our + `LICENSE` (MIT) marks the module redistributable so docs are shown. Only the + public `cmd/` and `pkg/` packages appear; `internal/` is hidden by design. + +## Cut a release + +1. Make sure `main` is green and up to date locally. + +2. Generate and curate the changelog. Run `mage changelog` to prepend a draft + section for the next version to [`CHANGELOG.md`](../../../CHANGELOG.md), then + edit it down into user-facing notes. See [Changelog](#changelog) below. + +3. Tag the release. Once the changelog change is on `main`, run `mage release`: + it reads the version from the top `## [X.Y.Z]` heading in + [`CHANGELOG.md`](../../../CHANGELOG.md) and creates a matching annotated tag + (`vX.Y.Z`), so the tag and the changelog can't disagree. Then push the tag: + + ```console + mage release + git push origin vX.Y.Z + ``` + + `mage release` creates the tag locally but never pushes — pushing the tag is + what publishes the release. The version lives only in the git tag (there is no + version file); `azldev version` reads it from the build. To sign the tag, + create it manually with `git tag -s` instead. + + `mage release` is idempotent: if that version is already tagged it does + nothing, so the same command is safe to automate on every merge to `main`. + +4. Warm the proxy and pkg.go.dev so the new version is discoverable promptly. + This is harmless and only triggers indexing of an already-public tag: + + ```console + GOPROXY=https://proxy.golang.org go list \ + -m github.com/microsoft/azure-linux-dev-tools@v0.1.1 + ``` + + Then visit + `https://pkg.go.dev/github.com/microsoft/azure-linux-dev-tools@v0.1.1` once to + prompt the docs build. + +5. (Optional, recommended) Create a GitHub Release for the tag and paste the new + `CHANGELOG.md` section as the release notes. A GitHub Release is separate from + the Git tag, so you can add notes even to a tag that already exists. + +## Changelog + +[`CHANGELOG.md`](../../../CHANGELOG.md) at the repo root follows the +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format. New sections are +drafted from the Conventional Commit history with +[git-cliff](https://git-cliff.org) and then curated by hand. Generate a draft +with: + +```console +mage changelog +``` + +This prepends a `## [X.Y.Z]` section (the version is inferred from the commits) +above the previous release, skipping internal commit types — docs, test, chore, +build, ci, style, refactor, and dependency bumps — per +[`cliff.toml`](../../../cliff.toml). The result is a **draft**: git-cliff emits +commit subjects, not release prose, so prune and reword the entries into +user-facing notes before committing. + +`mage changelog` is the single changelog flow — it runs identically locally and +in CI (CI just installs git-cliff first), so there is nothing to keep in sync +between the two. It needs git-cliff on your `PATH`; the version is pinned in +[`tools/git-cliff/Cargo.toml`](../../../tools/git-cliff/Cargo.toml) so Dependabot +and security scanners track it. Install it once: + +```console +cargo binstall git-cliff # or: cargo install git-cliff --locked, or: brew install git-cliff +``` + +> Tip: the generated draft is a natural place to let Copilot help rewrite commit +> subjects into concise, user-facing notes before you commit. + +## Fixing a bad release + +Proxy versions are immutable — you cannot delete or move a published version. +To withdraw one, [retract][retract] it: add a `retract` directive to `go.mod` +describing the bad version(s) and release a new patch. `go get` will then skip +the retracted versions. + +```go +// in go.mod +retract ( + v0.1.1 // contains a build-breaking bug; use v0.1.2. +) +``` + +[pkgsite]: https://pkg.go.dev/github.com/microsoft/azure-linux-dev-tools +[semver]: https://semver.org/ +[proxy]: https://proxy.golang.org/ +[retract]: https://go.dev/ref/mod#go-mod-file-retract diff --git a/magefiles/magefile.go b/magefiles/magefile.go index da8330ef6..486eef79b 100644 --- a/magefiles/magefile.go +++ b/magefiles/magefile.go @@ -16,6 +16,8 @@ import ( //mage:import "github.com/microsoft/azure-linux-dev-tools/magefiles/magecheckfix" //mage:import + _ "github.com/microsoft/azure-linux-dev-tools/magefiles/magerelease" + //mage:import _ "github.com/microsoft/azure-linux-dev-tools/magefiles/magemutation" //mage:import "github.com/microsoft/azure-linux-dev-tools/magefiles/magescenario" diff --git a/magefiles/magerelease/magerelease.go b/magefiles/magerelease/magerelease.go new file mode 100644 index 000000000..bfa0c8d6b --- /dev/null +++ b/magefiles/magerelease/magerelease.go @@ -0,0 +1,190 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package magerelease + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + + "github.com/magefile/mage/sh" + "github.com/microsoft/azure-linux-dev-tools/magefiles/mageutil" +) + +var ( + ErrChangelog = errors.New("changelog generation failed") + ErrRelease = errors.New("release tagging failed") +) + +// Changelog generates a draft changelog section for the next release from the Conventional Commit +// history and prepends it to 'CHANGELOG.md', using git-cliff and the repo's 'cliff.toml'. +// +// The output is a *draft*: review and curate it into user-facing notes before releasing. See +// docs/developer/how-to/releasing.md. +// +// git-cliff must be installed and on PATH; this target does not install it. The same target runs +// locally and in CI (CI just installs git-cliff first), so there is a single changelog flow. +func Changelog() error { + mageutil.MagePrintln(mageutil.MsgStart, "Generating changelog draft with git-cliff...") + + // git-cliff is an external (non-Go) tool installed separately, so invoke it by name and let + // PATH resolution happen at execution time (the repo forbids exec.LookPath for tool checks). + // Probe it once up front so a missing binary surfaces an actionable install hint. + const cliff = "git-cliff" + if _, err := sh.Output(cliff, "--version"); err != nil { + if errors.Is(err, exec.ErrNotFound) { + return mageutil.PrintAndReturnError(gitCliffInstallHint(), ErrChangelog, err) + } + + return mageutil.PrintAndReturnError("git-cliff is installed but failed to run.", ErrChangelog, err) + } + + projectDir := mageutil.AzldevProjectDir() + configPath := filepath.Join(projectDir, "cliff.toml") + changelogPath := filepath.Join(projectDir, "CHANGELOG.md") + + // Guard against double-prepending: if CHANGELOG.md already starts with the version git-cliff + // would bump to, the draft is already present. Re-running would add a duplicate section. + bumped := bumpedVersion(cliff, configPath) + + top, topErr := latestChangelogVersion() + if bumped != "" && topErr == nil && top == bumped { + mageutil.MagePrintf(mageutil.MsgInfo, + "CHANGELOG.md already has a draft for v%s; nothing to do "+ + "(run 'git restore CHANGELOG.md' to regenerate).\n", bumped) + + return nil + } + + // --bump computes the next version from the commits; --unreleased limits to commits since the + // last release tag; --prepend inserts the new section at the top of the existing changelog. + err := sh.Run(cliff, "--config", configPath, "--bump", "--unreleased", "--prepend", changelogPath) + if err != nil { + return mageutil.PrintAndReturnError("git-cliff failed to generate the changelog.", ErrChangelog, err) + } + + mageutil.MagePrintf(mageutil.MsgSuccess, + "Updated %#q. Review and curate the new section before releasing.\n", changelogPath) + + return nil +} + +// Release creates the release tag from CHANGELOG.md. It reads the version from the top +// `## [X.Y.Z]` heading and creates a matching annotated git tag (vX.Y.Z) on HEAD, so the tag and +// the changelog can never disagree. It does not push: pushing the tag is the point of no return +// for a release, so that stays an explicit step (manual locally, or a dedicated CI step). +// +// It is idempotent: if the changelog version is already tagged it does nothing, so it is safe to +// trigger on every merge to 'main' and only tags when the changelog carries a new version. Run it +// after the changelog PR has merged (see docs/developer/how-to/releasing.md). +func Release() error { + mageutil.MagePrintln(mageutil.MsgStart, "Preparing release tag from CHANGELOG.md...") + + version, err := latestChangelogVersion() + if err != nil { + return mageutil.PrintAndReturnError("Could not read the release version from CHANGELOG.md.", ErrRelease, err) + } + + tag := "v" + version + + exists, err := tagExists(tag) + if err != nil { + return mageutil.PrintAndReturnError("Could not check existing tags.", ErrRelease, err) + } + + if exists { + mageutil.MagePrintf(mageutil.MsgInfo, "Tag %#q already exists; nothing to release.\n", tag) + + return nil + } + + err = sh.Run("git", "tag", "-a", tag, "-m", tag) + if err != nil { + return mageutil.PrintAndReturnError(fmt.Sprintf("Failed to create tag %#q.", tag), ErrRelease, err) + } + + mageutil.MagePrintf(mageutil.MsgSuccess, "Created annotated tag %#q (matching CHANGELOG.md).\n", tag) + mageutil.MagePrintf(mageutil.MsgInfo, "Push it to publish the release: git push origin %s\n", tag) + mageutil.MagePrintf(mageutil.MsgInfo, "The tag is local only; to remove it before pushing: git tag -d %s\n", tag) + + return nil +} + +// latestChangelogVersion returns the version from the first `## [X.Y.Z]` heading in CHANGELOG.md, +// which is the release being prepared. +func latestChangelogVersion() (string, error) { + changelogPath := filepath.Join(mageutil.AzldevProjectDir(), "CHANGELOG.md") + + content, err := os.ReadFile(changelogPath) + if err != nil { + return "", fmt.Errorf("failed to read %#q:\n%w", changelogPath, err) + } + + // Match a Keep a Changelog version heading, e.g. "## [0.2.0] - 2026-06-25". + headingRe := regexp.MustCompile(`(?m)^##\s+\[(\d+\.\d+\.\d+)\]`) + + match := headingRe.FindStringSubmatch(string(content)) + if match == nil { + return "", fmt.Errorf("no '## [X.Y.Z]' version heading found in %#q", changelogPath) + } + + return match[1], nil +} + +// tagExists reports whether the given git tag already exists locally. +func tagExists(tag string) (bool, error) { + out, err := sh.Output("git", "tag", "--list", tag) + if err != nil { + return false, fmt.Errorf("failed to list git tags:\n%w", err) + } + + return strings.TrimSpace(out) != "", nil +} + +// gitCliffInstallHint builds the "git-cliff is missing" message, naming the version pinned in +// tools/git-cliff/Cargo.toml when it can be read. +func gitCliffInstallHint() string { + const base = "git-cliff not found on PATH. The pin lives in tools/git-cliff/Cargo.toml." + + if version := pinnedGitCliffVersion(); version != "" { + return fmt.Sprintf("%s Install it (e.g. 'cargo binstall git-cliff@%s' or 'brew install git-cliff'), "+ + "then re-run.", base, version) + } + + return base + " Install it (e.g. 'cargo binstall git-cliff' or 'brew install git-cliff'), then re-run." +} + +// bumpedVersion asks git-cliff for the version it would bump to next (without the leading "v"), +// or "" if it can't be determined (e.g. no eligible commits, or git-cliff fails). +func bumpedVersion(cliff, configPath string) string { + out, err := sh.Output(cliff, "--config", configPath, "--bumped-version") + if err != nil { + return "" + } + + return strings.TrimPrefix(strings.TrimSpace(out), "v") +} + +// pinnedGitCliffVersion returns the git-cliff version pinned in tools/git-cliff/Cargo.toml, or "" +// if it can't be determined. The pin lives there (not in Go) so Dependabot and security scanners +// can track and update it. +func pinnedGitCliffVersion() string { + cargoPath := filepath.Join(mageutil.AzldevProjectDir(), "tools", "git-cliff", "Cargo.toml") + + content, err := os.ReadFile(cargoPath) + if err != nil { + return "" + } + + match := regexp.MustCompile(`(?m)^\s*git-cliff\s*=\s*"=?(\d+\.\d+\.\d+)"`).FindStringSubmatch(string(content)) + if match == nil { + return "" + } + + return match[1] +} diff --git a/pkg/app/azldev_cli/azldev.go b/pkg/app/azldev_cli/azldev.go index 54872aa9c..1f8782de7 100644 --- a/pkg/app/azldev_cli/azldev.go +++ b/pkg/app/azldev_cli/azldev.go @@ -1,6 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +// Package azldev_cli wires together and runs the azldev command-line application. +// +// It is the entry point used by the azldev command (see +// github.com/microsoft/azure-linux-dev-tools/cmd/azldev); end users should +// install and run that command rather than importing this package directly. package azldev_cli import ( @@ -18,6 +23,8 @@ import ( "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/cmds/version" ) +// Main constructs the azldev CLI application, runs it with the process +// arguments, and exits the process with the resulting status code. func Main() { // Instantiate the main CLI app instance. app := InstantiateApp() @@ -28,7 +35,8 @@ func Main() { os.Exit(ret) } -// Constructs a new instance of the main CLI application, with all subcommands registered. +// InstantiateApp constructs a new instance of the azldev CLI application with +// all subcommands registered. func InstantiateApp() *azldev.App { // Instantiate the main CLI application. app := azldev.NewApp(azldev.DefaultFileSystemFactory(), azldev.DefaultOSEnvFactory()) diff --git a/tools/README.md b/tools/README.md index b7a3fb17b..b588f6337 100644 --- a/tools/README.md +++ b/tools/README.md @@ -4,6 +4,8 @@ This directory contains a subdirectory for each golang-based build tool that thi We use separate modules (one per tool) to manage and isolate tools' dependencies. +> **Note:** Not every tool here is Go. [`git-cliff`](./git-cliff/) (used by `mage changelog`) is a Rust CLI, pinned via a `Cargo.toml` instead of a `go.mod` so Dependabot (cargo ecosystem) and security scanners can track and bump it. It is not installed via `go tool`; install it with `cargo`/`brew` (or in CI). + ## MCP Server (magemcp) The `magemcp` subdirectory contains an MCP (Model Context Protocol) server that exposes Mage build targets as tools for AI coding assistants. This allows AI agents to build, test, and check code quality in this repository. diff --git a/tools/git-cliff/Cargo.toml b/tools/git-cliff/Cargo.toml new file mode 100644 index 000000000..cb8a2b108 --- /dev/null +++ b/tools/git-cliff/Cargo.toml @@ -0,0 +1,14 @@ +# Pin for the git-cliff CLI used by `mage changelog` (and installed by CI). +# +# This crate is never compiled into azldev. It exists only so the git-cliff +# version is pinned in a manifest that Dependabot (cargo ecosystem) and security +# scanners can read and bump -- unlike a version hardcoded in Go. The `mage +# changelog` target reads this version for its install hint, and CI installs it. +[package] +name = "azldev-git-cliff-pin" +version = "0.0.0" +edition = "2021" +publish = false + +[dependencies] +git-cliff = "=2.13.1" diff --git a/tools/git-cliff/src/lib.rs b/tools/git-cliff/src/lib.rs new file mode 100644 index 000000000..8ad62a255 --- /dev/null +++ b/tools/git-cliff/src/lib.rs @@ -0,0 +1,3 @@ +//! Intentionally empty. This crate exists only to pin the git-cliff CLI version +//! in Cargo.toml so Dependabot and security scanners can track and bump it (see +//! Cargo.toml). Nothing here is built into azldev. From 5a03dd584fdd6e648fd0c433b1ea19a28074e0be Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:00:30 -0700 Subject: [PATCH 04/20] build(deps): bump the dependabot-gomod-minor group across 1 directory with 2 updates (#259) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 6 +++--- go.sum | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index a3c97d6bc..410448829 100644 --- a/go.mod +++ b/go.mod @@ -36,7 +36,7 @@ require ( github.com/mark3labs/mcp-go v0.55.0 github.com/mattn/go-isatty v0.0.22 github.com/mitchellh/hashstructure/v2 v2.0.2 - github.com/moby/moby/api v1.54.2 + github.com/moby/moby/api v1.55.0 github.com/muesli/termenv v0.16.0 github.com/nxadm/tail v1.4.11 github.com/opencontainers/selinux v1.15.1 @@ -50,7 +50,7 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 - github.com/testcontainers/testcontainers-go v0.42.0 + github.com/testcontainers/testcontainers-go v0.43.0 github.com/thejerf/slogassert v0.3.4 github.com/ulikunitz/xz v0.5.15 go.szostok.io/version v1.2.0 @@ -140,7 +140,7 @@ require ( github.com/samber/slog-common v0.21.0 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/sergi/go-diff v1.4.0 // indirect - github.com/shirou/gopsutil/v4 v4.26.3 // indirect + github.com/shirou/gopsutil/v4 v4.26.5 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/spf13/cast v1.10.0 // indirect diff --git a/go.sum b/go.sum index 8d6fa3061..f15d4cbc2 100644 --- a/go.sum +++ b/go.sum @@ -234,8 +234,8 @@ github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3N github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= -github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg= -github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= +github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw= github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= @@ -300,8 +300,8 @@ github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= -github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= -github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM= +github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= @@ -326,8 +326,8 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= -github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= +github.com/testcontainers/testcontainers-go v0.43.0 h1:oEQx5MW2DGd9z3AeEQfB2lPM0eLs7ztyaGRu75bFo5A= +github.com/testcontainers/testcontainers-go v0.43.0/go.mod h1:+VxkT2NQnKOZPKi6praMuMKYHYyOGXr0XSBSlSMCzFo= github.com/thejerf/slogassert v0.3.4 h1:VoTsXixRbXMrRSSxDjYTiEDCM4VWbsYPW5rB/hX24kM= github.com/thejerf/slogassert v0.3.4/go.mod h1:0zn9ISLVKo1aPMTqcGfG1o6dWwt+Rk574GlUxHD4rs8= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= From 33e0b5cd6c1a08042785708a63b532be438a3c88 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:00:41 -0700 Subject: [PATCH 05/20] build(deps): bump actions/checkout from 6.0.3 to 7.0.0 in the dependabot-github-actions group across 1 directory (#260) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/check-docs.yaml | 2 +- .github/workflows/check-workflows.yml | 2 +- .github/workflows/codeql.yml | 2 +- .github/workflows/coverage.yml | 2 +- .github/workflows/devcontainer.yml | 2 +- .github/workflows/format.yml | 2 +- .github/workflows/generate.yml | 2 +- .github/workflows/license.yml | 2 +- .github/workflows/mod.yml | 2 +- .github/workflows/static.yml | 2 +- .github/workflows/test.yml | 6 +++--- .github/workflows/testpublish.yml | 2 +- 12 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/check-docs.yaml b/.github/workflows/check-docs.yaml index 94d7e0be0..e92a2ceca 100644 --- a/.github/workflows/check-docs.yaml +++ b/.github/workflows/check-docs.yaml @@ -30,7 +30,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout sources - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false diff --git a/.github/workflows/check-workflows.yml b/.github/workflows/check-workflows.yml index 1cccbc782..de8769855 100644 --- a/.github/workflows/check-workflows.yml +++ b/.github/workflows/check-workflows.yml @@ -23,7 +23,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install zizmor diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index cc1417771..938b66e62 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -66,7 +66,7 @@ jobs: # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index dc334d664..75df839d5 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -23,7 +23,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Get go tools diff --git a/.github/workflows/devcontainer.yml b/.github/workflows/devcontainer.yml index 937a9e0f5..1dcc81adb 100644 --- a/.github/workflows/devcontainer.yml +++ b/.github/workflows/devcontainer.yml @@ -23,7 +23,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index b9edbb304..cd67c973f 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -23,7 +23,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Get go tools diff --git a/.github/workflows/generate.yml b/.github/workflows/generate.yml index 2c6b25f1e..07964742d 100644 --- a/.github/workflows/generate.yml +++ b/.github/workflows/generate.yml @@ -25,7 +25,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false diff --git a/.github/workflows/license.yml b/.github/workflows/license.yml index a0846ce12..3a2996406 100644 --- a/.github/workflows/license.yml +++ b/.github/workflows/license.yml @@ -23,7 +23,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Get go tools diff --git a/.github/workflows/mod.yml b/.github/workflows/mod.yml index c24fdfab2..0da30e50e 100644 --- a/.github/workflows/mod.yml +++ b/.github/workflows/mod.yml @@ -23,7 +23,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Get go tools diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index 5ab822bd4..1f76aaf9e 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -23,7 +23,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Get go tools diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f7acc036c..600f3b2fa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,7 +23,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Get go tools @@ -41,7 +41,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Get go tools @@ -77,7 +77,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Get go tools diff --git a/.github/workflows/testpublish.yml b/.github/workflows/testpublish.yml index 03ac355e3..172607a3b 100644 --- a/.github/workflows/testpublish.yml +++ b/.github/workflows/testpublish.yml @@ -23,7 +23,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Get go tools From b7a1318af862a3f20fbf92639c4d606a3f5a7741 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:23:22 -0700 Subject: [PATCH 06/20] build(deps): bump golang.org/x/net from 0.54.0 to 0.55.0 (#266) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 410448829..42cf7525d 100644 --- a/go.mod +++ b/go.mod @@ -162,7 +162,7 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect golang.org/x/crypto v0.52.0 // indirect - golang.org/x/net v0.54.0 // indirect + golang.org/x/net v0.55.0 // indirect golang.org/x/term v0.43.0 // indirect golang.org/x/text v0.37.0 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect diff --git a/go.sum b/go.sum index f15d4cbc2..807421802 100644 --- a/go.sum +++ b/go.sum @@ -383,8 +383,8 @@ golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGb golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= From 53c1af5e65fdd88f90c923a3c35aa6b8d5100401 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:24:09 -0700 Subject: [PATCH 07/20] build(deps): bump the dependabot-gomod-tools-security group across 2 directories with 1 update (#265) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- tools/editorconfig-checker/go.mod | 6 +++--- tools/editorconfig-checker/go.sum | 12 ++++++------ tools/go-licenses/go.mod | 12 ++++++------ tools/go-licenses/go.sum | 24 ++++++++++++------------ 4 files changed, 27 insertions(+), 27 deletions(-) diff --git a/tools/editorconfig-checker/go.mod b/tools/editorconfig-checker/go.mod index 848a786a3..4493e74e0 100644 --- a/tools/editorconfig-checker/go.mod +++ b/tools/editorconfig-checker/go.mod @@ -9,8 +9,8 @@ require ( github.com/editorconfig-checker/editorconfig-checker/v3 v3.3.0 // indirect github.com/editorconfig/editorconfig-core-go/v2 v2.6.3 // indirect github.com/gabriel-vasile/mimetype v1.4.9 // indirect - golang.org/x/mod v0.23.0 // indirect - golang.org/x/net v0.39.0 // indirect - golang.org/x/text v0.25.0 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/text v0.37.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect ) diff --git a/tools/editorconfig-checker/go.sum b/tools/editorconfig-checker/go.sum index 82c85e27d..93c9c6545 100644 --- a/tools/editorconfig-checker/go.sum +++ b/tools/editorconfig-checker/go.sum @@ -38,12 +38,12 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM= -golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= -golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= -golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= -golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/tools/go-licenses/go.mod b/tools/go-licenses/go.mod index 1601a7875..a88cb80db 100644 --- a/tools/go-licenses/go.mod +++ b/tools/go-licenses/go.mod @@ -16,11 +16,11 @@ require ( github.com/spf13/cobra v1.7.0 // indirect github.com/spf13/pflag v1.0.5 // indirect go.opencensus.io v0.24.0 // indirect - golang.org/x/mod v0.27.0 // indirect - golang.org/x/net v0.43.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.28.0 // indirect - golang.org/x/tools v0.36.0 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + golang.org/x/tools v0.44.0 // indirect k8s.io/klog/v2 v2.90.1 // indirect ) diff --git a/tools/go-licenses/go.sum b/tools/go-licenses/go.sum index 2499030b2..e33f0958f 100644 --- a/tools/go-licenses/go.sum +++ b/tools/go-licenses/go.sum @@ -81,39 +81,39 @@ golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= -golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= -golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= From ae390fced14b23e04b8970b5a70d2e4ef42a0870 Mon Sep 17 00:00:00 2001 From: Daniel McIlvaney Date: Mon, 6 Jul 2026 15:53:03 -0700 Subject: [PATCH 08/20] feat(cli): add history command to show changes made to components (#212) Co-authored-by: Nan Liu <108544011+liunan-ms@users.noreply.github.com> --- docs/user/reference/cli/azldev_component.md | 1 + .../reference/cli/azldev_component_history.md | 74 +++ .../app/azldev/cmds/component/component.go | 1 + internal/app/azldev/cmds/component/history.go | 465 ++++++++++++++++++ .../cmds/component/history_customizations.go | 228 +++++++++ .../cmds/component/history_gitmetrics.go | 387 +++++++++++++++ .../cmds/component/history_internal_test.go | 327 ++++++++++++ .../azldev/cmds/component/history_render.go | 126 +++++ internal/app/azldev/command.go | 8 + internal/utils/git/log.go | 57 +++ .../TestMCPServerMode_1.snap.json | 95 ++++ scenario/component_history_test.go | 218 ++++++++ 12 files changed, 1987 insertions(+) create mode 100644 docs/user/reference/cli/azldev_component_history.md create mode 100644 internal/app/azldev/cmds/component/history.go create mode 100644 internal/app/azldev/cmds/component/history_customizations.go create mode 100644 internal/app/azldev/cmds/component/history_gitmetrics.go create mode 100644 internal/app/azldev/cmds/component/history_internal_test.go create mode 100644 internal/app/azldev/cmds/component/history_render.go create mode 100644 internal/utils/git/log.go create mode 100644 scenario/component_history_test.go diff --git a/docs/user/reference/cli/azldev_component.md b/docs/user/reference/cli/azldev_component.md index 0516a5e89..1c78ba8d5 100644 --- a/docs/user/reference/cli/azldev_component.md +++ b/docs/user/reference/cli/azldev_component.md @@ -42,6 +42,7 @@ components defined in the project configuration. * [azldev component build](azldev_component_build.md) - Build packages for components * [azldev component changed](azldev_component_changed.md) - Detect which components changed between two git refs * [azldev component diff-sources](azldev_component_diff-sources.md) - Show the diff that overlays apply to a component's sources +* [azldev component history](azldev_component_history.md) - Report per-component change activity and customization detail * [azldev component list](azldev_component_list.md) - List components in this project * [azldev component prepare-sources](azldev_component_prepare-sources.md) - Prepare buildable sources for components * [azldev component query](azldev_component_query.md) - Query info for components in this project diff --git a/docs/user/reference/cli/azldev_component_history.md b/docs/user/reference/cli/azldev_component_history.md new file mode 100644 index 000000000..00c58cd58 --- /dev/null +++ b/docs/user/reference/cli/azldev_component_history.md @@ -0,0 +1,74 @@ + + +## azldev component history + +Report per-component change activity and customization detail + +### Synopsis + +Report three independent change-activity signals per component: + + - toml-commits: commits to the component's source TOML file + - customizations: count of explicit customization items in the config + - fingerprint-changes: commits where the lock file's input-fingerprint changed + +Use this to find which packages get the most attention (for documentation, +review prioritization, or refactoring planning). + +When a component shares its source TOML with other components (e.g., a bare +entry in a shared components.toml), the toml-commit count is coarse and the +component is marked 'toml-shared'. Use --shared=omit to drop those rows. + +When exactly one component is selected the customization items are printed +inline below the row, showing kind, value and description — useful for +hand-picking entries to document. + +``` +azldev component history [flags] +``` + +### Examples + +``` + # Heatmap of an entire project + azldev component history -a + + # JSON for downstream tooling + azldev component history -a -O json + + # Drill into a single component (auto-expands customization details) + azldev component history bash +``` + +### Options + +``` + -a, --all-components Include all components + -p, --component stringArray Component name pattern + -g, --component-group stringArray Component group name + -h, --help help for history + --include-bare Include components with zero customizations in the output. By default they are hidden -- their config inherits everything from defaults, and computing their git metrics is the dominant cost on large projects. + --shared string How to report rows for components that share a TOML file with others: show (keep row, count is coarse), omit (drop row). (default "show") + -s, --spec-path stringArray Spec path +``` + +### Options inherited from parent commands + +``` + -y, --accept-all accept all prompts + --color mode output colorization mode {always, auto, never} (default auto) + --config-file stringArray additional TOML config file(s) to merge (may be repeated) + -n, --dry-run dry run only (do not take action) + --network-retries int maximum number of attempts for network operations (minimum 1) (default 3) + --no-default-config disable default configuration + -O, --output-format fmt output format {csv, json, markdown, table} (default table) + --permissive-config do not fail on unknown fields in TOML config files + -C, --project string path to Azure Linux project + -q, --quiet only enable minimal output + -v, --verbose enable verbose output +``` + +### SEE ALSO + +* [azldev component](azldev_component.md) - Manage components + diff --git a/internal/app/azldev/cmds/component/component.go b/internal/app/azldev/cmds/component/component.go index df28df19b..9cfee2ea3 100644 --- a/internal/app/azldev/cmds/component/component.go +++ b/internal/app/azldev/cmds/component/component.go @@ -27,6 +27,7 @@ components defined in the project configuration.`, buildOnAppInit(app, cmd) changedOnAppInit(app, cmd) diffSourcesOnAppInit(app, cmd) + historyOnAppInit(app, cmd) listOnAppInit(app, cmd) prepareOnAppInit(app, cmd) queryOnAppInit(app, cmd) diff --git a/internal/app/azldev/cmds/component/history.go b/internal/app/azldev/cmds/component/history.go new file mode 100644 index 000000000..2ea21ef5e --- /dev/null +++ b/internal/app/azldev/cmds/component/history.go @@ -0,0 +1,465 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package component + +import ( + "context" + "fmt" + "strings" + "time" + + "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/utils/parmap" + "github.com/spf13/cobra" +) + +// HistoryOptions holds options for the component history command. +type HistoryOptions struct { + ComponentFilter components.ComponentFilter + // SharedTomlMode controls how toml-commit counts are reported for + // components that share their source TOML file with at least one other + // component: + // "show" (default): include the row, report the count, set SharedToml=true + // "omit": drop the row entirely + // + // JSON consumers always see the raw TomlCommits + SharedToml fields and + // can apply their own presentation (e.g., zero out shared rows) via jq. + SharedTomlMode string + // IncludeBare, when true, keeps components with zero customizations in + // the output. By default they are filtered out -- they have no + // per-component config worth reporting, and computing their git + // metrics across all selected components is the dominant cost on + // large projects (e.g., azurelinux). + IncludeBare bool +} + +const ( + sharedTomlModeShow = "show" + sharedTomlModeOmit = "omit" +) + +func historyOnAppInit(_ *azldev.App, parentCmd *cobra.Command) { + parentCmd.AddCommand(NewHistoryCmd()) +} + +// NewHistoryCmd constructs a [cobra.Command] for the "component history" CLI subcommand. +func NewHistoryCmd() *cobra.Command { + options := &HistoryOptions{ + SharedTomlMode: sharedTomlModeShow, + } + + cmd := &cobra.Command{ + Use: "history", + Aliases: []string{"hist"}, + Short: "Report per-component change activity and customization detail", + Long: `Report three independent change-activity signals per component: + + - toml-commits: commits to the component's source TOML file + - customizations: count of explicit customization items in the config + - fingerprint-changes: commits where the lock file's input-fingerprint changed + +Use this to find which packages get the most attention (for documentation, +review prioritization, or refactoring planning). + +When a component shares its source TOML with other components (e.g., a bare +entry in a shared components.toml), the toml-commit count is coarse and the +component is marked 'toml-shared'. Use --shared=omit to drop those rows. + +When exactly one component is selected the customization items are printed +inline below the row, showing kind, value and description — useful for +hand-picking entries to document.`, + Example: ` # Heatmap of an entire project + azldev component history -a + + # JSON for downstream tooling + azldev component history -a -O json + + # Drill into a single component (auto-expands customization details) + azldev component history bash`, + RunE: azldev.RunFuncWithExtraArgs(func(env *azldev.Env, args []string) (interface{}, error) { + options.ComponentFilter.ComponentNamePatterns = append(args, options.ComponentFilter.ComponentNamePatterns...) + + results, err := ComponentHistory(env, options) + if err != nil { + return nil, err + } + + // Card view side-channel: when exactly one component is being + // reported in a human-readable format, render a vertical card + // ourselves and short-circuit the standard table renderer. + // reportResults treats a `true` return as a no-op (see + // reportResultsViaReflectable in azldev/command.go) which is + // how we suppress the would-be 1-row table. + // + // CAVEAT: the trigger is implicit (single result + human + // format) so a broad -a query that happens to narrow to one + // component silently switches output shape. JSON / CSV + // consumers always get the raw slice unchanged. + if shouldRenderCardView(env, results) { + renderCardView(env.ReportFile(), results[0]) + + return true, nil + } + + return results, nil + }), + ValidArgsFunction: components.GenerateComponentNameCompletions, + } + + components.AddComponentFilterOptionsToCommand(cmd, &options.ComponentFilter) + + cmd.Flags().StringVar(&options.SharedTomlMode, "shared", sharedTomlModeShow, + "How to report rows for components that share a TOML file with others: "+ + "show (keep row, count is coarse), omit (drop row).") + // Shell completion advertises the valid choices. Note: the MCP tool + // schema does not yet derive an `enum` constraint from cobra flag + // completion functions (see internal/app/azldev/core/mcp/mcpserver.go), + // so MCP agents see this as an unconstrained string until that gap is + // closed. Runtime validation happens in [validateSharedTomlMode]. + _ = cmd.RegisterFlagCompletionFunc("shared", + func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return []string{sharedTomlModeShow, sharedTomlModeOmit}, + cobra.ShellCompDirectiveNoFileComp + }) + cmd.Flags().BoolVar(&options.IncludeBare, "include-bare", false, + "Include components with zero customizations in the output. "+ + "By default they are hidden -- their config inherits everything from defaults, "+ + "and computing their git metrics is the dominant cost on large projects.") + + // History is read-only; the lock validation flag is meaningless here. + _ = cmd.Flags().MarkHidden("skip-lock-validation") + + azldev.ExportAsMCPTool(cmd) + + return cmd +} + +// CustomizationItem captures one user-authored customization on a component. +// +// Kind is a dotted-namespace string forming part of the JSON wire contract +// (downstream `jq`/`gjson` consumers key on it). It is either an overlay +// type emitted verbatim (e.g. "spec-remove-tag", "patch-add") or a fixed +// token derived from the structured TOML path. The fixed set: +// +// build.with, build.without, build.defines, build.undefines, +// build.check.skip, spec.source-type, spec.upstream-commit, +// spec.upstream-name, spec.upstream-distro, release.calculation, +// render.skip-file-filter, packages, source-files, +// source-files.replace-upstream +// +// Adding a Kind is non-breaking; renaming or removing one is breaking. +// Value is a short summary suitable for table cells; Description is the +// human-readable rationale from the config (overlay.description, +// check.skip_reason, etc.). +type CustomizationItem struct { + Kind string `json:"kind"` + Value string `json:"value,omitempty"` + Description string `json:"description,omitempty"` +} + +// HistoryResult is the per-component output row. +// +// This is the stable wire contract that downstream tooling pins against: +// adding a field is non-breaking; renaming or removing one is a breaking +// change. Keep JSON tags stable. +type HistoryResult struct { + // Name of the component. We intentionally do *not* tag this with + // 'sortkey' -- the reflectable table writer would otherwise re-sort + // by name and stomp our customizations-first sort. + Name string `json:"name"` + + // TomlCommits is the number of commits touching the component's source + // TOML file. When shared-mode = "omit" and the component shares its TOML + // with another component, the count is suppressed to zero (with + // SharedToml=true) -- unless the component was named explicitly, which + // always reports the real count. + TomlCommits int `json:"tomlCommits"` + + // SharedToml is true when at least one other component anywhere in the + // project (not just within the current selection) uses the same source + // TOML file. The TomlCommits count is then coarse because git history + // is on a per-file basis -- the count includes commits that touched the + // shared file for any reason, not just for this component. + SharedToml bool `json:"sharedToml,omitempty"` + + // TomlPath is the repo-relative path of the component's source TOML file. + TomlPath string `json:"tomlPath,omitempty"` + + // LatestCommit is the timestamp of the most recent commit to the TOML + // file. Zero if no commits found. Uses 'omitzero' (Go 1.24+) rather than + // 'omitempty' because the latter is a no-op for struct types and would + // serialize as "0001-01-01T00:00:00Z" for components with no history. + LatestCommit time.Time `json:"latestCommit,omitzero" table:"-"` + + // Customizations is the count of customization items (len of the + // Customization slice). + Customizations int `json:"customizations"` + + // CustomizationItems are the individual customization records; + // rendered as JSON detail and as inline expansion for single-component + // invocations. + CustomizationItems []CustomizationItem `json:"customizationItems,omitempty" table:"-"` + + // FingerprintChanges is the number of commits where the lock file's + // input-fingerprint actually changed. + FingerprintChanges int `json:"fingerprintChanges"` + + // FingerprintChangeDetails is the per-commit metadata for each + // fingerprint change counted in [FingerprintChanges] (oldest first). + // Hidden from the human-readable table -- use JSON output to consume + // them (e.g., to hand-author changelog entries). + // + // Each entry is populated from [sources.FingerprintChange] via an + // explicit field-by-field copy in [populateLockMetrics]. The + // gathering algorithm is shared with the synthetic dist-git history + // flow; the wire-level type is local so that: + // - the JSON contract for this command lives in this file, and + // - removing a field from [sources.FingerprintChange] / + // [sources.CommitMetadata] surfaces as a compile error at the + // copy site rather than silently dropping changelog metadata. + // The compile-error guard is one-directional (it catches REMOVED + // upstream fields); a NEWLY ADDED upstream field is caught instead by + // TestFingerprintChangeDTOMirrorsSource. + FingerprintChangeDetails []FingerprintChange `json:"fingerprintChangeDetails,omitempty" table:"-"` + + // HasLock is true when a lock file currently exists for this component. + HasLock bool `json:"hasLock,omitempty" table:"-"` + + // HasImport is true when the lock file records a non-empty + // import-commit (i.e., the component was forked from upstream). + HasImport bool `json:"hasImport,omitempty" table:"-"` + + // ManualBump is the lock file's manual-bump counter. Always emitted + // (no omitempty) so a real bump of 0 isn't indistinguishable from an + // absent field; pair it with HasLock to tell "no lock" from "bump 0". + ManualBump int `json:"manualBump" table:"-"` + + // Warnings collects per-component diagnostics for failure paths that + // were swallowed to keep the overall report rendering. Empty when no + // problems were encountered. Surfaces in the single-component card + // view and in JSON; hidden from the human-readable table. + Warnings []string `json:"warnings,omitempty" table:"-"` +} + +// FingerprintChange is the wire-level representation of one lock-file +// fingerprint change for the [HistoryResult.FingerprintChangeDetails] +// field. It mirrors the fields of [sources.FingerprintChange] (and its +// embedded [sources.CommitMetadata]) that consumers of `azldev component +// history` JSON output care about. +// +// The fields are copied explicitly in [populateLockMetrics] rather than +// embedding [sources.FingerprintChange] directly so that: +// - the JSON contract for this command is owned by this package, and +// - dropping a field from the synthetic-history source type produces a +// compile error at the copy site instead of silently emptying the +// downstream changelog data. +type FingerprintChange struct { + Hash string `json:"hash"` + Author string `json:"author"` + AuthorEmail string `json:"authorEmail"` + Timestamp int64 `json:"timestamp"` + Message string `json:"message"` + UpstreamCommit string `json:"upstreamCommit,omitempty"` +} + +// ComponentHistory computes the per-component history data for the components +// matching options.ComponentFilter. Per-component work runs in parallel; a +// progress event tracks completion for the (often slow) -a case. +// +// By default, components with zero customizations are skipped before any +// git work runs (set IncludeBare to keep them). This is the dominant +// performance lever on large projects -- the vast majority of components +// in real distros inherit everything from defaults and have no +// per-component history worth reporting. +// +// When the user explicitly names component(s) (via positional args, --component, +// or --spec-path) the bare filter is force-disabled regardless of IncludeBare, +// so `azldev component history nano` always returns a row for nano even when +// nano has zero customizations. The perf rationale for the default does not +// apply to scope-limiting explicit selections. +func ComponentHistory(env *azldev.Env, options *HistoryOptions) ([]HistoryResult, error) { + if err := validateSharedTomlMode(options.SharedTomlMode); err != nil { + return nil, err + } + + // History is read-only; skip lock validation so stale or missing locks + // don't block reporting. + options.ComponentFilter.SkipLockValidation = true + + resolver := components.NewResolver(env) + + comps, err := resolver.FindComponents(&options.ComponentFilter) + if err != nil { + return nil, fmt.Errorf("resolving components:\n%w", err) + } + + ctx, err := newHistoryContext(env) + if err != nil { + return nil, err + } + + // Phase 0: compute customizations for every selected component + // (sync, fast, no git). When --include-bare is off, drop components + // with zero customizations before any expensive work runs -- unless + // the user explicitly named components, in which case they get what + // they asked for regardless. + explicit := hasExplicitComponentSelection(&options.ComponentFilter) + effectiveIncludeBare := options.IncludeBare || explicit + + stubs := buildHistoryStubs(env, comps.Components(), effectiveIncludeBare) + if len(stubs) == 0 { + return nil, nil + } + + tomlSharing := countTomlSharing(env.Config().Components) + + workerEnv, cancel := env.WithCancel() + defer cancel() + + // Phase A: memoize toml-commit counts per unique source TOML path. + // In real projects (e.g., azurelinux) thousands of components share a + // single components.toml; without this we'd re-run the same `git log` + // thousands of times. + tomlCache, err := precomputeTomlMetricsForStubs(workerEnv, env, ctx, stubs) + if err != nil { + return nil, err + } + + // Phase B: build per-component results in parallel. + progressEvent := env.StartEvent("Computing component history", "count", len(stubs)) + defer progressEvent.End() + + total := int64(len(stubs)) + + parmapResults := parmap.Map( + workerEnv, + // Each worker shells out to git; that's I/O-bound work, matching the + // concurrency model used by render/update on similar workloads. + env.IOBoundConcurrency(), + stubs, + func(done, _ int) { progressEvent.SetProgress(int64(done), total) }, + func(_ context.Context, stub historyStub) HistoryResult { + // workerEnv carries the cancellable ctx; the parmap-supplied + // ctx is identical (parmap derives it from workerEnv) and + // unused here. Mirrors how render.go does this. + return buildHistoryResult( //nolint:contextcheck // env carries the ctx + workerEnv, stub, ctx, tomlSharing, tomlCache, options.SharedTomlMode, explicit, + ) + }, + ) + + results := make([]HistoryResult, 0, len(stubs)) + + for _, parmapRes := range parmapResults { + if parmapRes.Cancelled { + continue + } + + // --shared=omit drops the row entirely for components whose source + // TOML is shared with at least one other component -- unless the user + // explicitly named it, in which case they get the row regardless + // (mirroring the --include-bare override above; sharing is a + // presentation default, not the user's intent). + if options.SharedTomlMode == sharedTomlModeOmit && parmapRes.Value.SharedToml && !explicit { + continue + } + + results = append(results, parmapRes.Value) + } + + // FingerprintChangeDetails is potentially the largest field in the + // payload (one entry per fingerprint change per component, each with + // commit metadata); JSON consumers on -a runs at azurelinux scale would + // otherwise get multi-MB responses. The details exist for drilling into + // a single component to author a changelog, so keep them only when + // exactly one component survives filtering. This is decided AFTER the + // --shared=omit drop above so a single surviving row always carries its + // details (the same len()==1 predicate the card view keys off). + if len(results) != 1 { + for i := range results { + results[i].FingerprintChangeDetails = nil + } + } + + sortHistoryResults(results) + + return results, nil +} + +// historyStub carries the cheap, sync-computed slice of work for one +// component: customization items (pre-collected) plus the underlying +// Component handle for later git-metric work. Keyed by component name. +type historyStub struct { + component components.Component + customizationItems []CustomizationItem +} + +// buildHistoryStubs computes customization items for every selected +// component synchronously. When includeBare is false, components with no +// customizations are excluded so that the expensive parallel phases +// don't run on them at all. +func buildHistoryStubs( + env *azldev.Env, comps []components.Component, includeBare bool, +) []historyStub { + stubs := make([]historyStub, 0, len(comps)) + + for _, comp := range comps { + name := comp.GetName() + + // Read the raw per-component config (as authored in TOML), not the + // resolved one returned by comp.GetConfig() -- the resolver + // pre-merges project- and group-level defaults, which would + // otherwise look like per-component customizations. + var items []CustomizationItem + if raw, ok := env.Config().Components[name]; ok { + items = collectCustomizations(name, &raw) + } + + if !includeBare && len(items) == 0 { + continue + } + + stubs = append(stubs, historyStub{component: comp, customizationItems: items}) + } + + return stubs +} + +// hasExplicitComponentSelection reports whether the user pinpointed +// individual components (vs asking for everything, a group, or relying on +// no-criteria defaults). Used by [ComponentHistory] to override +// --include-bare (and the --shared=omit count suppression) in the explicit +// case so that `azldev component history nano` always returns a row for nano +// with its real count. +// +// Only an *exact* name (or a spec path) counts as explicit. A glob pattern +// (e.g. -p '*') can select the whole project, so it carries no more intent +// than -a or --component-group and must not defeat those filters' perf +// rationale. Wildcard detection mirrors the resolver (see +// [components.Resolver]). +// +// Group selection (--component-group) is likewise NOT treated as explicit -- +// groups can contain hundreds of components. +func hasExplicitComponentSelection(filter *components.ComponentFilter) bool { + for _, pattern := range filter.ComponentNamePatterns { + if !strings.ContainsAny(pattern, "*?[") { + return true + } + } + + return len(filter.SpecPaths) > 0 +} + +// validateSharedTomlMode rejects unrecognized --shared values. +func validateSharedTomlMode(mode string) error { + switch mode { + case sharedTomlModeShow, sharedTomlModeOmit: + return nil + default: + return fmt.Errorf( + "invalid --shared value %#q (want one of: %s, %s)", + mode, sharedTomlModeShow, sharedTomlModeOmit) + } +} diff --git a/internal/app/azldev/cmds/component/history_customizations.go b/internal/app/azldev/cmds/component/history_customizations.go new file mode 100644 index 000000000..f0fa64a28 --- /dev/null +++ b/internal/app/azldev/cmds/component/history_customizations.go @@ -0,0 +1,228 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package component + +import ( + "fmt" + "sort" + "strconv" + + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" +) + +// collectCustomizations gathers all customization items declared on the +// component's config into a uniform list. Items are emitted in a stable +// order (overlays first in declared order; then build, spec, release, +// packages, source-files in field order) so the output is deterministic. +func collectCustomizations(name string, config *projectconfig.ComponentConfig) []CustomizationItem { + if config == nil { + return nil + } + + items := make([]CustomizationItem, 0) + + items = appendOverlayItems(items, config.Overlays) + items = appendBuildItems(items, config.Build) + items = appendSpecItems(items, name, config.Spec) + items = appendReleaseItems(items, config.Release) + items = appendRenderItems(items, config.Render) + items = appendPackageItems(items, config.Packages) + items = appendSourceFileItems(items, config.SourceFiles) + + return items +} + +// appendRenderItems flags non-default render-config customizations. +func appendRenderItems( + items []CustomizationItem, render projectconfig.ComponentRenderConfig, +) []CustomizationItem { + if render.SkipFileFilter { + items = append(items, CustomizationItem{ + Kind: "render.skip-file-filter", + Value: strconv.FormatBool(true), + }) + } + + return items +} + +// appendOverlayItems converts each overlay into a CustomizationItem. +func appendOverlayItems( + items []CustomizationItem, overlays []projectconfig.ComponentOverlay, +) []CustomizationItem { + for i := range overlays { + overlay := &overlays[i] + + items = append(items, CustomizationItem{ + Kind: string(overlay.Type), + Value: overlaySummary(overlay), + Description: overlay.Description, + }) + } + + return items +} + +// overlaySummary returns a short human-readable identification of an overlay, +// suitable for the Value field of a CustomizationItem. +func overlaySummary(overlay *projectconfig.ComponentOverlay) string { + switch { + case overlay.Tag != "" && overlay.Value != "": + return fmt.Sprintf("%s=%s", overlay.Tag, overlay.Value) + case overlay.Tag != "": + return overlay.Tag + case overlay.EffectiveSourceName() != "": + return overlay.EffectiveSourceName() + case overlay.Filename != "": + return overlay.Filename + case overlay.SectionName != "": + return overlay.SectionName + case overlay.Regex != "": + return overlay.Regex + default: + return "" + } +} + +// appendBuildItems converts non-default build-config fields into items. +func appendBuildItems( + items []CustomizationItem, build projectconfig.ComponentBuildConfig, +) []CustomizationItem { + for _, flag := range build.With { + items = append(items, CustomizationItem{Kind: "build.with", Value: flag}) + } + + for _, flag := range build.Without { + items = append(items, CustomizationItem{Kind: "build.without", Value: flag}) + } + + // Sort define keys so iteration order is deterministic. + defineKeys := make([]string, 0, len(build.Defines)) + for key := range build.Defines { + defineKeys = append(defineKeys, key) + } + + sort.Strings(defineKeys) + + for _, key := range defineKeys { + items = append(items, CustomizationItem{ + Kind: "build.defines", + Value: fmt.Sprintf("%s=%s", key, build.Defines[key]), + }) + } + + for _, macro := range build.Undefines { + items = append(items, CustomizationItem{Kind: "build.undefines", Value: macro}) + } + + if build.Check.Skip { + items = append(items, CustomizationItem{ + Kind: "build.check.skip", + Value: strconv.FormatBool(true), + Description: build.Check.SkipReason, + }) + } + + return items +} + +// appendSpecItems captures spec-source customizations relative to the +// inherited default. We cannot perfectly know the inherited default without +// re-resolving, but we can flag the cases that are unambiguous (commit pin, +// upstream-name renamed away from the component name, upstream-distro set). +func appendSpecItems( + items []CustomizationItem, name string, spec projectconfig.SpecSource, +) []CustomizationItem { + // Only surface SourceType when explicitly set in the raw per-component + // config -- components that inherit from group defaults leave it empty, + // so this avoids inflating the customization count for every component. + if spec.SourceType != "" { + items = append(items, CustomizationItem{ + Kind: "spec.source-type", + Value: string(spec.SourceType), + }) + } + + if spec.UpstreamCommit != "" { + items = append(items, CustomizationItem{ + Kind: "spec.upstream-commit", + Value: spec.UpstreamCommit, + }) + } + + if spec.UpstreamName != "" && spec.UpstreamName != name { + items = append(items, CustomizationItem{ + Kind: "spec.upstream-name", + Value: spec.UpstreamName, + }) + } + + // Both Name and Version are real build inputs (only Snapshot carries + // fingerprint:"-"), so a version-only pin is a genuine customization. + if spec.UpstreamDistro.Name != "" || spec.UpstreamDistro.Version != "" { + items = append(items, CustomizationItem{ + Kind: "spec.upstream-distro", + Value: spec.UpstreamDistro.String(), + }) + } + + return items +} + +// appendReleaseItems flags non-default release-calculation modes. +func appendReleaseItems( + items []CustomizationItem, release projectconfig.ReleaseConfig, +) []CustomizationItem { + if release.Calculation == "" || release.Calculation == projectconfig.ReleaseCalculationAuto { + return items + } + + return append(items, CustomizationItem{ + Kind: "release.calculation", + Value: string(release.Calculation), + }) +} + +// appendPackageItems emits one item per binary package override. +func appendPackageItems( + items []CustomizationItem, packages map[string]projectconfig.PackageConfig, +) []CustomizationItem { + if len(packages) == 0 { + return items + } + + keys := make([]string, 0, len(packages)) + for key := range packages { + keys = append(keys, key) + } + + sort.Strings(keys) + + for _, key := range keys { + items = append(items, CustomizationItem{Kind: "packages", Value: key}) + } + + return items +} + +// appendSourceFileItems emits one item per declared source-file reference, +// plus a distinct item for the high-signal ReplaceUpstream toggle (which +// actively masks a same-named upstream source and would otherwise be hidden +// behind the plain filename entry). +func appendSourceFileItems( + items []CustomizationItem, sourceFiles []projectconfig.SourceFileReference, +) []CustomizationItem { + for _, sourceFile := range sourceFiles { + items = append(items, CustomizationItem{Kind: "source-files", Value: sourceFile.Filename}) + + if sourceFile.ReplaceUpstream { + items = append(items, CustomizationItem{ + Kind: "source-files.replace-upstream", + Value: sourceFile.Filename, + }) + } + } + + return items +} diff --git a/internal/app/azldev/cmds/component/history_gitmetrics.go b/internal/app/azldev/cmds/component/history_gitmetrics.go new file mode 100644 index 000000000..f89a6b5ca --- /dev/null +++ b/internal/app/azldev/cmds/component/history_gitmetrics.go @@ -0,0 +1,387 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package component + +import ( + "context" + "errors" + "fmt" + "time" + + "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/app/azldev/core/sources" + "github.com/microsoft/azure-linux-dev-tools/internal/lockfile" + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/git" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/parmap" +) + +// historyContext holds resolved repo state shared across components. +type historyContext struct { + repoRoot string + lockDir string +} + +// newHistoryContext opens the project repository once just to resolve the +// worktree root, then discards it: go-git's *Repository is not safe to +// share across goroutines (see synthistory.go which always opens a fresh +// repo per component). Per-worker repos are reopened inline. +func newHistoryContext(env *azldev.Env) (*historyContext, error) { + cfg := env.Config() + if cfg == nil { + return nil, errors.New("no project configuration loaded") + } + + repo, err := git.OpenProjectRepo(env.ProjectDir()) + if err != nil { + return nil, fmt.Errorf("opening project repository:\n%w", err) + } + + worktree, err := repo.Worktree() + if err != nil { + return nil, fmt.Errorf("getting project worktree:\n%w", err) + } + + return &historyContext{ + repoRoot: worktree.Filesystem.Root(), + lockDir: cfg.Project.LockDir, + }, nil +} + +// countTomlSharing returns the number of components that point at each +// source TOML path. Used to detect shared files (where toml-commit counts +// are coarse). +func countTomlSharing(allComponents map[string]projectconfig.ComponentConfig) map[string]int { + sharing := make(map[string]int) + + for _, cfg := range allComponents { + if cfg.SourceConfigFile == nil { + continue + } + + path := cfg.SourceConfigFile.SourcePath() + if path == "" { + continue + } + + sharing[path]++ + } + + return sharing +} + +// tomlMetrics is one entry in the precomputed cache populated by +// [precomputeTomlMetrics]. Keyed by repo-relative TOML path. A non-nil err +// records a real `git log` failure so [populateTomlMetrics] can surface a +// warning, keeping it distinguishable from a genuine zero-commit history. +type tomlMetrics struct { + count int + latest time.Time + err error +} + +// precomputeTomlMetricsForStubs runs `git log` once per *unique* source- +// TOML path across the selected stubs and returns the results keyed by +// repo-relative path. This is the central performance optimization: in +// real projects (e.g., azurelinux) thousands of components share a single +// components.toml file, and without de-duplicating we'd re-run the same +// `git log` thousands of times. +// +// Paths that resolve outside the repo are skipped. A `git log` failure is +// cached as a per-path error (not a fatal one) so [populateTomlMetrics] can +// surface a warning, keeping it distinguishable from a genuine zero-commit +// history. +func precomputeTomlMetricsForStubs( + workerEnv *azldev.Env, + env *azldev.Env, + ctx *historyContext, + stubs []historyStub, +) (map[string]tomlMetrics, error) { + uniqueRelPaths := collectUniqueTomlRelPathsFromStubs(ctx.repoRoot, stubs) + if len(uniqueRelPaths) == 0 { + return map[string]tomlMetrics{}, nil + } + + progressEvent := env.StartEvent("Counting TOML commit history", "uniqueFiles", len(uniqueRelPaths)) + defer progressEvent.End() + + total := int64(len(uniqueRelPaths)) + + parmapResults := parmap.Map( + workerEnv, + env.IOBoundConcurrency(), + uniqueRelPaths, + func(done, _ int) { progressEvent.SetProgress(int64(done), total) }, + func(_ context.Context, relPath string) tomlMetrics { + count, latest, err := git.CountCommitsTouchingFile( //nolint:contextcheck // env carries the ctx + workerEnv, workerEnv, ctx.repoRoot, relPath, + ) + if err != nil { + // Cache the failure rather than failing the whole command -- + // populateTomlMetrics surfaces it as a warning so a real error + // (corrupt repo, permission denied) stays distinguishable from a + // genuine zero-commit history. + return tomlMetrics{err: err} + } + + return tomlMetrics{count: count, latest: latest} + }, + ) + + cache := make(map[string]tomlMetrics, len(uniqueRelPaths)) + + for idx, parmapRes := range parmapResults { + if parmapRes.Cancelled { + continue + } + + cache[uniqueRelPaths[idx]] = parmapRes.Value + } + + return cache, nil +} + +// collectUniqueTomlRelPathsFromStubs returns the deduplicated set of in- +// repo, repo-relative source-TOML paths across the given stubs. +func collectUniqueTomlRelPathsFromStubs(repoRoot string, stubs []historyStub) []string { + seen := make(map[string]struct{}) + + relPaths := make([]string, 0) + + for _, stub := range stubs { + config := stub.component.GetConfig() + if config.SourceConfigFile == nil { + continue + } + + absPath := config.SourceConfigFile.SourcePath() + if absPath == "" { + continue + } + + relPath, err := repoRelPath(repoRoot, absPath) + if err != nil { + continue + } + + if _, dup := seen[relPath]; dup { + continue + } + + seen[relPath] = struct{}{} + + relPaths = append(relPaths, relPath) + } + + return relPaths +} + +// buildHistoryResult assembles a single [HistoryResult] for a stub. The +// stub already carries the precomputed customization items; this function +// fills in the git-driven metrics (toml-commits via cache, fingerprint-changes via +// per-call repo). +func buildHistoryResult( + env *azldev.Env, + stub historyStub, + ctx *historyContext, + tomlSharing map[string]int, + tomlCache map[string]tomlMetrics, + sharedMode string, + explicit bool, +) HistoryResult { + result := HistoryResult{ + Name: stub.component.GetName(), + CustomizationItems: stub.customizationItems, + Customizations: len(stub.customizationItems), + } + + populateTomlMetrics(stub.component, ctx, tomlSharing, tomlCache, sharedMode, explicit, &result) + populateLockMetrics(env, stub.component, ctx, &result) + + return result +} + +// populateTomlMetrics fills in TomlCommits, SharedToml, TomlPath, +// LatestCommit from the precomputed [tomlMetrics] cache. +func populateTomlMetrics( + comp components.Component, + ctx *historyContext, + tomlSharing map[string]int, + tomlCache map[string]tomlMetrics, + sharedMode string, + explicit bool, + result *HistoryResult, +) { + config := comp.GetConfig() + + if config.SourceConfigFile == nil || config.SourceConfigFile.SourcePath() == "" { + return + } + + tomlAbsPath := config.SourceConfigFile.SourcePath() + result.SharedToml = tomlSharing[tomlAbsPath] > 1 + + tomlRelPath, err := repoRelPath(ctx.repoRoot, tomlAbsPath) + if err != nil { + // A TOML file outside the repo isn't a hard error -- record a + // warning and leave path/commit counts empty. + result.Warnings = append(result.Warnings, + fmt.Sprintf("source TOML %q is outside the git repository; toml-commits skipped: %v", + tomlAbsPath, err)) + + return + } + + result.TomlPath = tomlRelPath + + // --shared=omit suppresses the (coarse) count for shared TOMLs, but an + // explicitly-named component is the user asking for that component + // specifically -- give them the real count, mirroring the row-keep + // override in [ComponentHistory]. + if result.SharedToml && sharedMode == sharedTomlModeOmit && !explicit { + return + } + + metrics, ok := tomlCache[tomlRelPath] + if !ok { + // Precompute didn't run for this path (e.g., out-of-repo TOML + // or a precompute failure that was tolerated). Surface so the + // user can tell zero-counts apart from missing-data. + result.Warnings = append(result.Warnings, + fmt.Sprintf("no TOML commit metrics cached for %q; toml-commits left at zero", tomlRelPath)) + + return + } + + if metrics.err != nil { + // A real `git log` failure was cached during precompute. Surface it + // rather than silently reporting zero commits (mirrors the lock-path + // warning behavior). + result.Warnings = append(result.Warnings, + fmt.Sprintf("counting TOML commits for %q failed; toml-commits left at zero: %v", + tomlRelPath, metrics.err)) + + return + } + + result.TomlCommits = metrics.count + result.LatestCommit = metrics.latest +} + +// populateLockMetrics fills in FingerprintChanges, FingerprintChangeDetails, +// HasLock, HasImport, ManualBump. +// A missing lock file is "no data", not an error; a genuine read failure +// (corrupt/unparseable lock) is surfaced via result.Warnings so a +// tomlCommits/fingerprintChanges of 0 can't be silently confused with a +// real failure. +// +// FingerprintChangeDetails is always populated here; the caller strips it +// when more than one component is reported. See [ComponentHistory] for the +// rationale. +func populateLockMetrics( + env *azldev.Env, + comp components.Component, + ctx *historyContext, + result *HistoryResult, +) { + name := comp.GetName() + + lockReader := env.LockReader() + if lockReader != nil { + lock, lockErr := lockReader.Get(name) + + switch { + case lockErr == nil && lock != nil: + result.HasLock = true + result.HasImport = lock.ImportCommit != "" + result.ManualBump = lock.ManualBump + case lockErr != nil: + // Distinguish a missing lock ("no data", expected) from a real + // read failure (corrupt/unparseable lock). Mirror the store's + // own not-found detection (Exists) since the wrapped fs error + // isn't reliably errors.Is(os.ErrNotExist)-comparable. Only a + // genuine failure earns a warning, so a fingerprintChanges of 0 + // can't be silently confused with a load error. + exists, existsErr := lockReader.Exists(name) + switch { + case existsErr != nil: + result.Warnings = append(result.Warnings, + fmt.Sprintf("reading lock file for %q: %v (existence check also failed: %v)", + name, lockErr, existsErr)) + case exists: + result.Warnings = append(result.Warnings, + fmt.Sprintf("reading lock file for %q: %v", name, lockErr)) + } + } + } + + lockAbsPath, err := lockfile.LockPath(ctx.lockDir, name) + if err != nil { + // Invalid component name for path resolution: skip lock metrics + // rather than failing the whole report. + result.Warnings = append(result.Warnings, + fmt.Sprintf("resolving lock path: %v", err)) + + return + } + + lockRelPath, err := repoRelPath(ctx.repoRoot, lockAbsPath) + if err != nil { + // Lock dir lives outside the repo: nothing to count. + result.Warnings = append(result.Warnings, + fmt.Sprintf("lock file %q is outside the git repository; fingerprint-changes skipped: %v", + lockAbsPath, err)) + + return + } + + fingerprintChanges, err := func() ([]sources.FingerprintChange, error) { + // Open a fresh repo for this call -- go-git's *Repository is not + // safe for concurrent use. Opening is cheap (just reads .git/config). + repo, openErr := git.OpenProjectRepo(env.ProjectDir()) + if openErr != nil { + return nil, fmt.Errorf("opening project repository:\n%w", openErr) + } + + return sources.FindFingerprintChanges(env.Context(), env, repo, ctx.repoRoot, lockRelPath) + }() + if err != nil { + // A lock file with no committed history is NOT an error here -- + // FindFingerprintChanges returns (nil, nil) in that case. This + // branch only fires on real failures (git open, blob read, etc.). + result.Warnings = append(result.Warnings, + fmt.Sprintf("computing fingerprint changes for %q: %v", lockRelPath, err)) + + return + } + + result.FingerprintChanges = len(fingerprintChanges) + result.FingerprintChangeDetails = toFingerprintChanges(fingerprintChanges) +} + +// toFingerprintChanges copies each [sources.FingerprintChange] into the +// local [FingerprintChange] wire type by naming every field explicitly. +// Removing a field from [sources.FingerprintChange] or +// [sources.CommitMetadata] trips a compile error here, alerting us to a +// quietly-shrunk changelog payload. +func toFingerprintChanges(changes []sources.FingerprintChange) []FingerprintChange { + if len(changes) == 0 { + return nil + } + + out := make([]FingerprintChange, len(changes)) + for i, change := range changes { + out[i] = FingerprintChange{ + Hash: change.Hash, + Author: change.Author, + AuthorEmail: change.AuthorEmail, + Timestamp: change.Timestamp, + Message: change.Message, + UpstreamCommit: change.UpstreamCommit, + } + } + + return out +} diff --git a/internal/app/azldev/cmds/component/history_internal_test.go b/internal/app/azldev/cmds/component/history_internal_test.go new file mode 100644 index 000000000..3504f3902 --- /dev/null +++ b/internal/app/azldev/cmds/component/history_internal_test.go @@ -0,0 +1,327 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package component + +import ( + "reflect" + "strings" + "testing" + + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/components" + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/sources" + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" + "github.com/stretchr/testify/assert" +) + +// TestHasExplicitComponentSelection pins the NEW-1 fix: only an exact name or +// spec path is "explicit". A glob pattern selects broadly and must not defeat +// --include-bare / --shared=omit (it carries no more intent than -a). +func TestHasExplicitComponentSelection(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + filter components.ComponentFilter + want bool + }{ + {"exact name", components.ComponentFilter{ComponentNamePatterns: []string{"curl"}}, true}, + {"spec path", components.ComponentFilter{SpecPaths: []string{"specs/curl/curl.spec"}}, true}, + {"star glob", components.ComponentFilter{ComponentNamePatterns: []string{"*"}}, false}, + {"prefix glob", components.ComponentFilter{ComponentNamePatterns: []string{"lib*"}}, false}, + {"char-class glob", components.ComponentFilter{ComponentNamePatterns: []string{"cur[lp]"}}, false}, + {"question glob", components.ComponentFilter{ComponentNamePatterns: []string{"cur?"}}, false}, + {"glob plus exact", components.ComponentFilter{ComponentNamePatterns: []string{"*", "curl"}}, true}, + {"nothing", components.ComponentFilter{}, false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, hasExplicitComponentSelection(&tc.filter)) + }) + } +} + +// TestCustomizationCollectorsCoverEveryFingerprintableField pins the +// "customization vs upstream" split to the existing fingerprint:"-" +// taxonomy: a field counts as a customization iff it contributes to the +// component's input fingerprint (i.e., it changes what we ship). +// +// The collector layer in [collectCustomizations] is hand-written so it can +// produce nice human-readable Kind/Value/Description entries per field. +// This test enforces that every fingerprint-relevant field of +// [projectconfig.ComponentConfig] (and its directly walked sub-structs) +// has been consciously categorized in [expectedCovered]. When a new field +// is added to one of these structs, this test forces a choice: +// +// - Tag it `fingerprint:"-"` (declaring it operational metadata such as +// publish channels, build hints, or maintenance markers). The +// fingerprint test in projectconfig already enforces tag presence. +// - Add it here AND wire up a collector in [collectCustomizations]. +// +// Structs whose fingerprintable fields are surfaced *wholesale* by a +// single collector ([projectconfig.ComponentOverlay], +// [projectconfig.PackageConfig]) are intentionally NOT walked here -- only +// their parent ComponentConfig field appears in expectedCovered. +// Field-level drift inside those opaque units is still caught by the +// fingerprint exhaustiveness test in projectconfig, at which point a +// human reviewer decides whether richer per-field surfacing belongs in +// history too. +func TestCustomizationCollectorsCoverEveryFingerprintableField(t *testing.T) { + t.Parallel() + + // Structs walked here are those we want field-level drift detection on, + // so adding a fingerprintable field forces a conscious decision about + // surfacing it. A walked field need not map to a *distinct* Kind: + // DistroReference's two fields both fold into spec.upstream-distro, and + // SourceFileReference's Hash/HashType fold into the file's entry. Adding + // a new sub-struct that should get this scrutiny means adding it here. + walkedStructs := []reflect.Type{ + reflect.TypeFor[projectconfig.ComponentConfig](), + reflect.TypeFor[projectconfig.ComponentBuildConfig](), + reflect.TypeFor[projectconfig.CheckConfig](), + reflect.TypeFor[projectconfig.SpecSource](), + reflect.TypeFor[projectconfig.DistroReference](), + reflect.TypeFor[projectconfig.ReleaseConfig](), + reflect.TypeFor[projectconfig.ComponentRenderConfig](), + reflect.TypeFor[projectconfig.SourceFileReference](), + } + + // Maps "StructName.FieldName" -> short note describing how the field + // surfaces in `component history` output. Every fingerprint-relevant + // (i.e., NOT `fingerprint:"-"`) field in walkedStructs must appear here. + expectedCovered := map[string]string{ + // ComponentConfig -- top-level fields dispatch to sub-collectors + // or are treated as opaque-unit collections. + "ComponentConfig.Spec": "appendSpecItems (per-field via SpecSource walk)", + "ComponentConfig.Release": "appendReleaseItems (per-field via ReleaseConfig walk)", + "ComponentConfig.Overlays": "appendOverlayItems (opaque unit per overlay)", + "ComponentConfig.Build": "appendBuildItems (per-field via ComponentBuildConfig walk)", + "ComponentConfig.Render": "appendRenderItems (per-field via ComponentRenderConfig walk)", + "ComponentConfig.SourceFiles": "appendSourceFileItems (opaque unit per source file)", + "ComponentConfig.Packages": "appendPackageItems (opaque unit per package override)", + + // ComponentBuildConfig. + "ComponentBuildConfig.With": "build.with", + "ComponentBuildConfig.Without": "build.without", + "ComponentBuildConfig.Defines": "build.defines", + "ComponentBuildConfig.Undefines": "build.undefines", + "ComponentBuildConfig.Check": "delegates to CheckConfig walk", + + // CheckConfig. + "CheckConfig.Skip": "build.check.skip", + + // SpecSource. + "SpecSource.SourceType": "spec.source-type", + "SpecSource.UpstreamDistro": "spec.upstream-distro", + "SpecSource.UpstreamName": "spec.upstream-name (only when distinct from component name)", + "SpecSource.UpstreamCommit": "spec.upstream-commit", + + // DistroReference -- both fields fold into the single spec.upstream-distro + // item emitted by appendSpecItems (DistroReference.String()). + "DistroReference.Name": "spec.upstream-distro", + "DistroReference.Version": "spec.upstream-distro", + + // ReleaseConfig. + "ReleaseConfig.Calculation": "release.calculation (only when non-auto)", + + // ComponentRenderConfig. + "ComponentRenderConfig.SkipFileFilter": "render.skip-file-filter", + + // SourceFileReference -- Filename and the ReplaceUpstream toggle each get + // their own Kind. Hash/HashType are deliberately NOT emitted as output: + // the file's *presence* is the customization signal, and a checksum-only + // change is still caught by toml-commits / fingerprint-changes. + "SourceFileReference.Filename": "source-files", + "SourceFileReference.Hash": "not emitted (checksum change caught via toml-commits/fingerprint)", + "SourceFileReference.HashType": "not emitted (ditto Hash)", + "SourceFileReference.ReplaceUpstream": "source-files.replace-upstream", + } + + actualFields := make(map[string]bool) + + for _, st := range walkedStructs { + for i := range st.NumField() { + field := st.Field(i) + key := st.Name() + "." + field.Name + + // Fields excluded from the fingerprint are operational + // metadata (publish channels, build hints, maintenance + // markers, etc.), not modifications to upstream. Skip them. + if field.Tag.Get("fingerprint") == "-" { + continue + } + + actualFields[key] = true + + _, ok := expectedCovered[key] + assert.Truef(t, ok, + "field %q is fingerprint-relevant but has no entry in expectedCovered. "+ + "Either tag it `fingerprint:\"-\"` (operational metadata) or add it "+ + "to expectedCovered AND wire a collector in collectCustomizations.", key) + } + } + + // Reverse: no stale entries left after a field was removed or + // re-tagged `fingerprint:"-"`. + for key := range expectedCovered { + assert.Truef(t, actualFields[key], + "expectedCovered entry %q does not correspond to a fingerprint-relevant "+ + "field. Was the field removed, renamed, or tagged `fingerprint:\"-\"`?", key) + } +} + +// TestCollectCustomizationsEmitsEveryKind complements the reflection-based +// coverage test above: that test proves every fingerprintable field is +// *categorized*, this one proves the collectors are actually *wired* by +// invoking collectCustomizations on a config with every customizable field +// populated and asserting each expected Kind appears. Deleting a collector +// call or emptying a collector body turns this red (the reflection test +// alone would stay green). +func TestCollectCustomizationsEmitsEveryKind(t *testing.T) { + t.Parallel() + + config := projectconfig.ComponentConfig{ + Overlays: []projectconfig.ComponentOverlay{ + {Type: projectconfig.ComponentOverlayAddSpecTag, Tag: "Release", Value: "1"}, + }, + Build: projectconfig.ComponentBuildConfig{ + With: []string{"feature"}, + Without: []string{"docs"}, + Defines: map[string]string{"macro": "value"}, + Undefines: []string{"othermacro"}, + Check: projectconfig.CheckConfig{Skip: true, SkipReason: "flaky"}, + }, + Spec: projectconfig.SpecSource{ + SourceType: projectconfig.SpecSourceTypeUpstream, + UpstreamName: "different-name", + UpstreamCommit: "abc1234", + UpstreamDistro: projectconfig.DistroReference{Name: "fedora", Version: "43"}, + }, + Release: projectconfig.ReleaseConfig{ + Calculation: projectconfig.ReleaseCalculationAutorelease, + }, + Render: projectconfig.ComponentRenderConfig{SkipFileFilter: true}, + Packages: map[string]projectconfig.PackageConfig{ + "libfoo": {}, + }, + SourceFiles: []projectconfig.SourceFileReference{ + {Filename: "extra.tar.gz", ReplaceUpstream: true, ReplaceReason: "vendored fix"}, + }, + } + + wantKinds := []string{ + "spec-add-tag", + "build.with", + "build.without", + "build.defines", + "build.undefines", + "build.check.skip", + "spec.source-type", + "spec.upstream-commit", + "spec.upstream-name", + "spec.upstream-distro", + "release.calculation", + "render.skip-file-filter", + "packages", + "source-files", + "source-files.replace-upstream", + } + + items := collectCustomizations("comp", &config) + + gotKinds := make(map[string]bool, len(items)) + for _, item := range items { + gotKinds[item.Kind] = true + } + + for _, kind := range wantKinds { + assert.Truef(t, gotKinds[kind], + "collectCustomizations did not emit an item of Kind %q; "+ + "a collector for it may be unwired or its trigger condition wrong", kind) + } +} + +// TestFingerprintChangeDTOMirrorsSource guards the direction the explicit +// field-by-field copy in [toFingerprintChanges] cannot: a NEW field added to +// [sources.FingerprintChange] / [sources.CommitMetadata] would compile fine +// but silently never reach JSON consumers. This asserts the local DTO carries +// a field of the same type for every exported source field (matched by name), +// so a field addition OR a type change (e.g. int64->int32) trips the test. +func TestFingerprintChangeDTOMirrorsSource(t *testing.T) { + t.Parallel() + + dtoFields := exportedFieldTypes(reflect.TypeFor[FingerprintChange]()) + + for name, srcType := range exportedFieldTypes(reflect.TypeFor[sources.FingerprintChange]()) { + dtoType, ok := dtoFields[name] + if !assert.Truef(t, ok, + "sources.FingerprintChange field %q has no counterpart in the local "+ + "FingerprintChange DTO; add it (and to toFingerprintChanges) so it "+ + "reaches JSON consumers, or it is silently dropped.", name) { + continue + } + + assert.Equalf(t, srcType, dtoType, + "FingerprintChange DTO field %q has type %s but sources.FingerprintChange "+ + "has %s; the explicit copy in toFingerprintChanges would silently "+ + "narrow or mistype the value.", name, dtoType, srcType) + } +} + +// TestRenderCardViewFingerprintHint pins the N6 fix: the single-component +// card omits the per-commit FingerprintChangeDetails (to stay scannable) but +// must point the user at -O json whenever fingerprint changes exist, so the +// details aren't a silent dead end. +func TestRenderCardViewFingerprintHint(t *testing.T) { + t.Parallel() + + var withChanges strings.Builder + + renderCardView(&withChanges, HistoryResult{ + Name: "curl", + TomlPath: "azldev.toml", + TomlCommits: 3, + Customizations: 2, + FingerprintChanges: 2, + }) + + out := withChanges.String() + assert.Contains(t, out, "Component: curl") + assert.Contains(t, out, "FP changes: 2") + assert.Contains(t, out, "-O json", + "card should point at -O json when fingerprint changes exist") + + var noChanges strings.Builder + + renderCardView(&noChanges, HistoryResult{Name: "bash"}) + + assert.NotContains(t, noChanges.String(), "-O json", + "no fingerprint changes means no -O json hint") +} + +// exportedFieldTypes returns the exported fields of a struct type keyed by +// name -> type, flattening anonymously-embedded structs (e.g. CommitMetadata) +// into the parent's namespace. +func exportedFieldTypes(t reflect.Type) map[string]reflect.Type { + types := make(map[string]reflect.Type) + + for i := range t.NumField() { + field := t.Field(i) + + if field.Anonymous && field.Type.Kind() == reflect.Struct { + for name, typ := range exportedFieldTypes(field.Type) { + types[name] = typ + } + + continue + } + + if field.IsExported() { + types[field.Name] = field.Type + } + } + + return types +} diff --git a/internal/app/azldev/cmds/component/history_render.go b/internal/app/azldev/cmds/component/history_render.go new file mode 100644 index 000000000..55e9030ee --- /dev/null +++ b/internal/app/azldev/cmds/component/history_render.go @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package component + +import ( + "fmt" + "io" + "sort" + "time" + + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev" +) + +// sortHistoryResults orders results "most-customized first": highest +// customization count first, then fingerprint-changes, then alphabetical by name. +// Customizations is the most direct signal of human attention paid to a +// component (and it's deterministic / fast); fingerprint-changes and the name +// tie-break it for stable output. +func sortHistoryResults(results []HistoryResult) { + sort.SliceStable(results, func(left, right int) bool { + if results[left].Customizations != results[right].Customizations { + return results[left].Customizations > results[right].Customizations + } + + if results[left].FingerprintChanges != results[right].FingerprintChanges { + return results[left].FingerprintChanges > results[right].FingerprintChanges + } + + return results[left].Name < results[right].Name + }) +} + +// shouldRenderCardView decides whether to print the per-component "card" +// view instead of falling through to the default table renderer. We only +// switch to the card for exactly one result and only for the plain table +// format; markdown falls through to the reflectable renderer so a +// `-O markdown` consumer gets real markdown structure, and JSON / CSV +// consumers always get the machine-readable slice. +func shouldRenderCardView(env *azldev.Env, results []HistoryResult) bool { + if len(results) != 1 { + return false + } + + switch env.DefaultReportFormat() { + case azldev.ReportFormatTable: + return true + case azldev.ReportFormatCSV, azldev.ReportFormatJSON, azldev.ReportFormatMarkdown: + return false + default: + return false + } +} + +// renderCardView prints a single-component card view: a vertical key/value +// header followed by an indented list of customization items (with their +// descriptions when present). This is what the user sees from +// `azldev component history ` and is intended to be the most useful +// view for hand-picking entries to document. +func renderCardView(writer io.Writer, result HistoryResult) { + fmt.Fprintf(writer, "Component: %s\n", result.Name) + + if result.TomlPath != "" { + fmt.Fprintf(writer, " Source TOML: %s\n", result.TomlPath) + } + + sharedNote := "" + if result.SharedToml { + sharedNote = " (shared file -- count is coarse)" + } + + latestNote := "" + if !result.LatestCommit.IsZero() { + // Render in UTC so the same commit shows the same date regardless of + // the host's local timezone. + latestNote = ", latest " + result.LatestCommit.UTC().Format(time.DateOnly) + } + + fmt.Fprintf(writer, " TOML commits: %d%s%s\n", result.TomlCommits, sharedNote, latestNote) + fmt.Fprintf(writer, " Customizations: %d\n", result.Customizations) + fmt.Fprintf(writer, " FP changes: %d\n", result.FingerprintChanges) + + // The per-commit FingerprintChangeDetails are populated for a single + // surviving component but omitted from the card to keep it scannable; + // point the user at -O json so the changelog records aren't a dead end. + if result.FingerprintChanges > 0 { + fmt.Fprintln(writer, " (run with -O json for per-commit details)") + } + + if result.HasLock { + fmt.Fprintf(writer, + " Lock state: locked (manual-bump=%d, has-import=%t)\n", + result.ManualBump, result.HasImport) + } else { + fmt.Fprintln(writer, " Lock state: no lock") + } + + if len(result.Warnings) > 0 { + fmt.Fprintln(writer) + fmt.Fprintln(writer, "Warnings:") + + for _, warning := range result.Warnings { + fmt.Fprintf(writer, " - %s\n", warning) + } + } + + if len(result.CustomizationItems) == 0 { + return + } + + fmt.Fprintln(writer) + fmt.Fprintln(writer, "Customizations:") + + for idx, item := range result.CustomizationItems { + value := item.Value + if value == "" { + value = "(no value)" + } + + fmt.Fprintf(writer, " %d. [%s] %s\n", idx+1, item.Kind, value) + + if item.Description != "" { + fmt.Fprintf(writer, " %s\n", item.Description) + } + } +} diff --git a/internal/app/azldev/command.go b/internal/app/azldev/command.go index ab40fed0f..1880b332c 100644 --- a/internal/app/azldev/command.go +++ b/internal/app/azldev/command.go @@ -234,6 +234,14 @@ func createReflectableOptions(env *Env, format reflectable.Format) *reflectable. // Displays the results of a command to stdout in JSON format. func reportResultsAsJSON(env *Env, results interface{}) error { + // Mirror reportResultsViaReflectable: a nil/bool sentinel means "nothing + // to report" (e.g. a command that already rendered its own output and + // returns true to suppress the framework's). Without this guard such a + // value would marshal to a literal `true`/`false`/`null`. + if results == nil || results == true || results == false { + return nil + } + // Normalize a typed-nil slice/map to an empty one so it marshals as `[]` // or `{}` rather than `null`. This keeps JSON output friendly for // downstream pipelines (e.g., `jq '.[]'` and `jq 'keys'` work whether or diff --git a/internal/utils/git/log.go b/internal/utils/git/log.go new file mode 100644 index 000000000..2f1257e4c --- /dev/null +++ b/internal/utils/git/log.go @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package git + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/microsoft/azure-linux-dev-tools/internal/global/opctx" +) + +// CountCommitsTouchingFile returns the number of commits that touched relPath +// in the git repository rooted at repoDir, plus the timestamp of the most +// recent such commit (zero when no commits found). +// +// The returned timestamp is on the committer-date axis: we format with %ct so +// it matches the order 'git log' walks (newest committer-date first). +// +// Shells out to 'git log -- ' because go-git's PathFilter walks the +// entire commit graph in-process and is prohibitively slow on large repos +// (see the commentary on gitLogFileMetadata in +// internal/app/azldev/core/sources/synthistory.go). +func CountCommitsTouchingFile( + ctx context.Context, + cmdFactory opctx.CmdFactory, + repoDir, relPath string, +) (count int, latest time.Time, err error) { + args := []string{"log", "--format=%ct"} + + args = append(args, "--", relPath) + + output, err := RunInDir(ctx, cmdFactory, repoDir, args...) + if err != nil { + return 0, time.Time{}, fmt.Errorf("listing commits for %#q:\n%w", relPath, err) + } + + if output == "" { + return 0, time.Time{}, nil + } + + lines := strings.Split(output, "\n") + + // 'git log' emits newest-first; the first line is the latest commit. + unixSeconds, err := strconv.ParseInt(strings.TrimSpace(lines[0]), 10, 64) + if err != nil { + return 0, time.Time{}, fmt.Errorf("parsing commit timestamp %#q:\n%w", lines[0], err) + } + + // Normalize to UTC so the timestamp serializes identically regardless of + // the host's local timezone (time.Unix defaults to Location: Local, which + // would otherwise make JSON output non-reproducible across machines). + return len(lines), time.Unix(unixSeconds, 0).UTC(), nil +} diff --git a/scenario/__snapshots__/TestMCPServerMode_1.snap.json b/scenario/__snapshots__/TestMCPServerMode_1.snap.json index 356765c2e..0972acfe5 100755 --- a/scenario/__snapshots__/TestMCPServerMode_1.snap.json +++ b/scenario/__snapshots__/TestMCPServerMode_1.snap.json @@ -200,6 +200,101 @@ }, "name": "component-diff-sources" }, + { + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true, + "readOnlyHint": false + }, + "description": "Report per-component change activity and customization detail", + "inputSchema": { + "properties": { + "accept-all": { + "default": false, + "description": "accept all prompts", + "type": "boolean" + }, + "all-components": { + "default": false, + "description": "Include all components", + "type": "boolean" + }, + "color": { + "description": "output colorization mode {always, auto, never}", + "type": "string" + }, + "component": { + "description": "Component name pattern", + "type": "string" + }, + "component-group": { + "description": "Component group name", + "type": "string" + }, + "config-file": { + "description": "additional TOML config file(s) to merge (may be repeated)", + "type": "string" + }, + "dry-run": { + "default": false, + "description": "dry run only (do not take action)", + "type": "boolean" + }, + "include-bare": { + "default": false, + "description": "Include components with zero customizations in the output. By default they are hidden -- their config inherits everything from defaults, and computing their git metrics is the dominant cost on large projects.", + "type": "boolean" + }, + "network-retries": { + "default": 3, + "description": "maximum number of attempts for network operations (minimum 1)", + "type": "number" + }, + "no-default-config": { + "default": false, + "description": "disable default configuration", + "type": "boolean" + }, + "output-format": { + "description": "output format {csv, json, markdown, table}", + "type": "string" + }, + "permissive-config": { + "default": false, + "description": "do not fail on unknown fields in TOML config files", + "type": "boolean" + }, + "project": { + "default": "", + "description": "path to Azure Linux project", + "type": "string" + }, + "quiet": { + "default": false, + "description": "only enable minimal output", + "type": "boolean" + }, + "shared": { + "default": "show", + "description": "How to report rows for components that share a TOML file with others: show (keep row, count is coarse), omit (drop row).", + "type": "string" + }, + "spec-path": { + "description": "Spec path", + "type": "string" + }, + "verbose": { + "default": false, + "description": "enable verbose output", + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "name": "component-history" + }, { "annotations": { "destructiveHint": true, diff --git a/scenario/component_history_test.go b/scenario/component_history_test.go new file mode 100644 index 000000000..5d584771d --- /dev/null +++ b/scenario/component_history_test.go @@ -0,0 +1,218 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//go:build scenario + +package scenario_tests + +import ( + "encoding/json" + "os/exec" + "path/filepath" + "testing" + + componentcmds "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/cmds/component" + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" + "github.com/microsoft/azure-linux-dev-tools/scenario/internal/projecttest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// runHistory runs `azldev component history` with the given args and returns +// parsed JSON results. Fails the test on any error. Decodes into the real +// [componentcmds.HistoryResult] so the test stays in sync with the command's +// schema automatically. +func runHistory(t *testing.T, azldevBin, projectDir string, extraArgs ...string) []componentcmds.HistoryResult { + t.Helper() + + args := []string{"-C", projectDir, "--no-default-config", "component", "history"} + args = append(args, extraArgs...) + args = append(args, "-q", "-O", "json") + + cmd := exec.CommandContext(t.Context(), azldevBin, args...) + out, err := cmd.CombinedOutput() + require.NoError(t, err, "azldev failed: %s", string(out)) + + var results []componentcmds.HistoryResult + require.NoError(t, json.Unmarshal(out, &results), "failed to parse JSON: %s", string(out)) + + return results +} + +// historyMap converts a slice of [componentcmds.HistoryResult] into a map keyed +// by component name. +func historyMap(results []componentcmds.HistoryResult) map[string]componentcmds.HistoryResult { + m := make(map[string]componentcmds.HistoryResult, len(results)) + for _, r := range results { + m[r.Name] = r + } + + return m +} + +// TestComponentHistory_Smoke exercises the `azldev component history` command +// end-to-end with a real git repository, verifying that: +// - customized components are reported with their customization count and items, +// - bare components are excluded by default, +// - `--include-bare` brings the bare components back into the output. +// +// This is a smoke test — it doesn't validate every metric, just that the command +// runs, emits valid JSON, and respects the most common filtering flag. +func TestComponentHistory_Smoke(t *testing.T) { + t.Parallel() + + if testing.Short() { + t.Skip("skipping long test") + } + + azldevBin, projectDir := setupProjectWithGit(t, + []*projecttest.TestSpec{ + projecttest.NewSpec( + projecttest.WithName("curl"), + projecttest.WithVersion("8.0.0"), + projecttest.WithRelease("1%{?dist}"), + projecttest.WithBuildArch(projecttest.NoArch), + ), + }, + []*projectconfig.ComponentConfig{ + { + // curl has explicit customizations so it should appear by default. + Name: "curl", + Spec: projectconfig.SpecSource{ + SourceType: projectconfig.SpecSourceTypeLocal, + Path: filepath.Join("specs", "curl", "curl.spec"), + }, + Build: projectconfig.ComponentBuildConfig{ + With: []string{"feature-a"}, + }, + }, + { + // bash is truly bare: no Spec, no Build, no anything. The + // collectors emit zero items so it gets filtered out unless + // --include-bare is passed. + Name: "bash", + }, + }, + nil, + ) + + // Commit everything so the project has a git history for toml-commit counting. + gitInDir(t, projectDir, "add", ".") + gitInDir(t, projectDir, "-c", "commit.gpgsign=false", "commit", "-m", "initial") + + // Seed two commits to curl's lock file with distinct fingerprints so the + // fp-change details path has something to report. + writeFileInDir(t, projectDir, "locks/curl.lock", + `version = 1`+"\n"+`input-fingerprint = "sha256:curl-v1"`+"\n") + gitInDir(t, projectDir, "add", "locks/curl.lock") + gitInDir(t, projectDir, "-c", "commit.gpgsign=false", "commit", "-m", "curl: initial lock") + + writeFileInDir(t, projectDir, "locks/curl.lock", + `version = 1`+"\n"+`input-fingerprint = "sha256:curl-v2"`+"\n") + gitInDir(t, projectDir, "add", "locks/curl.lock") + gitInDir(t, projectDir, "-c", "commit.gpgsign=false", "commit", "-m", "curl: bump fingerprint") + + // Default run: bare components are filtered out. + results := runHistory(t, azldevBin, projectDir, "-a") + rm := historyMap(results) + + require.Contains(t, rm, "curl", "customized component should be reported by default") + require.NotContains(t, rm, "bash", "bare component should be filtered out by default") + + curl := rm["curl"] + // curl's config sets exactly two fingerprintable fields: Build.With and the + // explicit Spec.SourceType=local. Pin the exact count and Kinds so a + // regression in either collector (or an accidental extra emission) is caught + // rather than masked by a loose >= comparison. + assert.Equal(t, 2, curl.Customizations, "curl should have exactly two customizations") + assert.ElementsMatch(t, []string{"build.with", "spec.source-type"}, customizationKinds(curl), + "curl's customization Kinds should be exactly build.with and spec.source-type") + assert.NotEmpty(t, curl.TomlPath, "curl's source TOML path should be populated") + // Both components are defined in the single azldev.toml, so it is shared and + // the one initial commit that created it is the only one touching it. + assert.True(t, curl.SharedToml, "curl shares azldev.toml with bash") + assert.Equal(t, 1, curl.TomlCommits, "only the initial commit touched the shared azldev.toml") + + // Fingerprint-change details: should include both lock commits with full + // author / message metadata sourced from the synthetic-distgit + // FingerprintChange type via the local DTO copy. + require.Equal(t, 2, curl.FingerprintChanges, "expected two fingerprint changes") + require.Len(t, curl.FingerprintChangeDetails, curl.FingerprintChanges, + "FingerprintChangeDetails length must match FingerprintChanges count") + + for i, change := range curl.FingerprintChangeDetails { + assert.NotEmpty(t, change.Hash, "change[%d].Hash should be populated", i) + assert.NotEmpty(t, change.Author, "change[%d].Author should be populated", i) + assert.NotEmpty(t, change.Message, "change[%d].Message should be populated", i) + assert.Positive(t, change.Timestamp, "change[%d].Timestamp should be populated", i) + } + + // With --include-bare both components show up. With more than one result, + // FingerprintChangeDetails is suppressed in JSON output to keep responses + // bounded on -a runs (count is still populated). + results = runHistory(t, azldevBin, projectDir, "-a", "--include-bare") + rm = historyMap(results) + + require.Contains(t, rm, "curl", "customized component should still be reported with --include-bare") + require.Contains(t, rm, "bash", "bare component should be reported with --include-bare") + assert.Equal(t, 0, rm["bash"].Customizations, "bash has no customizations") + assert.Equal(t, 2, rm["curl"].FingerprintChanges, + "FingerprintChanges count should still be populated on multi-result runs") + assert.Nil(t, rm["curl"].FingerprintChangeDetails, + "FingerprintChangeDetails should be suppressed when more than one component is reported") + + // Explicit single-component query for a bare component: --include-bare + // is force-disabled so the user gets the row they asked for. + results = runHistory(t, azldevBin, projectDir, "bash") + rm = historyMap(results) + + require.Contains(t, rm, "bash", + "explicit positional name should override --include-bare and return the row") + assert.Equal(t, 0, rm["bash"].Customizations) + + // Explicit single-component query for curl: even though curl shares its TOML, + // being the only surviving row means FingerprintChangeDetails is retained + // (the multi-result suppression only kicks in with >1 row). + results = runHistory(t, azldevBin, projectDir, "curl") + rm = historyMap(results) + require.Contains(t, rm, "curl") + require.Len(t, results, 1, "explicit single-component query returns exactly one row") + assert.Len(t, rm["curl"].FingerprintChangeDetails, 2, + "single surviving row retains its FingerprintChangeDetails") + + // --shared=omit without an explicit selection drops shared-TOML rows. Both + // curl and bash live in the shared azldev.toml, so the omit run is empty. + results = runHistory(t, azldevBin, projectDir, "-a", "--include-bare", "--shared=omit") + rm = historyMap(results) + assert.NotContains(t, rm, "curl", "--shared=omit drops shared-TOML rows without explicit selection") + assert.NotContains(t, rm, "bash", "--shared=omit drops shared-TOML rows without explicit selection") + + // An explicit positional selection overrides --shared=omit: the user asked + // for curl by name, so they get it back even though its TOML is shared -- + // and with the real toml-commit count, not a suppressed zero (N3). + results = runHistory(t, azldevBin, projectDir, "curl", "--shared=omit") + rm = historyMap(results) + require.Contains(t, rm, "curl", + "explicit selection overrides --shared=omit") + assert.Equal(t, 1, rm["curl"].TomlCommits, + "explicit --shared=omit survivor keeps its real count, not a suppressed zero") +} + +// customizationKinds returns the set of CustomizationItem Kinds in a result, +// deduplicated, for order-independent assertions. +func customizationKinds(r componentcmds.HistoryResult) []string { + seen := make(map[string]bool, len(r.CustomizationItems)) + kinds := make([]string, 0, len(r.CustomizationItems)) + + for _, item := range r.CustomizationItems { + if seen[item.Kind] { + continue + } + + seen[item.Kind] = true + + kinds = append(kinds, item.Kind) + } + + return kinds +} From 5917bdf41e03747219f6eac5debbab9087201846 Mon Sep 17 00:00:00 2001 From: Daniel McIlvaney Date: Mon, 6 Jul 2026 15:56:25 -0700 Subject: [PATCH 09/20] feat(overlays): handle empty sections explicitly (#208) Co-authored-by: Nan Liu <108544011+liunan-ms@users.noreply.github.com> --- docs/user/reference/config/overlays.md | 39 ++- internal/app/azldev/core/sources/overlays.go | 22 +- .../app/azldev/core/sources/overlays_test.go | 81 +++++ internal/projectconfig/overlay.go | 290 +++++++++++------- internal/projectconfig/overlay_test.go | 57 ++++ internal/rpm/spec/edit.go | 18 ++ internal/rpm/spec/edit_test.go | 127 ++++++++ ...ainer_config_generate-schema_stdout_1.snap | 4 +- ...shots_config_generate-schema_stdout_1.snap | 4 +- schemas/azldev.schema.json | 4 +- 10 files changed, 512 insertions(+), 134 deletions(-) diff --git a/docs/user/reference/config/overlays.md b/docs/user/reference/config/overlays.md index f635bef92..8db2ea772 100644 --- a/docs/user/reference/config/overlays.md +++ b/docs/user/reference/config/overlays.md @@ -19,9 +19,9 @@ These overlays modify `.spec` files using the structured spec parser, allowing p | `spec-set-tag` | Sets a tag value; replaces if exists, adds if not | `tag`, `value` | | `spec-update-tag` | Updates an existing tag; **fails if the tag doesn't exist** | `tag`, `value` | | `spec-remove-tag` | Removes a tag from the spec; **fails if the tag doesn't exist** | `tag` | -| `spec-prepend-lines` | Prepends lines to the start of a section; **fails if section doesn't exist** | `lines` | -| `spec-append-lines` | Appends lines to the end of a section; **fails if section doesn't exist** | `lines` | -| `spec-search-replace` | Regex-based search and replace on spec content | `regex` | +| `spec-prepend-lines` | Prepends lines to the start of a section, or to the top of the file if `section` is omitted; **fails if a named section doesn't exist** | `lines` | +| `spec-append-lines` | Appends lines to the end of a section, or to the bottom of the file if `section` is omitted; **fails if a named section doesn't exist** | `lines` | +| `spec-search-replace` | Regex-based search and replace on spec content; targets a single section if `section` is given, otherwise the entire spec | `regex` | | `spec-remove-section` | Removes an entire section from the spec; **fails if section doesn't exist** | `section` | | `spec-remove-subpackage` | Removes every section associated with a sub-package (e.g. its `%package`, `%description`, `%files`, `%post`, `%postun`, ...); **fails if no such sections exist** | `package` | | `patch-add` | Adds a patch file and registers it in the spec (PatchN tag or %patchlist) | `source` | @@ -55,8 +55,8 @@ successfully makes a replacement to at least one matching file. | Description | `description` | Human-readable explanation documenting the need for the change; helps identify overlays in error messages | All (optional) | | Tag | `tag` | The spec tag name (e.g., `BuildRequires`, `Requires`, `Version`) | `spec-add-tag`, `spec-insert-tag`, `spec-set-tag`, `spec-update-tag`, `spec-remove-tag` | | Value | `value` | The tag value to set, or value to match for removal | `spec-add-tag`, `spec-insert-tag`, `spec-set-tag`, `spec-update-tag`, `spec-remove-tag` (optional for matching) | -| Section | `section` | The spec section to target (e.g., `%build`, `%install`, `%files`, `%description`) | `spec-prepend-lines`, `spec-append-lines`, `spec-search-replace` (optional), `spec-remove-section` | -| Package | `package` | The sub-package name for multi-package specs; omit to target the main package | All spec overlays (optional, except `spec-remove-subpackage` which **requires** it) | +| Section | `section` | The spec section to target (e.g., `%build`, `%install`, `%files`, `%description`). Optional for `spec-prepend-lines`, `spec-append-lines`, and `spec-search-replace` — omit to target the entire spec file. Required for `spec-remove-section`. | `spec-prepend-lines` (optional), `spec-append-lines` (optional), `spec-search-replace` (optional), `spec-remove-section` | +| Package | `package` | The sub-package name for multi-package specs; omit to target the main package. Cannot be combined with an omitted `section` (a sub-package is always a sub-qualifier of a section). | All spec overlays (optional, except `spec-remove-subpackage` which **requires** it) | | Regex | `regex` | Regular expression pattern to match | `spec-search-replace`, `file-search-replace` | | Replacement | `replacement` | Literal replacement text; capture group references like `$1` are **not** expanded. Omit or leave empty to delete matched text. | `spec-search-replace`, `file-search-replace`, `file-rename` | | Lines | `lines` | Array of text lines to insert | `spec-prepend-lines`, `spec-append-lines`, `file-prepend-lines` | @@ -295,6 +295,35 @@ regex = "--enable-deprecated-feature\\s*" replacement = "" ``` +### Targeting the Entire Spec File + +The `spec-prepend-lines`, `spec-append-lines`, and `spec-search-replace` overlays accept an +empty/omitted `section` field to operate on the whole spec file rather than a single section: +prepend inserts at the very top of the file, append inserts at the very bottom, and search-replace +scans every section. The `package` field cannot be combined with an omitted `section`. + +```toml +[[components.mypackage.overlays]] +type = "spec-prepend-lines" +description = "Add a top-of-file banner comment" +lines = ["# This spec is maintained by the Azure Linux team."] +``` + +```toml +[[components.mypackage.overlays]] +type = "spec-append-lines" +description = "Append a trailing macro definition" +lines = ["%global azl_marker 1"] +``` + +```toml +[[components.mypackage.overlays]] +type = "spec-search-replace" +description = "Rename the project everywhere it appears" +regex = "oldname" +replacement = "newname" +``` + ### Targeting a Sub-Package For multi-package specs, use the `package` field to target a specific sub-package: diff --git a/internal/app/azldev/core/sources/overlays.go b/internal/app/azldev/core/sources/overlays.go index cfc23069c..66cd2373e 100644 --- a/internal/app/azldev/core/sources/overlays.go +++ b/internal/app/azldev/core/sources/overlays.go @@ -116,7 +116,7 @@ func ApplySpecOverlayToFileInPlace(fs opctx.FS, overlay projectconfig.ComponentO // ApplySpecOverlay applies a spec-based overlay to an opened spec. An error is returned if a non-spec // overlay is provided. // -//nolint:cyclop,funlen // This function's complexity is inflated by the big switch over overlay types. +//nolint:cyclop,funlen,gocognit // This function's complexity is inflated by the big switch over overlay types. func ApplySpecOverlay(overlay projectconfig.ComponentOverlay, openedSpec *spec.Spec) error { //nolint:exhaustive // We intentionally ignore non-spec overlay types. switch overlay.Type { @@ -146,14 +146,22 @@ func ApplySpecOverlay(overlay projectconfig.ComponentOverlay, openedSpec *spec.S return fmt.Errorf("failed to remove tag %#q from spec:\n%w", overlay.Tag, err) } case projectconfig.ComponentOverlayPrependSpecLines: - err := openedSpec.PrependLinesToSection(overlay.SectionName, overlay.PackageName, overlay.Lines) - if err != nil { - return fmt.Errorf("failed to prepend lines to spec:\n%w", err) + if overlay.SectionName == "" { + openedSpec.PrependLines(overlay.Lines) + } else { + err := openedSpec.PrependLinesToSection(overlay.SectionName, overlay.PackageName, overlay.Lines) + if err != nil { + return fmt.Errorf("failed to prepend lines to spec:\n%w", err) + } } case projectconfig.ComponentOverlayAppendSpecLines: - err := openedSpec.AppendLinesToSection(overlay.SectionName, overlay.PackageName, overlay.Lines) - if err != nil { - return fmt.Errorf("failed to append lines to spec:\n%w", err) + if overlay.SectionName == "" { + openedSpec.AppendLines(overlay.Lines) + } else { + err := openedSpec.AppendLinesToSection(overlay.SectionName, overlay.PackageName, overlay.Lines) + if err != nil { + return fmt.Errorf("failed to append lines to spec:\n%w", err) + } } case projectconfig.ComponentOverlaySearchAndReplaceInSpec: err := openedSpec.SearchAndReplace( diff --git a/internal/app/azldev/core/sources/overlays_test.go b/internal/app/azldev/core/sources/overlays_test.go index 60bb69e0c..5e6a26d4d 100644 --- a/internal/app/azldev/core/sources/overlays_test.go +++ b/internal/app/azldev/core/sources/overlays_test.go @@ -38,6 +38,7 @@ func applyOverlayToSpecContents( return outputBuffer.String(), nil } +//nolint:maintidx // Test table complexity scales with the number of overlay types. func TestApplySpecOverlay(t *testing.T) { testCases := []struct { name string @@ -256,6 +257,58 @@ line1 `, errorExpected: true, }, + { + name: "prepend lines to entire spec (no section)", + overlay: projectconfig.ComponentOverlay{ + Type: projectconfig.ComponentOverlayPrependSpecLines, + Lines: []string{"# top of file"}, + }, + spec: `Name: name + +%description +text + +%changelog +* Mon Jan 01 2024 User - 1.0-1 +- Initial release +`, + result: `# top of file +Name: name + +%description +text + +%changelog +* Mon Jan 01 2024 User - 1.0-1 +- Initial release +`, + }, + { + name: "append lines to entire spec (no section)", + overlay: projectconfig.ComponentOverlay{ + Type: projectconfig.ComponentOverlayAppendSpecLines, + Lines: []string{"# end of file"}, + }, + spec: `Name: name + +%description +text + +%changelog +* Mon Jan 01 2024 User - 1.0-1 +- Initial release +`, + result: `Name: name + +%description +text + +%changelog +* Mon Jan 01 2024 User - 1.0-1 +- Initial release +# end of file +`, + }, { name: "search and replace", overlay: projectconfig.ComponentOverlay{ @@ -296,6 +349,34 @@ line1 `, errorExpected: true, }, + { + name: "search and replace across entire spec (no section)", + overlay: projectconfig.ComponentOverlay{ + Type: projectconfig.ComponentOverlaySearchAndReplaceInSpec, + Regex: `oldname`, + Replacement: "newname", + }, + spec: `Name: oldname + +%description +oldname package + +%build +./configure --prefix=/opt/oldname + +%changelog +`, + result: `Name: newname + +%description +newname package + +%build +./configure --prefix=/opt/newname + +%changelog +`, + }, } for _, testCase := range testCases { diff --git a/internal/projectconfig/overlay.go b/internal/projectconfig/overlay.go index d6a13c768..d890a6fec 100644 --- a/internal/projectconfig/overlay.go +++ b/internal/projectconfig/overlay.go @@ -25,9 +25,14 @@ type ComponentOverlay struct { // apply to multiple files, supports glob patterns (including globstar). Filename string `toml:"file,omitempty" json:"file,omitempty" jsonschema:"title=Filename,description=The name of the non-spec file to which this overlay applies, or a glob pattern matching multiple files"` // For overlays that apply to specs, indicates the name of the section to which it applies. - SectionName string `toml:"section,omitempty" json:"section,omitempty" jsonschema:"title=Section name,description=The name of the section to which this overlay applies"` + // Optional for spec-prepend-lines, spec-append-lines, and spec-search-replace: when omitted, + // the overlay targets the entire spec file (prepend at top, append at end, search-replace + // across all sections). + SectionName string `toml:"section,omitempty" json:"section,omitempty" jsonschema:"title=Section name,description=The name of the section to which this overlay applies. Optional for spec-prepend-lines/spec-append-lines/spec-search-replace; when omitted these overlays target the entire spec file."` // For overlays that apply to specs, indicates the name of the sub-package to which it applies. - PackageName string `toml:"package,omitempty" json:"package,omitempty" jsonschema:"title=Package name,description=The name of the sub-package to which this overlay applies"` + // A sub-package is always a sub-qualifier of a section, so this field cannot be combined + // with an omitted SectionName on overlays that support whole-file targeting. + PackageName string `toml:"package,omitempty" json:"package,omitempty" jsonschema:"title=Package name,description=The name of the sub-package to which this overlay applies. Cannot be combined with an omitted section on overlays that support whole-file targeting."` // For overlays that apply to spec tags, indicates the name of the tag. Tag string `toml:"tag,omitempty" json:"tag,omitempty" jsonschema:"title=Tag,description=For overlays that apply to spec tags, indicates the name of the tag"` // For overlays that apply to values in specs, an exact string value to match. @@ -193,157 +198,210 @@ const ( // Validate checks that required fields are set based on the overlay type. This catches // configuration errors at load time rather than at apply time. -// -//nolint:cyclop,gocognit,gocyclo,funlen // complexity is inherent to the number of overlay types. func (c *ComponentOverlay) Validate() error { desc := c.Description if desc == "" { desc = "(no description)" } - missingField := func(fieldName string) error { - return fmt.Errorf("overlay type %#q requires %#q field: %s", c.Type, fieldName, desc) + if err := c.validateRequiredFields(desc); err != nil { + return err } - unexpectedField := func(fieldName string) error { - return fmt.Errorf("overlay type %#q does not accept %#q field: %s", c.Type, fieldName, desc) + if c.Metadata != nil { + if err := c.Metadata.Validate(); err != nil { + return fmt.Errorf("invalid metadata for overlay %q:\n%w", desc, err) + } } - requireRelativePath := func(fieldName, value string) error { - if value == "" { - return missingField(fieldName) - } + return nil +} - if filepath.IsAbs(value) { - return fmt.Errorf( - "overlay type %#q requires %#q to be a relative path; found %#q", - c.Type, fieldName, value, - ) - } +func (c *ComponentOverlay) validateRequiredFields(desc string) error { + switch c.Type { + case ComponentOverlayAddSpecTag, ComponentOverlayInsertSpecTag, + ComponentOverlaySetSpecTag, ComponentOverlayUpdateSpecTag, ComponentOverlayRemoveSpecTag: + return c.validateSpecTagFields(desc) + case ComponentOverlayPrependSpecLines, ComponentOverlayAppendSpecLines: + return c.validateSpecLineOverlay(desc) + case ComponentOverlaySearchAndReplaceInSpec: + return c.validateSpecSearchReplaceOverlay(desc) + case ComponentOverlayPrependLinesToFile, ComponentOverlaySearchAndReplaceInFile: + return c.validateFileOverlay(desc) + case ComponentOverlayAddFile: + return c.validateAddFileOverlay(desc) + case ComponentOverlayRemoveFile: + return c.validateRemoveFileOverlay(desc) + case ComponentOverlayRenameFile: + return c.validateRenameFileOverlay(desc) + case ComponentOverlayRemoveSection: + return c.validateRemoveSectionOverlay(desc) + case ComponentOverlayRemoveSubpackage: + return c.validateRemoveSubpackageOverlay(desc) + case ComponentOverlayAddPatch, ComponentOverlayRemovePatch: + return c.validatePatchOverlay(desc) + default: + return fmt.Errorf("unknown overlay type %#q: %#q", c.Type, desc) + } +} - return nil +func (c *ComponentOverlay) validateSpecTagFields(desc string) error { + if c.Tag == "" { + return fmt.Errorf("overlay type %#q requires %#q field: %s", c.Type, "tag", desc) } - requireFileBasename := func(fieldName, value string) error { - if value == "" { - return missingField(fieldName) - } + if c.Type != ComponentOverlayRemoveSpecTag && c.Value == "" { + return fmt.Errorf("overlay type %#q requires %#q field: %s", c.Type, "value", desc) + } - if value != filepath.Base(value) { - return fmt.Errorf( - "overlay type %#q requires %#q to be a filename only (not a path); found %#q", - c.Type, fieldName, value, - ) - } + return nil +} - return nil +func (c *ComponentOverlay) validateSpecLineOverlay(desc string) error { + if len(c.Lines) == 0 { + return fmt.Errorf("overlay type %#q requires %#q field: %s", c.Type, "lines", desc) } - switch c.Type { - case ComponentOverlayAddSpecTag, ComponentOverlayInsertSpecTag, - ComponentOverlaySetSpecTag, ComponentOverlayUpdateSpecTag: - if c.Tag == "" { - return missingField("tag") - } + return c.requireSectionIfPackageSet(desc) +} - if c.Value == "" { - return missingField("value") - } - case ComponentOverlayRemoveSpecTag: - if c.Tag == "" { - return missingField("tag") - } - case ComponentOverlayPrependSpecLines, ComponentOverlayAppendSpecLines: - if len(c.Lines) == 0 { - return missingField("lines") - } - case ComponentOverlaySearchAndReplaceInSpec: - if c.Regex == "" { - return missingField("regex") - } +func (c *ComponentOverlay) validateSpecSearchReplaceOverlay(desc string) error { + if c.Regex == "" { + return fmt.Errorf("overlay type %#q requires %#q field: %s", c.Type, "regex", desc) + } - if err := validateRegex(c.Regex, desc); err != nil { - return err - } - case ComponentOverlayPrependLinesToFile: - if err := requireRelativePath("file", c.Filename); err != nil { - return err - } + if err := validateRegex(c.Regex, desc); err != nil { + return err + } + + return c.requireSectionIfPackageSet(desc) +} +func (c *ComponentOverlay) validateFileOverlay(desc string) error { + if err := c.requireRelativePath(c.Filename, desc); err != nil { + return err + } + + if c.Type == ComponentOverlayPrependLinesToFile { if len(c.Lines) == 0 { - return missingField("lines") - } - case ComponentOverlaySearchAndReplaceInFile: - if err := requireRelativePath("file", c.Filename); err != nil { - return err + return fmt.Errorf("overlay type %#q requires %#q field: %s", c.Type, "lines", desc) } - if c.Regex == "" { - return missingField("regex") - } + return nil + } - if err := validateRegex(c.Regex, desc); err != nil { - return err - } - case ComponentOverlayAddFile: - if err := requireRelativePath("file", c.Filename); err != nil { - return err - } + if c.Regex == "" { + return fmt.Errorf("overlay type %#q requires %#q field: %s", c.Type, "regex", desc) + } - if c.Source == "" { - return missingField("source") - } - case ComponentOverlayRemoveFile: - if err := requireRelativePath("file", c.Filename); err != nil { - return err - } - case ComponentOverlayRenameFile: - if err := requireRelativePath("file", c.Filename); err != nil { - return err - } + return validateRegex(c.Regex, desc) +} - if err := requireFileBasename("replacement", c.Replacement); err != nil { - return err - } - case ComponentOverlayRemoveSection: - if c.SectionName == "" { - return missingField("section") - } - case ComponentOverlayRemoveSubpackage: - if c.PackageName == "" { - return missingField("package") - } +func (c *ComponentOverlay) validateAddFileOverlay(desc string) error { + if err := c.requireRelativePath(c.Filename, desc); err != nil { + return err + } - if c.SectionName != "" { - return unexpectedField("section") - } - case ComponentOverlayAddPatch: + if c.Source == "" { + return fmt.Errorf("overlay type %#q requires %#q field: %s", c.Type, "source", desc) + } + + return nil +} + +func (c *ComponentOverlay) validateRemoveFileOverlay(desc string) error { + return c.requireRelativePath(c.Filename, desc) +} + +func (c *ComponentOverlay) validateRenameFileOverlay(desc string) error { + if err := c.requireRelativePath(c.Filename, desc); err != nil { + return err + } + + return c.requireFileBasename("replacement", c.Replacement, desc) +} + +func (c *ComponentOverlay) validateRemoveSectionOverlay(desc string) error { + if c.SectionName == "" { + return fmt.Errorf("overlay type %#q requires %#q field: %s", c.Type, "section", desc) + } + + return nil +} + +func (c *ComponentOverlay) validateRemoveSubpackageOverlay(desc string) error { + if c.PackageName == "" { + return fmt.Errorf("overlay type %#q requires %#q field: %s", c.Type, "package", desc) + } + + if c.SectionName != "" { + return fmt.Errorf("overlay type %#q does not accept %#q field: %s", c.Type, "section", desc) + } + + return nil +} + +func (c *ComponentOverlay) validatePatchOverlay(desc string) error { + if c.Type == ComponentOverlayAddPatch { if c.Source == "" { - return missingField("source") + return fmt.Errorf("overlay type %#q requires %#q field: %s", c.Type, "source", desc) } // Filename is optional; if provided, it must be a relative path. if c.Filename != "" { - if err := requireRelativePath("file", c.Filename); err != nil { - return err - } - } - case ComponentOverlayRemovePatch: - if err := requireRelativePath("file", c.Filename); err != nil { - return err + return c.requireRelativePath(c.Filename, desc) } - if err := validateGlobPattern(c.Filename, desc); err != nil { - return err - } - default: - return fmt.Errorf("unknown overlay type %#q: %#q", c.Type, desc) + return nil } - if c.Metadata != nil { - if err := c.Metadata.Validate(); err != nil { - return fmt.Errorf("invalid metadata for overlay %q:\n%w", desc, err) - } + if err := c.requireRelativePath(c.Filename, desc); err != nil { + return err + } + + return validateGlobPattern(c.Filename, desc) +} + +// requireSectionIfPackageSet checks that, for overlays that may target either a single +// section or the entire spec file (indicated by omitting `section`), a `package` is only +// specified when a `section` is also specified. A package is always a sub-qualifier of +// a section, so specifying one without the other is meaningless. +func (c *ComponentOverlay) requireSectionIfPackageSet(desc string) error { + if c.SectionName == "" && c.PackageName != "" { + return fmt.Errorf( + "overlay type %#q requires %#q field when %#q is set: %s", + c.Type, "section", "package", desc, + ) + } + + return nil +} + +func (c *ComponentOverlay) requireRelativePath(value, desc string) error { + if value == "" { + return fmt.Errorf("overlay type %#q requires %#q field: %s", c.Type, "file", desc) + } + + if filepath.IsAbs(value) { + return fmt.Errorf( + "overlay type %#q requires %#q to be a relative path; found %#q", + c.Type, "file", value, + ) + } + + return nil +} + +func (c *ComponentOverlay) requireFileBasename(fieldName, value, desc string) error { + if value == "" { + return fmt.Errorf("overlay type %#q requires %#q field: %s", c.Type, fieldName, desc) + } + + if value != filepath.Base(value) { + return fmt.Errorf( + "overlay type %#q requires %#q to be a filename only (not a path); found %#q", + c.Type, fieldName, value, + ) } return nil diff --git a/internal/projectconfig/overlay_test.go b/internal/projectconfig/overlay_test.go index 84ccf200b..ece2252ef 100644 --- a/internal/projectconfig/overlay_test.go +++ b/internal/projectconfig/overlay_test.go @@ -147,6 +147,63 @@ func TestComponentOverlay_Validate(t *testing.T) { }, errorExpected: false, }, + // whole-file targeting (empty section) for prepend/append/search-replace + { + name: "spec-prepend-lines whole-file (no section) valid", + overlay: projectconfig.ComponentOverlay{ + Type: projectconfig.ComponentOverlayPrependSpecLines, + Lines: []string{"# Top of file"}, + }, + errorExpected: false, + }, + { + name: "spec-prepend-lines whole-file with package rejected", + overlay: projectconfig.ComponentOverlay{ + Type: projectconfig.ComponentOverlayPrependSpecLines, + PackageName: "foo", + Lines: []string{"# Top of file"}, + }, + errorExpected: true, + errorContains: "requires `section` field when `package` is set", + }, + { + name: "spec-append-lines whole-file (no section) valid", + overlay: projectconfig.ComponentOverlay{ + Type: projectconfig.ComponentOverlayAppendSpecLines, + Lines: []string{"# End of file"}, + }, + errorExpected: false, + }, + { + name: "spec-append-lines whole-file with package rejected", + overlay: projectconfig.ComponentOverlay{ + Type: projectconfig.ComponentOverlayAppendSpecLines, + PackageName: "foo", + Lines: []string{"# End of file"}, + }, + errorExpected: true, + errorContains: "package", + }, + { + name: "spec-search-replace whole-file (no section) valid", + overlay: projectconfig.ComponentOverlay{ + Type: projectconfig.ComponentOverlaySearchAndReplaceInSpec, + Regex: "pattern", + Replacement: "replacement", + }, + errorExpected: false, + }, + { + name: "spec-search-replace whole-file with package rejected", + overlay: projectconfig.ComponentOverlay{ + Type: projectconfig.ComponentOverlaySearchAndReplaceInSpec, + PackageName: "foo", + Regex: "pattern", + Replacement: "replacement", + }, + errorExpected: true, + errorContains: "package", + }, // spec-search-replace tests { name: "spec-search-replace valid", diff --git a/internal/rpm/spec/edit.go b/internal/rpm/spec/edit.go index ac318d711..0fdd056c3 100644 --- a/internal/rpm/spec/edit.go +++ b/internal/rpm/spec/edit.go @@ -402,6 +402,24 @@ func (s *Spec) skipPastConditional(lineNum int, sectionEnd int) int { return lineNum } +// PrependLines prepends the given lines to the very top of the spec file. This is a +// whole-file edit, distinct from section-targeted editing, which applies within a specific +// section rather than to the raw file contents. +func (s *Spec) PrependLines(lines []string) { + slog.Debug("Prepending lines to spec file", "lines", lines) + + s.rawLines = append(append([]string{}, lines...), s.rawLines...) +} + +// AppendLines appends the given lines at the very bottom of the spec file. This is a +// whole-file edit, distinct from section-targeted editing, which applies within a specific +// section rather than to the raw file contents. +func (s *Spec) AppendLines(lines []string) { + slog.Debug("Appending lines to spec file", "lines", lines) + + s.rawLines = append(s.rawLines, lines...) +} + // PrependLinesToSection prepends the given lines to the start of the specified section, placing // them just after the section header (or at the top of the file in the global section). An error // is returned if the identified section cannot be found in the spec. diff --git a/internal/rpm/spec/edit_test.go b/internal/rpm/spec/edit_test.go index 1c63b8064..959c5f104 100644 --- a/internal/rpm/spec/edit_test.go +++ b/internal/rpm/spec/edit_test.go @@ -959,6 +959,133 @@ Name: test }) } +func TestPrependLines(t *testing.T) { + t.Run("empty spec", func(t *testing.T) { + specFile, err := spec.OpenSpec(strings.NewReader("")) + require.NoError(t, err) + + specFile.PrependLines([]string{"New line", "Next line"}) + + actual := new(bytes.Buffer) + err = specFile.Serialize(actual) + require.NoError(t, err) + + assert.Equal(t, `New line +Next line +`, actual.String()) + }) + + t.Run("spec starting with a section header", func(t *testing.T) { + input := `%description +A package. +` + specFile, err := spec.OpenSpec(strings.NewReader(input)) + require.NoError(t, err) + + specFile.PrependLines([]string{"# top comment"}) + + actual := new(bytes.Buffer) + err = specFile.Serialize(actual) + require.NoError(t, err) + + assert.Equal(t, `# top comment +%description +A package. +`, actual.String()) + }) + + t.Run("spec with preamble and sections", func(t *testing.T) { + input := `Name: test +Version: 1.0 + +%description +A package. +` + specFile, err := spec.OpenSpec(strings.NewReader(input)) + require.NoError(t, err) + + specFile.PrependLines([]string{"# header line 1", "# header line 2"}) + + actual := new(bytes.Buffer) + err = specFile.Serialize(actual) + require.NoError(t, err) + + assert.Equal(t, `# header line 1 +# header line 2 +Name: test +Version: 1.0 + +%description +A package. +`, actual.String()) + }) +} + +func TestAppendLines(t *testing.T) { + t.Run("empty spec", func(t *testing.T) { + specFile, err := spec.OpenSpec(strings.NewReader("")) + require.NoError(t, err) + + specFile.AppendLines([]string{"New line", "Next line"}) + + actual := new(bytes.Buffer) + err = specFile.Serialize(actual) + require.NoError(t, err) + + assert.Equal(t, `New line +Next line +`, actual.String()) + }) + + t.Run("spec ending with changelog", func(t *testing.T) { + input := `Name: test + +%description +A package. + +%changelog +* Mon Jan 01 2024 User - 1.0-1 +- Initial release +` + specFile, err := spec.OpenSpec(strings.NewReader(input)) + require.NoError(t, err) + + specFile.AppendLines([]string{"# trailing comment"}) + + actual := new(bytes.Buffer) + err = specFile.Serialize(actual) + require.NoError(t, err) + + assert.Equal(t, `Name: test + +%description +A package. + +%changelog +* Mon Jan 01 2024 User - 1.0-1 +- Initial release +# trailing comment +`, actual.String()) + }) + + t.Run("preamble only", func(t *testing.T) { + input := `Name: test +` + specFile, err := spec.OpenSpec(strings.NewReader(input)) + require.NoError(t, err) + + specFile.AppendLines([]string{"# tail"}) + + actual := new(bytes.Buffer) + err = specFile.Serialize(actual) + require.NoError(t, err) + + assert.Equal(t, `Name: test +# tail +`, actual.String()) + }) +} + func TestPrependLinesToSection(t *testing.T) { t.Run("empty spec", func(t *testing.T) { input := "" diff --git a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap index e9ed6d37c..a238e7eb1 100755 --- a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap @@ -268,12 +268,12 @@ "section": { "type": "string", "title": "Section name", - "description": "The name of the section to which this overlay applies" + "description": "The name of the section to which this overlay applies. Optional for spec-prepend-lines/spec-append-lines/spec-search-replace; when omitted these overlays target the entire spec file." }, "package": { "type": "string", "title": "Package name", - "description": "The name of the sub-package to which this overlay applies" + "description": "The name of the sub-package to which this overlay applies. Cannot be combined with an omitted section on overlays that support whole-file targeting." }, "tag": { "type": "string", diff --git a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap index e9ed6d37c..a238e7eb1 100755 --- a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap @@ -268,12 +268,12 @@ "section": { "type": "string", "title": "Section name", - "description": "The name of the section to which this overlay applies" + "description": "The name of the section to which this overlay applies. Optional for spec-prepend-lines/spec-append-lines/spec-search-replace; when omitted these overlays target the entire spec file." }, "package": { "type": "string", "title": "Package name", - "description": "The name of the sub-package to which this overlay applies" + "description": "The name of the sub-package to which this overlay applies. Cannot be combined with an omitted section on overlays that support whole-file targeting." }, "tag": { "type": "string", diff --git a/schemas/azldev.schema.json b/schemas/azldev.schema.json index e9ed6d37c..a238e7eb1 100644 --- a/schemas/azldev.schema.json +++ b/schemas/azldev.schema.json @@ -268,12 +268,12 @@ "section": { "type": "string", "title": "Section name", - "description": "The name of the section to which this overlay applies" + "description": "The name of the section to which this overlay applies. Optional for spec-prepend-lines/spec-append-lines/spec-search-replace; when omitted these overlays target the entire spec file." }, "package": { "type": "string", "title": "Package name", - "description": "The name of the sub-package to which this overlay applies" + "description": "The name of the sub-package to which this overlay applies. Cannot be combined with an omitted section on overlays that support whole-file targeting." }, "tag": { "type": "string", From 8ce989cd980710668977cdde6f84efb75d43b89a Mon Sep 17 00:00:00 2001 From: Daniel McIlvaney Date: Tue, 7 Jul 2026 15:20:31 -0700 Subject: [PATCH 10/20] feat: in verbose mode have mock print all output (#231) --- internal/app/azldev/app.go | 8 ++-- internal/app/azldev/core/testutils/testenv.go | 2 +- internal/app/azldev/event.go | 5 +- internal/app/azldev/event_test.go | 13 +++++ internal/app/azldev/eventlistener.go | 5 +- internal/rpm/mock/mock.go | 48 +++++++++++-------- internal/rpm/mock/mock_internal_test.go | 28 +++++++++++ internal/rpm/mock/mock_test.go | 46 ++++++++++++++++++ 8 files changed, 128 insertions(+), 27 deletions(-) create mode 100644 internal/rpm/mock/mock_internal_test.go diff --git a/internal/app/azldev/app.go b/internal/app/azldev/app.go index f9da6f299..33d692e35 100644 --- a/internal/app/azldev/app.go +++ b/internal/app/azldev/app.go @@ -267,7 +267,7 @@ func (a *App) Execute(args []string) int { // stdioLogger := a.initStdioLogging() - if err := setEventListener(stdioLogger, a.quiet, envOptions); err != nil { + if err := setEventListener(stdioLogger, a.quiet, a.verbose, envOptions); err != nil { slog.Error("Error setting event listener.", "err", err) return 1 @@ -384,7 +384,7 @@ func (a *App) reInitLoggingWithLogFile(envOptions *EnvOptions) error { return fmt.Errorf("error re-initializing file logging:\n%w", err) } - err = setEventListener(logger, a.quiet, envOptions) + err = setEventListener(logger, a.quiet, a.verbose, envOptions) if err != nil { return fmt.Errorf("error re-setting event listener:\n%w", err) } @@ -448,8 +448,8 @@ func (a *App) handlePostInitCallbacks(env *Env) error { return nil } -func setEventListener(stdioLogger *slog.Logger, quiet bool, envOptions *EnvOptions) error { - eventListener, err := NewEventListener(stdioLogger, quiet) +func setEventListener(stdioLogger *slog.Logger, quiet, verbose bool, envOptions *EnvOptions) error { + eventListener, err := NewEventListener(stdioLogger, quiet, verbose) if err != nil { return fmt.Errorf("error initializing event listener:\n%w", err) } diff --git a/internal/app/azldev/core/testutils/testenv.go b/internal/app/azldev/core/testutils/testenv.go index c9d4e4f0f..22c08448f 100644 --- a/internal/app/azldev/core/testutils/testenv.go +++ b/internal/app/azldev/core/testutils/testenv.go @@ -98,7 +98,7 @@ func setUpEventListener(t *testing.T, testEnv *TestEnv) { testLogHandler := slogassert.New(t, slog.LevelDebug, nil) testEventLogger := slog.New(testLogHandler) - testEventListener, err := azldev.NewEventListener(testEventLogger, false) + testEventListener, err := azldev.NewEventListener(testEventLogger, false, false) require.NoError(t, err) testEnv.EventListener = testEventListener diff --git a/internal/app/azldev/event.go b/internal/app/azldev/event.go index 027e98d7f..329f1aa05 100644 --- a/internal/app/azldev/event.go +++ b/internal/app/azldev/event.go @@ -19,6 +19,7 @@ type event struct { name string spinner *spinner.Spinner quiet bool + verbose bool lastReportedCompletionRatio float64 @@ -50,7 +51,9 @@ func (e *event) End() { } func (e *event) SetLongRunning(longRunningText string) { - if e.quiet { + // Skip the indeterminate spinner when quiet (no UI) or verbose (the command streams its own + // output live, and a spinner would fight with that output). + if e.quiet || e.verbose { return } diff --git a/internal/app/azldev/event_test.go b/internal/app/azldev/event_test.go index 0305882fc..2aaae3c15 100644 --- a/internal/app/azldev/event_test.go +++ b/internal/app/azldev/event_test.go @@ -55,3 +55,16 @@ func TestEvent_QuietModeSkipsLongRunningAndProgressRendering(t *testing.T) { assert.False(t, testEvent.initializedProgressBar) assert.Zero(t, testEvent.lastReportedCompletionRatio) } + +func TestEvent_VerboseModeSkipsLongRunningSpinner(t *testing.T) { + testEvent := &event{ + verbose: true, + } + + stderrOutput := captureStderr(t, func() { + testEvent.SetLongRunning("working") + }) + + assert.Empty(t, stderrOutput) + assert.Nil(t, testEvent.spinner) +} diff --git a/internal/app/azldev/eventlistener.go b/internal/app/azldev/eventlistener.go index 4ca926b4a..7c509370d 100644 --- a/internal/app/azldev/eventlistener.go +++ b/internal/app/azldev/eventlistener.go @@ -17,13 +17,14 @@ type appEventListener struct { eventLevel int eventLogger *slog.Logger quiet bool + verbose bool } // Ensure [appEventListener] implements [opctx.EventListener]. var _ opctx.EventListener = &appEventListener{} // NewEventListener creates a new event listener for the environment. -func NewEventListener(eventLogger *slog.Logger, quiet bool) (*appEventListener, error) { +func NewEventListener(eventLogger *slog.Logger, quiet, verbose bool) (*appEventListener, error) { if eventLogger == nil { return nil, errors.New("event logger cannot be nil") } @@ -32,6 +33,7 @@ func NewEventListener(eventLogger *slog.Logger, quiet bool) (*appEventListener, eventLevel: 0, eventLogger: eventLogger, quiet: quiet, + verbose: verbose, }, nil } @@ -57,6 +59,7 @@ func (el *appEventListener) StartEvent(name string, args ...any) opctx.Event { parentEventListener: el, name: name, quiet: el.quiet, + verbose: el.verbose, } } diff --git a/internal/rpm/mock/mock.go b/internal/rpm/mock/mock.go index 2ed9a0a81..566aa4973 100644 --- a/internal/rpm/mock/mock.go +++ b/internal/rpm/mock/mock.go @@ -242,9 +242,7 @@ func (r *Runner) InitRoot(ctx context.Context) (err error) { return fmt.Errorf("failed to create external command for mock:\n%w", err) } - if !r.verbose { - extcmd.SetLongRunning("Waiting for mock (initializing build root)...") - } + extcmd.SetLongRunning("Waiting for mock (initializing build root)...") err = extcmd.Run(ctx) if err != nil { @@ -359,12 +357,10 @@ func (r *Runner) BuildSRPM( return fmt.Errorf("failed to create external command for mock:\n%w", err) } - if !r.verbose { - extcmd.SetLongRunning("Waiting for mock (building SRPM)...") - } + extcmd.SetLongRunning("Waiting for mock (building SRPM)...") // Watch output logs in real-time so we can asynchronously synthesize progress updates. - err = addMockCmdListeners(r.eventListener, extcmd, outputDirPath) + err = addMockCmdListeners(r.eventListener, extcmd, outputDirPath, r.verbose) if err != nil { return err } @@ -443,7 +439,7 @@ func (r *Runner) BuildRPM(ctx context.Context, srpmPath, outputDirPath string, o extcmd = extcmd.SetLongRunning("Waiting for mock (building RPM)...") // Watch output logs in real-time so we can asynchronously synthesize progress updates. - err = addMockCmdListeners(r.eventListener, extcmd, outputDirPath) + err = addMockCmdListeners(r.eventListener, extcmd, outputDirPath, r.verbose) if err != nil { return err } @@ -463,18 +459,15 @@ func (r *Runner) BuildRPM(ctx context.Context, srpmPath, outputDirPath string, o // will only run for the duration of mock's invocation, allowing us to get insights // into what's happening *inside* mock while we're otherwise opaquely blocking on its // execution. -func addMockCmdListeners(eventListener opctx.EventListener, extcmd opctx.Cmd, outputDirPath string) error { - // Color-code stderr lines. +func addMockCmdListeners( + eventListener opctx.EventListener, + extcmd opctx.Cmd, + outputDirPath string, + verbose bool, +) error { + // Keep verbose mock output readable by defaulting to the terminal's normal style. err := extcmd.SetRealTimeStderrListener(func(ctx context.Context, line string) { - if strings.HasPrefix(line, "No matching package to install:") { - color.Set(color.FgHiYellow) - } else { - color.Set(color.FgHiBlack, color.Italic) - } - - defer color.Unset() - - fmt.Fprintf(os.Stderr, "%s\n", line) + fmt.Fprintf(os.Stderr, "%s\n", styleMockStderrLine(verbose, line)) }) if err != nil { return fmt.Errorf("failed to setup mock stderr listener: %w", err) @@ -525,6 +518,18 @@ func addMockCmdListeners(eventListener opctx.EventListener, extcmd opctx.Cmd, ou return nil } +func styleMockStderrLine(verbose bool, line string) string { + if verbose { + return line + } + + if strings.HasPrefix(line, "No matching package to install:") { + return color.New(color.FgHiYellow).Sprint(line) + } + + return color.New(color.FgHiBlack, color.Italic).Sprint(line) +} + func (r *Runner) ensureMockPresentAndConfigured() error { if !r.cmdFactory.CommandInSearchPath(MockBinary) { return errors.New("mock tool required but could not be found in path") @@ -644,7 +649,10 @@ func (r *Runner) ScrubRoot(ctx context.Context) error { } func (r *Runner) getBaseArgs() (args []string) { - if !r.verbose { + if r.verbose { + // Have mock stream its full build output (including build.log) live to the console. + args = append(args, "--verbose") + } else { args = append(args, "--quiet") } diff --git a/internal/rpm/mock/mock_internal_test.go b/internal/rpm/mock/mock_internal_test.go new file mode 100644 index 000000000..71be5d345 --- /dev/null +++ b/internal/rpm/mock/mock_internal_test.go @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package mock + +import ( + "testing" + + "github.com/fatih/color" + "github.com/stretchr/testify/assert" +) + +func TestStyleMockStderrLine(t *testing.T) { + oldNoColor := color.NoColor + color.NoColor = false + + t.Cleanup(func() { + color.NoColor = oldNoColor + }) + + line := "mock stderr line" + + assert.Equal(t, line, styleMockStderrLine(true, line)) + + styledLine := styleMockStderrLine(false, line) + assert.NotEqual(t, line, styledLine) + assert.Contains(t, styledLine, line) +} diff --git a/internal/rpm/mock/mock_test.go b/internal/rpm/mock/mock_test.go index 96d5f1488..3d402c9ce 100644 --- a/internal/rpm/mock/mock_test.go +++ b/internal/rpm/mock/mock_test.go @@ -203,6 +203,52 @@ func TestBuildRPM(t *testing.T) { assert.Contains(t, mockCmd, fmt.Sprintf("--define %s %s", macroName, macroValue)) } +func TestBuildRPM_VerboseArgs(t *testing.T) { + t.Run("NonVerbose", func(t *testing.T) { + ctx := newTestCtxWithMockPrereqsPresent() + ctx.VerboseValue = false + + mockCmd := captureBuildRPMCmd(t, ctx) + + // Non-verbose runs should ask mock to be quiet and must not pass --verbose. + assert.Contains(t, mockCmd, "--quiet") + assert.NotContains(t, mockCmd, "--verbose") + }) + + t.Run("Verbose", func(t *testing.T) { + ctx := newTestCtxWithMockPrereqsPresent() + ctx.VerboseValue = true + + mockCmd := captureBuildRPMCmd(t, ctx) + + // Verbose runs should pass --verbose so mock streams its full output, and must not be quiet. + assert.Contains(t, mockCmd, "--verbose") + assert.NotContains(t, mockCmd, "--quiet") + }) +} + +// captureBuildRPMCmd runs a successful BuildRPM and returns the joined mock command line. +func captureBuildRPMCmd(t *testing.T, ctx *testctx.TestCtx) string { + t.Helper() + + var mockCmd string + + ctx.CmdFactory.RunHandler = func(cmd *exec.Cmd) error { + mockCmd = strings.Join(cmd.Args, " ") + + require.NoError(t, fileutils.WriteFile(ctx.FS(), testRPMPath, []byte{}, fileperms.PrivateFile)) + + return nil + } + + runner := mock.NewRunner(ctx, testMockConfigPath) + + err := runner.BuildRPM(ctx, testSRPMPath, testOutputDirPath, mock.RPMBuildOptions{}) + require.NoError(t, err) + + return mockCmd +} + func TestBuildRPM_MockFails(t *testing.T) { testError := errors.New("injected mock failure") From c83c8c8d33996cf167fc675ab959bf5472e4b45b Mon Sep 17 00:00:00 2001 From: Antonio Salinas Date: Wed, 8 Jul 2026 13:40:44 -0700 Subject: [PATCH 11/20] feat(source-files): add custom origin type for mock-based source generation (#261) Co-authored-by: Antonio Salinas --- docs/user/reference/config/components.md | 82 +++--- .../cmds/component/history_customizations.go | 20 +- .../cmds/component/history_internal_test.go | 9 + .../azldev/cmds/component/preparesources.go | 43 +-- internal/projectconfig/component.go | 45 +++- internal/projectconfig/configfile.go | 55 ++++ internal/projectconfig/configfile_test.go | 132 +++++++++ internal/projectconfig/fingerprint_test.go | 9 +- .../sourceproviders/customsourceprovider.go | 252 ++++++++++++++++++ .../customsourceprovider_internal_test.go | 125 +++++++++ .../sourceproviders/sourcemanager.go | 140 +++++++++- .../sourceproviders/sourcemanager_test.go | 41 ++- ...ainer_config_generate-schema_stdout_1.snap | 16 +- ...shots_config_generate-schema_stdout_1.snap | 16 +- schemas/azldev.schema.json | 16 +- 15 files changed, 919 insertions(+), 82 deletions(-) create mode 100644 internal/providers/sourceproviders/customsourceprovider.go create mode 100644 internal/providers/sourceproviders/customsourceprovider_internal_test.go diff --git a/docs/user/reference/config/components.md b/docs/user/reference/config/components.md index a08d7dd15..ccf70d22b 100644 --- a/docs/user/reference/config/components.md +++ b/docs/user/reference/config/components.md @@ -321,71 +321,71 @@ tests = [ ## Source File References -The `[[components..source-files]]` array defines additional source files that azldev should download before building. These are files not available in the dist-git repository or lookaside cache — typically binaries, pre-built artifacts, or files from custom hosting. +The `[[components..source-files]]` array defines additional source files to fetch or generate before building — binaries, pre-built artifacts, or archives generated on-the-fly by a script. | Field | TOML Key | Type | Required | Description | |-------|----------|------|----------|-------------| -| Filename | `filename` | string | **Yes** | Name of the file as it will appear in the sources directory | -| Hash | `hash` | string | Conditional | Expected hash of the downloaded file for integrity verification. Required for the `prep-sources` command unless `--allow-no-hashes` is used, in which case the hash is computed automatically from the downloaded file. | -| Hash type | `hash-type` | string | Conditional | Hash algorithm used (examples: `"SHA256"`, `"SHA512"`). Required when `hash` is specified. When omitted alongside `hash` for the `prep-sources` command and `--allow-no-hashes` is used, defaults to `"SHA512"`. | -| Origin | `origin` | [Origin](#origin) | **Yes** | Where to download the file from | -| Replace upstream | `replace-upstream` | bool | No | When `true`, intentionally replaces an entry with the same `filename` in the upstream dist-git `sources` file. The matching upstream entry **must exist**, otherwise source preparation fails (this guards against stale configs and filename typos). When `false` (default) a filename collision with an upstream entry is also an error. Requires `replace-reason`. | -| Replace reason | `replace-reason` | string | Conditional | Human-readable explanation for the replacement. **Required** when `replace-upstream = true`; **must be empty** otherwise. Captured in the `prep-sources` warning log so the override is auditable. | +| Filename | `filename` | string | **Yes** | Name of the file in the sources directory | +| Hash | `hash` | string | Conditional | Expected hash. Required unless `--allow-no-hashes` is passed to `prep-sources` (which computes and prints the hash). | +| Hash type | `hash-type` | string | Conditional | Hash algorithm (`"SHA256"`, `"SHA512"`). Required with `hash`; defaults to `"SHA512"` when auto-computed. | +| Origin | `origin` | [Origin](#origin) | **Yes** | How to obtain the file | +| Replace upstream | `replace-upstream` | bool | No | Replace the same-named entry in the upstream `sources` file. The upstream entry must exist. Requires `replace-reason`. | +| Replace reason | `replace-reason` | string | Conditional | Required when `replace-upstream = true`. Logged by `prep-sources` for auditability. | ### Origin -The `origin` field specifies how to obtain the source file. +Two origin types are supported. -| Field | TOML Key | Type | Required | Description | -|-------|----------|------|----------|-------------| -| Type | `type` | string | **Yes** | Origin type. Currently only `"download"` is supported. | -| URI | `uri` | string | No | URI to download the file from (required when type is `"download"`) | +#### `"download"` — fetch from a URI -### Example +`origin = { type = "download", uri = "https://..." }`. The `uri` field is required. ```toml [[components.shim.source-files]] -filename = "shimx64.efi" -hash = "7741013d9a24ce554bf6a9df6b776a57b114055e..." +filename = "shimx64.efi" +hash = "7741013d9a24ce554bf6a9df6b776a57b114055e..." hash-type = "SHA512" -origin = { type = "download", uri = "https://example.com/repo/pkgs/shim/shimx64.efi/sha512/.../shimx64.efi" } +origin = { type = "download", uri = "https://example.com/repo/.../shimx64.efi" } +``` -[[components.shim.source-files]] -filename = "shimaa64.efi" -hash = "57aa116d1c91a9ec36ab8b46c9164ae19af192b..." +#### `"custom"` — generate via a mock script + +Use `origin.type = "custom"` when a source archive must be assembled or modified (e.g. stripping sensitive test fixtures from an upstream tarball). azldev runs the script inside a fresh mock chroot — the script **must write all output to `/azldev-gen/output/`**, which azldev packages into the archive named by `filename`. Network access is always enabled; the mock config comes from the project's default distro. + +The `script` and `mock-packages` fields are nested under `[origin]`: + +| Field | TOML Key | Type | Required | Description | +|-------|----------|------|----------|-------------| +| Script | `origin.script` | string | **Yes** | Script filename (relative to the component's spec dir) to run in mock. Required for `origin.type = "custom"`. | +| Mock packages | `origin.mock-packages` | array of string | No | Extra RPM packages to install in the mock chroot before the script runs. | + +On first use, omit `hash` and run `prep-sources --allow-no-hashes` to generate the archive and print its hash, then copy it into the TOML. + +```toml +[[components.yara.source-files]] +filename = "yara-4.5.4-azl-stripped.tar.gz" hash-type = "SHA512" -origin = { type = "download", uri = "https://example.com/repo/pkgs/shim/shimaa64.efi/sha512/.../shimaa64.efi" } +hash = "abc123..." # from: prep-sources --allow-no-hashes +origin.type = "custom" +origin.script = "gen-yara-stripped.sh" # relative to the component's spec directory +origin.mock-packages = ["cmake"] # omit if not needed ``` ### Replacing an upstream `sources` entry -By default, declaring a `source-files` entry whose `filename` matches one already -listed in the upstream dist-git `sources` file is an **error** — this protects -against accidental shadowing of the upstream tarball. - -To intentionally substitute a different artifact for an upstream entry (e.g. to -ship a locally-patched tarball), set `replace-upstream = true` together with a -non-empty `replace-reason`: +A `source-files` entry whose `filename` collides with an upstream `sources` entry is an error by default. Set `replace-upstream = true` (with a non-empty `replace-reason`) to intentionally substitute it: ```toml [[components.example.source-files]] -filename = "example-1.0.tar.gz" # same filename as the upstream 'sources' entry -hash = "deadbeef..." -hash-type = "SHA512" -origin = { type = "download", uri = "https://internal.example.com/example-1.0-patched.tar.gz" } +filename = "example-1.0.tar.gz" +hash = "deadbeef..." +hash-type = "SHA512" +origin = { type = "download", uri = "https://internal.example.com/example-1.0-patched.tar.gz" } replace-upstream = true -replace-reason = "patched to fix CVE-2026-0001 before upstream" +replace-reason = "patched to fix CVE-2026-0001 before upstream" ``` -When `prep-sources` runs: - -- The matching upstream entry is removed from the generated `sources` file and - the user-defined entry takes its place. -- A `WARN`-level event is logged citing the `replace-reason`, both upstream and - new hashes, so the override is auditable. -- If **no upstream entry** exists with that `filename`, `prep-sources` fails — - this is almost always a stale config or filename typo. Drop - `replace-upstream` (and `replace-reason`) if you intended a brand-new +`prep-sources` removes the matching upstream entry, inserts the new one in its place, and logs a `WARN` with both hashes and the reason. If no upstream entry with that filename exists, `prep-sources` fails — this is almost always a stale config or filename typo. Drop `replace-upstream` if you intended a brand-new artifact instead. `replace-upstream` and `replace-reason` are per-entry switches, not a diff --git a/internal/app/azldev/cmds/component/history_customizations.go b/internal/app/azldev/cmds/component/history_customizations.go index f0fa64a28..ad5de7061 100644 --- a/internal/app/azldev/cmds/component/history_customizations.go +++ b/internal/app/azldev/cmds/component/history_customizations.go @@ -207,9 +207,9 @@ func appendPackageItems( } // appendSourceFileItems emits one item per declared source-file reference, -// plus a distinct item for the high-signal ReplaceUpstream toggle (which -// actively masks a same-named upstream source and would otherwise be hidden -// behind the plain filename entry). +// plus distinct items for high-signal toggles: the ReplaceUpstream flag (which +// actively masks a same-named upstream source), the Script field (custom +// generation script), and MockPackages (chroot deps for that script). func appendSourceFileItems( items []CustomizationItem, sourceFiles []projectconfig.SourceFileReference, ) []CustomizationItem { @@ -222,6 +222,20 @@ func appendSourceFileItems( Value: sourceFile.Filename, }) } + + if sourceFile.Origin.Script != "" { + items = append(items, CustomizationItem{ + Kind: "source-files.script", + Value: sourceFile.Origin.Script, + }) + } + + for _, pkg := range sourceFile.Origin.MockPackages { + items = append(items, CustomizationItem{ + Kind: "source-files.mock-packages", + Value: pkg, + }) + } } return items diff --git a/internal/app/azldev/cmds/component/history_internal_test.go b/internal/app/azldev/cmds/component/history_internal_test.go index 3504f3902..33e39a246 100644 --- a/internal/app/azldev/cmds/component/history_internal_test.go +++ b/internal/app/azldev/cmds/component/history_internal_test.go @@ -86,6 +86,7 @@ func TestCustomizationCollectorsCoverEveryFingerprintableField(t *testing.T) { reflect.TypeFor[projectconfig.ReleaseConfig](), reflect.TypeFor[projectconfig.ComponentRenderConfig](), reflect.TypeFor[projectconfig.SourceFileReference](), + reflect.TypeFor[projectconfig.Origin](), } // Maps "StructName.FieldName" -> short note describing how the field @@ -133,10 +134,18 @@ func TestCustomizationCollectorsCoverEveryFingerprintableField(t *testing.T) { // their own Kind. Hash/HashType are deliberately NOT emitted as output: // the file's *presence* is the customization signal, and a checksum-only // change is still caught by toml-commits / fingerprint-changes. + // Origin is walked as its own struct (see below); its Script and + // MockPackages fields are emitted when set (custom-origin source files). "SourceFileReference.Filename": "source-files", "SourceFileReference.Hash": "not emitted (checksum change caught via toml-commits/fingerprint)", "SourceFileReference.HashType": "not emitted (ditto Hash)", "SourceFileReference.ReplaceUpstream": "source-files.replace-upstream", + "SourceFileReference.Origin": "delegates to Origin walk", + + // Origin -- Script and MockPackages are fingerprint-relevant; Type and Uri + // are tagged fingerprint:"-" (download location, not build input). + "Origin.Script": "source-files.script", + "Origin.MockPackages": "source-files.mock-packages", } actualFields := make(map[string]bool) diff --git a/internal/app/azldev/cmds/component/preparesources.go b/internal/app/azldev/cmds/component/preparesources.go index 2f0ffcaab..f50c5be2a 100644 --- a/internal/app/azldev/cmds/component/preparesources.go +++ b/internal/app/azldev/cmds/component/preparesources.go @@ -130,21 +130,7 @@ func PrepareComponentSources(env *azldev.Env, options *PrepareSourcesOptions) er "synthetic history requires overlays to be applied") } - var preparerOpts []sources.PreparerOption - if !options.WithoutGitRepo && !options.SkipOverlays { - preparerOpts = append(preparerOpts, - sources.WithGitRepo(env, env.LockReader(), distro.Version.ReleaseVer), - sources.WithDirtyDetection(), - ) - } - - if options.AllowNoHashes { - preparerOpts = append(preparerOpts, sources.WithAllowNoHashes()) - } - - if options.SkipSources { - preparerOpts = append(preparerOpts, sources.WithSkipLookaside()) - } + preparerOpts := buildPreparerOptions(env, distro, options) preparer, err := sources.NewPreparer(sourceManager, env.FS(), env, env, preparerOpts...) if err != nil { @@ -159,6 +145,33 @@ func PrepareComponentSources(env *azldev.Env, options *PrepareSourcesOptions) er return nil } +// buildPreparerOptions assembles [sources.PreparerOption] values based on the resolved +// distro and command-line options. +func buildPreparerOptions( + env *azldev.Env, + distro sourceproviders.ResolvedDistro, + options *PrepareSourcesOptions, +) []sources.PreparerOption { + var opts []sources.PreparerOption + + if !options.WithoutGitRepo && !options.SkipOverlays { + opts = append(opts, + sources.WithGitRepo(env, env.LockReader(), distro.Version.ReleaseVer), + sources.WithDirtyDetection(), + ) + } + + if options.AllowNoHashes { + opts = append(opts, sources.WithAllowNoHashes()) + } + + if options.SkipSources { + opts = append(opts, sources.WithSkipLookaside()) + } + + return opts +} + // CheckOutputDir verifies the output directory state before source preparation. // If the directory exists and is non-empty, it either removes it (when Force is set) // or returns an actionable error suggesting --force. diff --git a/internal/projectconfig/component.go b/internal/projectconfig/component.go index d9dcd9251..4d74d570b 100644 --- a/internal/projectconfig/component.go +++ b/internal/projectconfig/component.go @@ -43,15 +43,43 @@ type OriginType string const ( // OriginTypeURI indicates that the source file is fetched from a URI. OriginTypeURI OriginType = "download" + + // OriginTypeCustom indicates that the source file is generated by running a local + // shell script inside a mock chroot. The script is expected to populate a specific + // output directory; azldev then packages that directory into a deterministic archive. + OriginTypeCustom OriginType = "custom" ) // Origin describes where a source file comes from and how to retrieve it. // When omitted from a source file reference, the file will be resolved via the lookaside cache. type Origin struct { // Type indicates how the source file should be acquired. - Type OriginType `toml:"type" json:"type" jsonschema:"required,enum=download,title=Origin type,description=Type of origin for this source file"` + Type OriginType `toml:"type" json:"type" jsonschema:"required,enum=download,enum=custom,title=Origin type,description=Type of origin for this source file" fingerprint:"-"` // Uri to download the source file from if origin type is 'download'. Ignored for other origin types. - Uri string `toml:"uri,omitempty" json:"uri,omitempty" jsonschema:"title=URI,description=URI to download the source file from if origin type is 'download',example=https://example.com/source.tar.gz"` + Uri string `toml:"uri,omitempty" json:"uri,omitempty" jsonschema:"title=URI,description=URI to download the source file from if origin type is 'download',example=https://example.com/source.tar.gz" fingerprint:"-"` + + // Script is the filename of a shell script, relative to the component's spec directory, + // that is run inside a mock chroot to generate this source file. + // Required when [Origin.Type] is 'custom'; must be empty otherwise. + Script string `toml:"script,omitempty" json:"script,omitempty" jsonschema:"title=Script,description=Shell script filename (relative to the component spec directory) to run in mock to generate this source file. Required when origin type is 'custom'."` + + // MockPackages is a list of RPM package names to install in the mock chroot before + // running [Origin.Script]. Only valid when [Origin.Type] is 'custom'. + MockPackages []string `toml:"mock-packages,omitempty" json:"mockPackages,omitempty" jsonschema:"title=Mock packages,description=RPM packages to install in the mock chroot before running the generation script. Only valid when origin type is 'custom'."` +} + +// HashInclude implements the hashstructure [Includable] interface so that +// [Origin.Script] and [Origin.MockPackages] are omitted from the component +// fingerprint when they hold their zero values. +func (o Origin) HashInclude(field string, _ any) (bool, error) { + switch field { + case "Script": + return o.Script != "", nil + case "MockPackages": + return len(o.MockPackages) > 0, nil + } + + return true, nil } // SourceFileReference encapsulates a reference to a specific source file artifact. @@ -69,7 +97,7 @@ type SourceFileReference struct { HashType fileutils.HashType `toml:"hash-type,omitempty" json:"hashType,omitempty" jsonschema:"enum=SHA256,enum=SHA512,title=Hash type,description=Hash algorithm used for the hash value"` // Origin for this source file. When omitted, the file is resolved via the lookaside cache. - Origin Origin `toml:"origin,omitempty" json:"origin,omitempty" fingerprint:"-"` + Origin Origin `toml:"origin,omitempty" json:"origin,omitempty"` // ReplaceUpstream, when true, intentionally replaces an upstream entry with the same // [SourceFileReference.Filename] in the dist-git 'sources' file. The matching upstream @@ -84,6 +112,17 @@ type SourceFileReference struct { ReplaceReason string `toml:"replace-reason,omitempty" json:"replaceReason,omitempty" jsonschema:"title=Replace reason,description=Required when 'replace-upstream' is true. Human-readable explanation for the replacement." fingerprint:"-"` } +// HashInclude implements the hashstructure [Includable] interface so that +// [SourceFileReference.Origin] is omitted from the component fingerprint when +// neither [Origin.Script] nor [Origin.MockPackages] are set. +func (r SourceFileReference) HashInclude(field string, _ any) (bool, error) { + if field == "Origin" { + return r.Origin.Script != "" || len(r.Origin.MockPackages) > 0, nil + } + + return true, nil +} + // ComponentPublishConfig holds publish channel settings for a component's packages. // The zero value means all channels are inherited from a higher-priority config layer. type ComponentPublishConfig struct { diff --git a/internal/projectconfig/configfile.go b/internal/projectconfig/configfile.go index cb102e5d2..5393edd9d 100644 --- a/internal/projectconfig/configfile.go +++ b/internal/projectconfig/configfile.go @@ -390,6 +390,10 @@ func validateSourceFiles(sourceFiles []SourceFileReference, componentName string return err } + if err := validateCustomSourceRef(ref, componentName); err != nil { + return err + } + if err := validateOrigin(ref.Origin, ref.Filename, componentName); err != nil { return err } @@ -424,6 +428,46 @@ func validateReplaceUpstream(ref SourceFileReference, componentName string) erro return nil } +// validateCustomSourceRef enforces the pairing rules for the 'script' and 'mock-packages' +// fields on a [SourceFileReference]: +// - 'script' is required when 'origin.type' is 'custom'. +// - 'script' must be empty when 'origin.type' is not 'custom'. +// - 'mock-packages' must be empty when 'origin.type' is not 'custom'. +func validateCustomSourceRef(ref SourceFileReference, componentName string) error { + if ref.Origin.Type == OriginTypeCustom { + if ref.Origin.Script == "" { + return fmt.Errorf( + "source file %#q in component %#q has 'custom' origin but no 'script'; "+ + "a non-empty 'script' filename is required for 'custom' origin", + ref.Filename, componentName) + } + + if err := fileutils.ValidateFilename(ref.Origin.Script); err != nil { + return fmt.Errorf( + "invalid 'script' value %#q for source file %#q in component %#q:\n%w", + ref.Origin.Script, ref.Filename, componentName, err) + } + + return nil + } + + if ref.Origin.Script != "" { + return fmt.Errorf( + "source file %#q in component %#q has 'script' set but origin type is %#q; "+ + "'script' is only valid when origin type is 'custom'", + ref.Filename, componentName, string(ref.Origin.Type)) + } + + if len(ref.Origin.MockPackages) > 0 { + return fmt.Errorf( + "source file %#q in component %#q has 'mock-packages' set but origin type is %#q; "+ + "'mock-packages' is only valid when origin type is 'custom'", + ref.Filename, componentName, string(ref.Origin.Type)) + } + + return nil +} + // validateOrigin checks that a source file [Origin] is present and valid for its type. // For [OriginTypeURI] ('download'), the [Origin.Uri] field must be a valid URI with a scheme. func validateOrigin(origin Origin, filename string, componentName string) error { @@ -456,6 +500,17 @@ func validateOrigin(origin Origin, filename string, componentName string) error "URI %#q is missing a scheme (e.g. 'https://')", filename, componentName, origin.Uri) } + + case OriginTypeCustom: + // Script validation is handled by validateCustomSourceRef on SourceFileReference. + // Reject 'uri' since it is meaningless for custom-generated sources. + if origin.Uri != "" { + return fmt.Errorf( + "source file %#q in component %#q has 'uri' set but origin type is 'custom'; "+ + "'uri' is only valid when origin type is 'download'", + filename, componentName) + } + default: return fmt.Errorf( "unsupported 'origin' type %#q for source file %#q, component %#q", diff --git a/internal/projectconfig/configfile_test.go b/internal/projectconfig/configfile_test.go index f09e787f4..21c7db578 100644 --- a/internal/projectconfig/configfile_test.go +++ b/internal/projectconfig/configfile_test.go @@ -727,3 +727,135 @@ func TestProjectConfigFileValidation_PerComponentSnapshotDisallowed(t *testing.T assert.Contains(t, err.Error(), "snapshot") assert.Contains(t, err.Error(), "test-component") } + +// --- Custom origin source file validation --- + +func TestValidateCustomSourceRef_ValidCustomOrigin(t *testing.T) { + file := projectconfig.ConfigFile{ + Components: map[string]projectconfig.ComponentConfig{ + "comp": { + SourceFiles: []projectconfig.SourceFileReference{ + { + Filename: "gen.tar.gz", + Origin: projectconfig.Origin{ + Type: projectconfig.OriginTypeCustom, + Script: "gen.sh", + }, + }, + }, + }, + }, + } + assert.NoError(t, file.Validate()) +} + +func TestValidateCustomSourceRef_MissingScript(t *testing.T) { + file := projectconfig.ConfigFile{ + Components: map[string]projectconfig.ComponentConfig{ + "comp": { + SourceFiles: []projectconfig.SourceFileReference{ + { + Filename: "gen.tar.gz", + Origin: projectconfig.Origin{Type: projectconfig.OriginTypeCustom}, + // Script intentionally absent + }, + }, + }, + }, + } + err := file.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "script") + assert.Contains(t, err.Error(), "gen.tar.gz") + assert.Contains(t, err.Error(), "comp") +} + +func TestValidateCustomSourceRef_ScriptOnDownloadOrigin(t *testing.T) { + file := projectconfig.ConfigFile{ + Components: map[string]projectconfig.ComponentConfig{ + "comp": { + SourceFiles: []projectconfig.SourceFileReference{ + { + Filename: "src.tar.gz", + Origin: projectconfig.Origin{ + Type: projectconfig.OriginTypeURI, + Uri: "https://example.com/src.tar.gz", + Script: "gen.sh", + }, + }, + }, + }, + }, + } + err := file.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "'script'") + assert.Contains(t, err.Error(), "custom") +} + +func TestValidateCustomSourceRef_MockPackagesOnDownloadOrigin(t *testing.T) { + file := projectconfig.ConfigFile{ + Components: map[string]projectconfig.ComponentConfig{ + "comp": { + SourceFiles: []projectconfig.SourceFileReference{ + { + Filename: "src.tar.gz", + Origin: projectconfig.Origin{ + Type: projectconfig.OriginTypeURI, + Uri: "https://example.com/src.tar.gz", + MockPackages: []string{"curl"}, + }, + }, + }, + }, + }, + } + err := file.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "'mock-packages'") + assert.Contains(t, err.Error(), "custom") +} + +func TestValidateCustomSourceRef_UriOnCustomOrigin(t *testing.T) { + file := projectconfig.ConfigFile{ + Components: map[string]projectconfig.ComponentConfig{ + "comp": { + SourceFiles: []projectconfig.SourceFileReference{ + { + Filename: "gen.tar.gz", + Origin: projectconfig.Origin{ + Type: projectconfig.OriginTypeCustom, + Uri: "https://example.com/should-not-be-here", + Script: "gen.sh", + }, + }, + }, + }, + }, + } + err := file.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "'uri'") + assert.Contains(t, err.Error(), "custom") +} + +func TestValidateCustomSourceRef_InvalidScriptFilename(t *testing.T) { + file := projectconfig.ConfigFile{ + Components: map[string]projectconfig.ComponentConfig{ + "comp": { + SourceFiles: []projectconfig.SourceFileReference{ + { + Filename: "gen.tar.gz", + Origin: projectconfig.Origin{ + Type: projectconfig.OriginTypeCustom, + Script: "../../escape.sh", // path traversal attempt + }, + }, + }, + }, + }, + } + err := file.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "script") +} diff --git a/internal/projectconfig/fingerprint_test.go b/internal/projectconfig/fingerprint_test.go index d8b733155..176c01434 100644 --- a/internal/projectconfig/fingerprint_test.go +++ b/internal/projectconfig/fingerprint_test.go @@ -32,6 +32,7 @@ func TestAllFingerprintedFieldsHaveDecision(t *testing.T) { reflect.TypeFor[projectconfig.SpecSource](), reflect.TypeFor[projectconfig.DistroReference](), reflect.TypeFor[projectconfig.SourceFileReference](), + reflect.TypeFor[projectconfig.Origin](), reflect.TypeFor[projectconfig.ReleaseConfig](), reflect.TypeFor[projectconfig.ComponentRenderConfig](), } @@ -88,10 +89,12 @@ func TestAllFingerprintedFieldsHaveDecision(t *testing.T) { // Excluding this prevents a snapshot bump from marking all upstream components as changed. "DistroReference.Snapshot": true, - // SourceFileReference.Origin — download location metadata (URI, type), not a build input. + // Origin.Type and Origin.Uri — download location metadata, not build inputs. // The file content is already captured by Filename + Hash; changing a CDN URL should not - // trigger a rebuild. - "SourceFileReference.Origin": true, + // trigger a rebuild. Script and MockPackages remain fingerprinted because they determine + // what the generated artifact contains. + "Origin.Type": true, + "Origin.Uri": true, // SourceFileReference.ReplaceReason — human documentation for why an upstream entry is // being replaced. ReplaceUpstream itself remains in the fingerprint because flipping it diff --git a/internal/providers/sourceproviders/customsourceprovider.go b/internal/providers/sourceproviders/customsourceprovider.go new file mode 100644 index 000000000..0f2efcfbe --- /dev/null +++ b/internal/providers/sourceproviders/customsourceprovider.go @@ -0,0 +1,252 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package sourceproviders + +import ( + "context" + "fmt" + "log/slog" + "path/filepath" + + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/components" + "github.com/microsoft/azure-linux-dev-tools/internal/global/opctx" + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" + "github.com/microsoft/azure-linux-dev-tools/internal/rpm/mock" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/archive" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileperms" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils" +) + +// customGenScriptDir is the path inside the mock chroot where the generation script +// is bind-mounted (read-only). +const customGenScriptDir = "/azldev-gen/script" + +// customGenOutputDir is the path inside the mock chroot where the generation script +// must write its output. +const customGenOutputDir = "/azldev-gen/output" + +// customFileSourceProvider implements [FileSourceProvider] for source files with +// [projectconfig.OriginTypeCustom]. It executes a user-supplied script inside a +// fresh mock chroot and packages the output directory as a deterministic archive. +type customFileSourceProvider struct { + fs opctx.FS + runner *mock.Runner +} + +var _ FileSourceProvider = (*customFileSourceProvider)(nil) + +// GetFile implements [FileSourceProvider]. It returns [ErrNotFound] for any file +// reference whose origin type is not [projectconfig.OriginTypeCustom], allowing +// the source manager to fall through to lookaside and other configured origins. +func (p *customFileSourceProvider) GetFile( + ctx context.Context, + component components.Component, + ref projectconfig.SourceFileReference, + destDirPath string, +) error { + if ref.Origin.Type != projectconfig.OriginTypeCustom { + return ErrNotFound + } + + destPath := filepath.Join(destDirPath, ref.Filename) + + return generateCustomSourceFile(ctx, p.fs, p.runner, component, &ref, destPath) +} + +// generateCustomSourceFile runs a single [projectconfig.SourceFileReference] through a +// fresh mock chroot and places the resulting deterministic archive at destPath. +// +// The runner must be configured with the distro's mock config path. A clone of the runner +// is created for each call so bind mounts and package installs do not bleed across invocations. +func generateCustomSourceFile( + ctx context.Context, + fs opctx.FS, + baseRunner *mock.Runner, + component components.Component, + ref *projectconfig.SourceFileReference, + destPath string, +) error { + slog.Info("Generating custom source file", + "filename", ref.Filename, + "script", ref.Origin.Script, + "component", component.GetName()) + + specDir, err := resolveComponentSpecDir(component) + if err != nil { + return fmt.Errorf("failed to resolve spec directory for component %#q:\n%w", + component.GetName(), err) + } + + // Verify the generation script is present before spinning up a mock chroot. + scriptHostPath := filepath.Join(specDir, ref.Origin.Script) + + if _, statErr := fs.Stat(scriptHostPath); statErr != nil { + return fmt.Errorf("generation script %#q not found at %#q:\n%w", + ref.Origin.Script, scriptHostPath, statErr) + } + + scriptTmpDir, genOutputTmpDir, cleanup, err := prepareStagingDirs(fs, scriptHostPath, ref.Origin.Script) + if err != nil { + return err + } + + defer cleanup() + + // Package the output directory as a deterministic archive whose format is + // inferred from the filename extension (e.g., .tar.gz, .tar.xz). + comp, compErr := archive.DetectCompression(ref.Filename) + if compErr != nil { + return fmt.Errorf("cannot determine archive format for custom source %#q:\n%w", + ref.Filename, compErr) + } + + // Clone the base runner so bind mounts added here don't persist to other calls. + // Network access is always enabled — custom source scripts commonly need to + // download upstream tarballs or toolchain artifacts. + runner := baseRunner.Clone() + runner.EnableNetwork() + runner.WithUnprivileged() + runner.AddBindMount(scriptTmpDir, customGenScriptDir) + runner.AddBindMount(genOutputTmpDir, customGenOutputDir) + + if err := execScriptInChroot(ctx, runner, ref); err != nil { + return err + } + + // Ensure the destination directory exists. FetchFiles runs before FetchComponent, + // so the output directory may not have been created yet when this is called. + if mkdirErr := fileutils.MkdirAll(fs, filepath.Dir(destPath)); mkdirErr != nil { + return fmt.Errorf("failed to create destination directory for %#q:\n%w", + ref.Filename, mkdirErr) + } + + if archiveErr := archive.CreateDeterministicArchive(destPath, genOutputTmpDir, comp); archiveErr != nil { + return fmt.Errorf("failed to create archive %#q:\n%w", ref.Filename, archiveErr) + } + + slog.Info("Custom source file generated successfully", + "filename", ref.Filename, + "path", destPath) + + return nil +} + +// resolveComponentSpecDir returns the directory on the host filesystem that contains the +// component's spec file and its sidecar files (patches, generation scripts, etc.). +// +// For local components the spec path is explicit; its parent directory is returned directly. +// For upstream components the directory is inferred from the config file that defines the +// component, because the script is a project-local file rather than something fetched from +// the upstream dist-git. +func resolveComponentSpecDir(component components.Component) (string, error) { + config := component.GetConfig() + + // Local components: the spec path is an absolute path on disk. + if config.Spec.SourceType == projectconfig.SpecSourceTypeLocal && config.Spec.Path != "" { + return filepath.Dir(config.Spec.Path), nil + } + + // Upstream components: derive from the config file that defines the component. + if config.SourceConfigFile != nil { + return config.SourceConfigFile.Dir(), nil + } + + return "", fmt.Errorf( + "cannot determine spec directory for component %#q: "+ + "neither a local spec path nor a config file directory is available", + config.Name) +} + +// prepareStagingDirs creates two temporary host directories: +// - a read-only script directory containing a copy of the generation script +// - a read-write output directory to be bind-mounted inside the chroot +// +// The returned cleanup function removes both directories; callers must defer it. +func prepareStagingDirs( + fs opctx.FS, + scriptHostPath string, + scriptName string, +) (scriptDir string, outputDir string, cleanup func(), err error) { + scriptDir, err = fileutils.MkdirTempInTempDir(fs, "azldev-script-*") + if err != nil { + return "", "", nil, fmt.Errorf("failed to create temporary script directory:\n%w", err) + } + + outputDir, err = fileutils.MkdirTempInTempDir(fs, "azldev-output-*") + if err != nil { + _ = fs.RemoveAll(scriptDir) + + return "", "", nil, fmt.Errorf("failed to create temporary output directory:\n%w", err) + } + + cleanup = func() { + _ = fs.RemoveAll(scriptDir) + _ = fs.RemoveAll(outputDir) + } + + // Copy the script into the staging directory so we bind-mount only that file, + // without leaking other sidecar files from the spec directory. + scriptData, readErr := fileutils.ReadFile(fs, scriptHostPath) + if readErr != nil { + cleanup() + + return "", "", nil, fmt.Errorf("failed to read generation script %#q:\n%w", + scriptName, readErr) + } + + hostScriptCopy := filepath.Join(scriptDir, scriptName) + + if writeErr := fileutils.WriteFile(fs, hostScriptCopy, scriptData, fileperms.PublicExecutable); writeErr != nil { + cleanup() + + return "", "", nil, fmt.Errorf("failed to stage generation script to %#q:\n%w", + hostScriptCopy, writeErr) + } + + return scriptDir, outputDir, cleanup, nil +} + +// execScriptInChroot initialises a fresh mock root, optionally installs extra packages, +// runs the generation script, and scrubs the root on return. +func execScriptInChroot( + ctx context.Context, + runner *mock.Runner, + ref *projectconfig.SourceFileReference, +) error { + if initErr := runner.InitRoot(ctx); initErr != nil { + return fmt.Errorf("failed to initialize mock root for generating %#q:\n%w", + ref.Filename, initErr) + } + + defer func() { + // Best-effort cleanup. Log on failure rather than overwriting the primary error. + if scrubErr := runner.ScrubRoot(ctx); scrubErr != nil { + slog.Warn("Failed to scrub mock root after custom source generation", + "filename", ref.Filename, + "error", scrubErr) + } + }() + + if len(ref.Origin.MockPackages) > 0 { + if installErr := runner.InstallPackages(ctx, ref.Origin.MockPackages); installErr != nil { + return fmt.Errorf("failed to install mock packages for generating %#q:\n%w", + ref.Filename, installErr) + } + } + + scriptChrootPath := filepath.Join(customGenScriptDir, ref.Origin.Script) + + cmd, cmdErr := runner.CmdInChroot(ctx, []string{scriptChrootPath}, false /* interactive */) + if cmdErr != nil { + return fmt.Errorf("failed to create chroot command for generating %#q:\n%w", + ref.Filename, cmdErr) + } + + if runErr := cmd.Run(ctx); runErr != nil { + return fmt.Errorf("generation script %#q failed for source %#q:\n%w", + ref.Origin.Script, ref.Filename, runErr) + } + + return nil +} diff --git a/internal/providers/sourceproviders/customsourceprovider_internal_test.go b/internal/providers/sourceproviders/customsourceprovider_internal_test.go new file mode 100644 index 000000000..e51ba6488 --- /dev/null +++ b/internal/providers/sourceproviders/customsourceprovider_internal_test.go @@ -0,0 +1,125 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package sourceproviders + +import ( + "context" + "path/filepath" + "testing" + + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/components/components_testutils" + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileperms" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestCustomFileSourceProvider_GetFile_NonCustomOriginReturnsNotFound(t *testing.T) { + provider := &customFileSourceProvider{ + fs: afero.NewMemMapFs(), + runner: nil, // never reached + } + + ctrl := gomock.NewController(t) + comp := components_testutils.NewMockComponent(ctrl) + + ref := projectconfig.SourceFileReference{ + Filename: "src.tar.gz", + Origin: projectconfig.Origin{Type: projectconfig.OriginTypeURI}, + } + + err := provider.GetFile(context.Background(), comp, ref, "/output") + assert.ErrorIs(t, err, ErrNotFound) +} + +func TestCustomFileSourceProvider_GetFile_MissingScriptReturnsError(t *testing.T) { + provider := &customFileSourceProvider{ + fs: afero.NewMemMapFs(), + runner: nil, // never reached — script stat check fails first + } + + ctrl := gomock.NewController(t) + comp := components_testutils.NewMockComponent(ctrl) + comp.EXPECT().GetName().Return("yara").AnyTimes() + comp.EXPECT().GetConfig().Return(&projectconfig.ComponentConfig{ + Name: "yara", + Spec: projectconfig.SpecSource{ + SourceType: projectconfig.SpecSourceTypeLocal, + Path: "/specs/yara/yara.spec", + }, + }).AnyTimes() + + ref := projectconfig.SourceFileReference{ + Filename: "gen.tar.gz", + Origin: projectconfig.Origin{ + Type: projectconfig.OriginTypeCustom, + Script: "gen.sh", + }, + } + + err := provider.GetFile(context.Background(), comp, ref, "/output") + require.Error(t, err) + assert.Contains(t, err.Error(), "gen.sh") + assert.NotErrorIs(t, err, ErrNotFound) +} + +func TestResolveComponentSpecDir_LocalComponent(t *testing.T) { + ctrl := gomock.NewController(t) + comp := components_testutils.NewMockComponent(ctrl) + comp.EXPECT().GetConfig().Return(&projectconfig.ComponentConfig{ + Spec: projectconfig.SpecSource{ + SourceType: projectconfig.SpecSourceTypeLocal, + Path: "/specs/yara/yara.spec", + }, + }).AnyTimes() + + dir, err := resolveComponentSpecDir(comp) + require.NoError(t, err) + assert.Equal(t, filepath.FromSlash("/specs/yara"), dir) +} + +func TestResolveComponentSpecDir_NoSpecInfoReturnsError(t *testing.T) { + ctrl := gomock.NewController(t) + comp := components_testutils.NewMockComponent(ctrl) + comp.EXPECT().GetConfig().Return(&projectconfig.ComponentConfig{ + Name: "yara", + // No Spec.Path, no SourceConfigFile + }).AnyTimes() + + _, err := resolveComponentSpecDir(comp) + require.Error(t, err) + assert.Contains(t, err.Error(), "yara") +} + +func TestPrepareStagingDirs_ScriptIsStagedAndExecutable(t *testing.T) { + memFS := afero.NewMemMapFs() + + const scriptContent = "#!/bin/bash\necho hello" + + require.NoError(t, afero.WriteFile(memFS, "/scripts/gen.sh", []byte(scriptContent), fileperms.PublicExecutable)) + + scriptDir, outputDir, cleanup, err := prepareStagingDirs(memFS, "/scripts/gen.sh", "gen.sh") + require.NoError(t, err) + require.NotEmpty(t, scriptDir) + require.NotEmpty(t, outputDir) + require.NotNil(t, cleanup) + + defer cleanup() + + // Script should have been copied into the staging dir on the same FS. + data, readErr := afero.ReadFile(memFS, filepath.Join(scriptDir, "gen.sh")) + require.NoError(t, readErr) + assert.Equal(t, scriptContent, string(data)) +} + +func TestPrepareStagingDirs_MissingScriptReturnsError(t *testing.T) { + emptyFS := afero.NewMemMapFs() // empty — no script present + + _, _, cleanup, err := prepareStagingDirs(emptyFS, "/scripts/missing.sh", "missing.sh") + require.Error(t, err) + assert.Contains(t, err.Error(), "missing.sh") + assert.Nil(t, cleanup) +} diff --git a/internal/providers/sourceproviders/sourcemanager.go b/internal/providers/sourceproviders/sourcemanager.go index b5431d847..4731c61c9 100644 --- a/internal/providers/sourceproviders/sourcemanager.go +++ b/internal/providers/sourceproviders/sourcemanager.go @@ -15,6 +15,7 @@ import ( "github.com/microsoft/azure-linux-dev-tools/internal/global/opctx" "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" "github.com/microsoft/azure-linux-dev-tools/internal/providers/sourceproviders/fedorasource" + "github.com/microsoft/azure-linux-dev-tools/internal/rpm/mock" "github.com/microsoft/azure-linux-dev-tools/internal/utils/downloader" "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils" "github.com/microsoft/azure-linux-dev-tools/internal/utils/git" @@ -26,14 +27,26 @@ import ( // Provider is an abstract interface implemented by a source provider. type Provider interface{} +// ErrNotFound is returned by a [FileSourceProvider] when it does not handle the +// given file reference. The source manager tries the next registered provider on +// this error, eventually falling back to lookaside cache and configured origins. +var ErrNotFound = errors.New("file not handled by this provider") + // FileSourceProvider is an abstract interface implemented by a source provider that can retrieve individual // source files. type FileSourceProvider interface { Provider - // GetFiles retrieves the specified source files and places them in the provided directory. If a file - // is not known to (or handled by) the providers, the error will be (or will wrap) ErrNotFound. - GetFiles(ctx context.Context, fileRefs []projectconfig.SourceFileReference, destDirPath string) error + // GetFile retrieves a single source file and places it in destDirPath. + // Implementations must return [ErrNotFound] (or an error wrapping it) when the + // provider does not handle the given file reference, so the manager can try the + // next registered provider before falling back to lookaside and configured origins. + GetFile( + ctx context.Context, + component components.Component, + fileRef projectconfig.SourceFileReference, + destDirPath string, + ) error } // SourceIdentityProvider resolves a reproducible identity string for a component's source. @@ -232,6 +245,13 @@ func NewSourceManager(env *azldev.Env, distro ResolvedDistro) (SourceManager, er // Create component providers manager.createComponentProviders(distro) + // Automatically register the custom file source provider when the distro has + // a mock config path. This makes 'custom' origin source files available to all + // commands (build, prep-sources, diff-sources, etc.) without any per-command + // wiring. The provider only spins up a mock chroot when GetFile is actually + // called for a custom-origin file, so registering it upfront is cheap. + manager.createFileProviders(env) + // Ensure at least one provider was created successfully if len(manager.upstreamComponentProviders) == 0 && len(manager.fileProviders) == 0 { @@ -260,6 +280,48 @@ func (m *sourceManager) createComponentProviders(distro ResolvedDistro) { slog.Debug("Registered Fedora component provider") } +// createFileProviders registers [FileSourceProvider] implementations based on the +// project's default distro configuration. This follows the same pattern as the +// render and build commands, which both use [azldev.Env.Distro] (the project-level +// default) rather than the per-component resolved distro for mock operations. +// Currently this registers a [customFileSourceProvider] when the project distro +// has a 'mock-config' path configured, enabling 'custom' origin source files for +// all commands without per-command wiring. +// +// Failures are logged and the provider is skipped, matching the tolerant +// registration pattern used by [createComponentProviders]. +func (m *sourceManager) createFileProviders(env *azldev.Env) { + _, distroVerDef, err := env.Distro() + if err != nil { + slog.Debug("Cannot resolve project distro; 'custom' origin source generation will be unavailable", + "error", err) + + return + } + + mockConfigPath := distroVerDef.MockConfigPath + if mockConfigPath == "" { + slog.Debug("No 'mock-config' set on the project distro version; 'custom' origin source generation is unavailable") + + return + } + + if _, statErr := env.FS().Stat(mockConfigPath); statErr != nil { + slog.Warn("Mock config not accessible; 'custom' origin source generation will be unavailable", + "path", mockConfigPath, + "error", statErr) + + return + } + + m.fileProviders = append(m.fileProviders, &customFileSourceProvider{ + fs: m.fs, + runner: mock.NewRunner(env, mockConfigPath), + }) + + slog.Debug("Registered custom file source provider", "mockConfig", mockConfigPath) +} + func (m *sourceManager) FetchFiles( ctx context.Context, component components.Component, @@ -280,6 +342,20 @@ func (m *sourceManager) FetchFiles( for i := range sourceFiles { fileRef := &sourceFiles[i] + // Fail fast when a 'custom' origin source file has no registered provider. + // This means the distro has no 'mock-config' set (or the file was inaccessible), + // so no generation can happen. Surfacing the error here — before any network + // or disk work — gives a clearer diagnosis than the message produced deep in + // the fetch fallback path. + if fileRef.Origin.Type == projectconfig.OriginTypeCustom && + len(m.fileProviders) == 0 && + (fileRef.Hash == "" || fileRef.HashType == "") { + return fmt.Errorf( + "source file %#q has 'custom' origin but no file provider is available; "+ + "set 'mock-config' on the project distro version definition to enable custom source generation", + fileRef.Filename) + } + err := m.fetchSourceFile(ctx, httpDownloader, component, fileRef, destDirPath) if err != nil { return fmt.Errorf("failed to fetch source file %#q:\n%w", fileRef.Filename, err) @@ -289,8 +365,14 @@ func (m *sourceManager) FetchFiles( return nil } -// fetchSourceFile downloads a source file, trying the lookaside cache first and falling -// back to the configured origin. When disable-origins is set, fallback is disabled. +// fetchSourceFile acquires a single source file using the following priority order: +// 1. Lookaside cache — if hash info is available, attempt a cached download first. +// This applies to all origin types, including 'custom', so a previously generated +// and cached archive avoids a full mock regeneration. +// 2. File providers — handles origin types that require local generation (e.g. 'custom'). +// 3. Configured download origin — final fallback for 'download' origin types. +// +// When disable-origins is set, step 3 is skipped and only lookaside and file providers apply. func (m *sourceManager) fetchSourceFile( ctx context.Context, httpDownloader downloader.Downloader, @@ -318,7 +400,10 @@ func (m *sourceManager) fetchSourceFile( return nil } - // Phase 1: Try lookaside cache if hash info is available + // Try the lookaside cache first if hash info is available. This applies to + // all origin types, including 'custom' — if the generated archive is already + // cached in the lookaside (as it will be after the first run), we skip the + // expensive mock generation entirely. if fileRef.Hash != "" && fileRef.HashType != "" { lookasideErr := m.tryLookasideDownload(ctx, httpDownloader, component, fileRef, destPath) if lookasideErr == nil { @@ -330,7 +415,30 @@ func (m *sourceManager) fetchSourceFile( "error", lookasideErr) } - // Phase 2: Fall back to configured origin (not allowed when disable-origins is set) + // Try each registered file provider. Providers return [ErrNotFound] to signal + // they don't handle this reference; any other error is fatal. + for _, provider := range m.fileProviders { + err := provider.GetFile(ctx, component, *fileRef, destDirPath) + if err == nil { + // File providers are responsible for producing the file but not for + // hash validation. Validate here so all acquisition paths are covered. + if fileRef.Hash != "" && fileRef.HashType != "" { + hashErr := fileutils.ValidateFileHash( + m.dryRunnable, m.fs, fileRef.HashType, destPath, fileRef.Hash) + if hashErr != nil { + return fmt.Errorf("hash validation failed for %#q:\n%w", fileRef.Filename, hashErr) + } + } + + return nil + } + + if !errors.Is(err, ErrNotFound) { + return fmt.Errorf("file provider failed for %#q:\n%w", fileRef.Filename, err) + } + } + + // Fall back to the configured origin (not allowed when disable-origins is set). if m.disableOrigins { return fmt.Errorf("source file %#q not found in lookaside cache and disable-origins is enabled in the distro config", fileRef.Filename) @@ -341,7 +449,7 @@ func (m *sourceManager) fetchSourceFile( fileRef.Filename) } - return m.downloadFromOrigin(ctx, httpDownloader, fileRef, destPath) + return m.fetchFromDownloadOrigin(ctx, httpDownloader, fileRef, destPath) } // tryLookasideDownload attempts to download a source file from the lookaside cache. @@ -377,8 +485,12 @@ func (m *sourceManager) tryLookasideDownload( return nil } -// downloadFromOrigin downloads a source file using its configured origin. -func (m *sourceManager) downloadFromOrigin( +// fetchFromDownloadOrigin acquires a source file using its configured origin. +// For [projectconfig.OriginTypeCustom], callers should have already dispatched +// to a registered [FileSourceProvider] — reaching this function for a custom +// origin means no provider was configured. +// For [projectconfig.OriginTypeURI], the file is downloaded from the configured URI. +func (m *sourceManager) fetchFromDownloadOrigin( ctx context.Context, httpDownloader downloader.Downloader, fileRef *projectconfig.SourceFileReference, @@ -403,6 +515,14 @@ func (m *sourceManager) downloadFromOrigin( return nil + case projectconfig.OriginTypeCustom: + // The file provider dispatch in fetchSourceFile should have handled this. + // Reaching here means no [FileSourceProvider] was registered for 'custom' origin. + return fmt.Errorf( + "source file %#q has 'custom' origin but no provider handled it; "+ + "ensure the distro has a 'mock-config' configured", + fileRef.Filename) + default: return fmt.Errorf("unsupported origin type %#q for source file %#q", fileRef.Origin.Type, fileRef.Filename) diff --git a/internal/providers/sourceproviders/sourcemanager_test.go b/internal/providers/sourceproviders/sourcemanager_test.go index 6f100792d..6297b6832 100644 --- a/internal/providers/sourceproviders/sourcemanager_test.go +++ b/internal/providers/sourceproviders/sourcemanager_test.go @@ -297,10 +297,11 @@ func TestSourceManager_FetchFiles_ExistingFile(t *testing.T) { func TestSourceManager_FetchFiles_Errors(t *testing.T) { tests := []struct { - name string - disableOrigins bool - sourceFiles []projectconfig.SourceFileReference - expectedError string + name string + disableOrigins bool + clearMockConfig bool + sourceFiles []projectconfig.SourceFileReference + expectedError string }{ { name: "disable-origins rejects lookaside failure", @@ -342,12 +343,44 @@ func TestSourceManager_FetchFiles_Errors(t *testing.T) { }}, expectedError: "no URI configured", }, + { + // No hash → lookaside skipped; no mock-config → no file provider registered. + // The fast-fail in FetchFiles should surface a clear "mock-config" message. + name: "custom origin no provider no hash", + clearMockConfig: true, + sourceFiles: []projectconfig.SourceFileReference{{ + Filename: testSourceTarball, + Origin: projectconfig.Origin{Type: projectconfig.OriginTypeCustom}, + }}, + expectedError: "mock-config", + }, + { + // Hash present → lookaside attempted then fails; no file provider registered. + // Falls through to fetchFromDownloadOrigin, which surfaces the "mock-config" message. + name: "custom origin no provider has hash", + clearMockConfig: true, + sourceFiles: []projectconfig.SourceFileReference{{ + Filename: testSourceTarball, + Hash: "abc123", + HashType: "SHA256", + Origin: projectconfig.Origin{Type: projectconfig.OriginTypeCustom}, + }}, + expectedError: "mock-config", + }, } for _, testCase := range tests { t.Run(testCase.name, func(t *testing.T) { env := testutils.NewTestEnv(t) + if testCase.clearMockConfig { + distro := env.Config.Distros["test-distro"] + ver := distro.Versions["1.0"] + ver.MockConfigPath = "" + distro.Versions["1.0"] = ver + env.Config.Distros["test-distro"] = distro + } + ctrl := gomock.NewController(t) component := components_testutils.NewMockComponent(ctrl) diff --git a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap index a238e7eb1..2cc69cad9 100755 --- a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap @@ -763,7 +763,8 @@ "type": { "type": "string", "enum": [ - "download" + "download", + "custom" ], "title": "Origin type", "description": "Type of origin for this source file" @@ -775,6 +776,19 @@ "examples": [ "https://example.com/source.tar.gz" ] + }, + "script": { + "type": "string", + "title": "Script", + "description": "Shell script filename (relative to the component spec directory) to run in mock to generate this source file. Required when origin type is 'custom'." + }, + "mock-packages": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Mock packages", + "description": "RPM packages to install in the mock chroot before running the generation script. Only valid when origin type is 'custom'." } }, "additionalProperties": false, diff --git a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap index a238e7eb1..2cc69cad9 100755 --- a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap @@ -763,7 +763,8 @@ "type": { "type": "string", "enum": [ - "download" + "download", + "custom" ], "title": "Origin type", "description": "Type of origin for this source file" @@ -775,6 +776,19 @@ "examples": [ "https://example.com/source.tar.gz" ] + }, + "script": { + "type": "string", + "title": "Script", + "description": "Shell script filename (relative to the component spec directory) to run in mock to generate this source file. Required when origin type is 'custom'." + }, + "mock-packages": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Mock packages", + "description": "RPM packages to install in the mock chroot before running the generation script. Only valid when origin type is 'custom'." } }, "additionalProperties": false, diff --git a/schemas/azldev.schema.json b/schemas/azldev.schema.json index a238e7eb1..2cc69cad9 100644 --- a/schemas/azldev.schema.json +++ b/schemas/azldev.schema.json @@ -763,7 +763,8 @@ "type": { "type": "string", "enum": [ - "download" + "download", + "custom" ], "title": "Origin type", "description": "Type of origin for this source file" @@ -775,6 +776,19 @@ "examples": [ "https://example.com/source.tar.gz" ] + }, + "script": { + "type": "string", + "title": "Script", + "description": "Shell script filename (relative to the component spec directory) to run in mock to generate this source file. Required when origin type is 'custom'." + }, + "mock-packages": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Mock packages", + "description": "RPM packages to install in the mock chroot before running the generation script. Only valid when origin type is 'custom'." } }, "additionalProperties": false, From c6e74413b90dd5ae7f8e540b74d5794592dbeede Mon Sep 17 00:00:00 2001 From: Daniel McIlvaney Date: Wed, 8 Jul 2026 16:31:00 -0700 Subject: [PATCH 12/20] ci: add python linters to cover future python code (#225) --- .devcontainer/Dockerfile.AZL-3.0 | 6 +- .devcontainer/README.md | 4 + .devcontainer/devcontainer.json | 7 +- .github/workflows/python.yml | 35 +++++ .vscode/extensions.json | 7 + .vscode/settings.json | 22 ++- docs/developer/how-to/get-started.md | 10 ++ docs/developer/reference/coding-standards.md | 11 ++ .../app/azldev/core/sources/render_process.py | 39 +++-- magefiles/magecheckfix/checkfix.go | 137 ++++++++++++++++-- pyrightconfig.json | 18 +++ ruff.toml | 26 ++++ 12 files changed, 293 insertions(+), 29 deletions(-) create mode 100644 .github/workflows/python.yml create mode 100644 .vscode/extensions.json mode change 100644 => 100755 internal/app/azldev/core/sources/render_process.py create mode 100644 pyrightconfig.json create mode 100644 ruff.toml diff --git a/.devcontainer/Dockerfile.AZL-3.0 b/.devcontainer/Dockerfile.AZL-3.0 index 19013af72..3ae8daf4f 100644 --- a/.devcontainer/Dockerfile.AZL-3.0 +++ b/.devcontainer/Dockerfile.AZL-3.0 @@ -2,9 +2,13 @@ FROM mcr.microsoft.com/azurelinux/base/core:3.0 # Install required packages RUN tdnf -y update && \ - tdnf -y install ca-certificates dnf dnf-utils which gh git golang gawk tar shadow-utils sudo tree bash-completion moby-engine moby-cli build-essential && \ + tdnf -y install ca-certificates dnf dnf-utils which gh git golang gawk tar shadow-utils sudo tree bash-completion moby-engine moby-cli build-essential python3-pip nodejs && \ tdnf clean all +# Install ruff and pyright for Python linting/formatting/type-checking +# (used by 'mage check python' / 'mage fix python'). pyright requires node (installed above). +RUN pip3 install --no-cache-dir ruff pyright + # Define the 'll' alias for all users RUN echo 'alias ll="ls -lah"' >> /etc/profile.d/aliases.sh && \ chmod +x /etc/profile.d/aliases.sh && \ diff --git a/.devcontainer/README.md b/.devcontainer/README.md index bfe9caaa6..6b155d9e4 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -9,6 +9,7 @@ The dev container uses the `azl_dev_3.0` image defined in `Dockerfile.AZL-3.0`. - Go development tools, - Mage build system, - golangci-lint, +- ruff and pyright (plus Node.js, required by pyright) for Python linting/formatting/type-checking, - required development dependencies for VS Code. ## Getting Started @@ -47,6 +48,9 @@ The dev container automatically installs these VS Code extensions: - JSON Language Features - JSON editing support - markdownlint - Markdown linting - YAML Language Support - YAML editing support +- Ruff (charliermarsh.ruff) - Python linting/formatting +- Pylance (ms-python.vscode-pylance) - Python type checking +- EditorConfig (editorconfig.editorconfig) - EditorConfig support ## Customization diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 6b3a6814b..4ef10ff9c 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,18 +4,23 @@ "dockerfile": "Dockerfile.AZL-3.0", "context": "." }, - "runArgs": ["--privileged"], + "runArgs": [ + "--privileged" + ], // Features to add to the dev container. More info: https://containers.dev/features. "features": {}, // Configure tool-specific properties. "customizations": { "vscode": { "extensions": [ + "charliermarsh.ruff", "davidanson.vscode-markdownlint", + "editorconfig.editorconfig", "github.copilot-chat", "github.copilot", "github.vscode-pull-request-github", "golang.go", + "ms-python.vscode-pylance", "ms-vscode.vscode-json", "redhat.vscode-yaml" ], diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml new file mode 100644 index 000000000..208249218 --- /dev/null +++ b/.github/workflows/python.yml @@ -0,0 +1,35 @@ +name: "Python lint and type-check" +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + schedule: + # Run every night at 3:15am PST (11:15am UTC) + - cron: "15 11 * * *" + +# Cancel in-progress runs of this workflow if a new run is triggered. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +permissions: {} + +jobs: + python: + name: "Lint and type-check" + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Get go tools + uses: ./.github/getgotools + - name: Install ruff and pyright + # ubuntu-latest ships Python and Node.js; pyright requires Node. + run: pip install ruff pyright + - name: Run mage check python + run: mage -v check python diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..68f655ac9 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "charliermarsh.ruff", + "editorconfig.editorconfig", + "ms-python.vscode-pylance" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 7d6937f6e..cdb7526d2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,7 +2,26 @@ "go.buildTags": "scenario", "go.lintTool": "golangci-lint-v2", "go.testTimeout": "5m", - "go.testFlags": ["-v"], + "go.testFlags": [ + "-v" + ], + // Lint rules for python + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll.ruff": "explicit", + "source.organizeImports.ruff": "explicit", + "source.fixAll": "explicit", + "source.fixAll.pylance": "explicit" + } + }, + "python.analysis.diagnosticsSource": "Pylance", + "python.missingPackage.severity": "Error", + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true, + "ruff.configuration": "${workspaceFolder}/ruff.toml", + "ruff.lint.enable": true, "cSpell.words": [ "acarl", "acobaugh", @@ -132,4 +151,3 @@ "wrapcheck" ] } - diff --git a/docs/developer/how-to/get-started.md b/docs/developer/how-to/get-started.md index d09888fd0..721257dcd 100644 --- a/docs/developer/how-to/get-started.md +++ b/docs/developer/how-to/get-started.md @@ -12,6 +12,16 @@ go install github.com/magefile/mage@latest ``` +- **ruff** and **pyright** *(only if you touch Python code)* - Python linter/formatter + and type checker, used by `mage check python` / `mage fix python`. pyright requires + Node.js. The dev container installs all of these for you. + + ```bash + # On Azure Linux + tdnf install -y python3-pip nodejs + pip3 install ruff pyright + ``` + ## Initial Setup 1. Fork the repository (strongly recommended for all contributors) diff --git a/docs/developer/reference/coding-standards.md b/docs/developer/reference/coding-standards.md index f3b2fc6b1..cce8e3653 100644 --- a/docs/developer/reference/coding-standards.md +++ b/docs/developer/reference/coding-standards.md @@ -50,6 +50,17 @@ - See [azldev command guidelines](../reference/command-guidelines.md) for details on command structure and naming conventions. +## Python Code Style + +Some functionality is implemented in Python scripts (e.g. scripts embedded in the +Go binary). + +- **Linting & formatting**: All Python must pass `ruff` (config: [`ruff.toml`](../../../ruff.toml)). + Run `mage check python` to lint and `mage fix python` to auto-format and apply fixes. +- **Type checking**: All Python must pass `pyright` (config: [`pyrightconfig.json`](../../../pyrightconfig.json)). + This is also run by `mage check python`. +- Both checks are part of `mage check all`. + ## Logging Guidelines - Use structured logging with the `slog` package diff --git a/internal/app/azldev/core/sources/render_process.py b/internal/app/azldev/core/sources/render_process.py old mode 100644 new mode 100755 index c9978c4c0..36d28f41a --- a/internal/app/azldev/core/sources/render_process.py +++ b/internal/app/azldev/core/sources/render_process.py @@ -2,8 +2,9 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -"""Process RPM specs inside a mock chroot: run rpmautospec and spectool for each -component, writing per-component results to a JSON file in the staging directory. +r"""Run rpmautospec and spectool for each component inside a mock chroot. + +Writes per-component results to a JSON file in the staging directory. This script is embedded in the azldev Go binary and executed inside a mock chroot during ``azldev component render``. It avoids the need for complex inline shell @@ -20,35 +21,41 @@ Results are written to ``/results.json``:: [ - {"name": "curl", "specFiles": "Source0: curl-8.5.0.tar.xz\\nPatch0: fix.patch", "error": null}, + {"name": "curl", "specFiles": "Source0: curl-8.5.0.tar.xz\nPatch0: fix.patch", "error": null}, {"name": "broken", "specFiles": "", "error": "rpmautospec failed: ..."} ] Progress is reported to stderr as ``PROGRESS / ``. """ +from __future__ import annotations + import json -import os import subprocess import sys from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path + +# Number of positional arguments expected on the command line (script, staging_dir, max_workers). +EXPECTED_ARG_COUNT = 3 -def process_component(staging_dir: str, name: str, spec_filename: str) -> dict: +def process_component(staging_dir: str, name: str, spec_filename: str) -> dict[str, str | None]: """Run rpmautospec + spectool for a single component, returning a result dict. Trust boundary: name and spec_filename are validated by BatchProcess in mockprocessor.go (validateComponentInput rejects path separators, empty values, and non-basename spec filenames) before this script is invoked. """ - comp_dir = os.path.join(staging_dir, name) - spec_path = os.path.join(comp_dir, spec_filename) + comp_dir = Path(staging_dir) / name + spec_path = comp_dir / spec_filename # rpmautospec: expand %autorelease / %autochangelog in-place. rpa_result = subprocess.run( - ["rpmautospec", "process-distgit", spec_path, spec_path], + ["rpmautospec", "process-distgit", str(spec_path), str(spec_path)], capture_output=True, text=True, + check=False, ) if rpa_result.returncode != 0: @@ -66,10 +73,11 @@ def process_component(staging_dir: str, name: str, spec_filename: str) -> dict: f"_sourcedir {comp_dir}", "-l", "-a", - spec_path, + str(spec_path), ], capture_output=True, text=True, + check=False, ) if st_result.returncode != 0: @@ -83,15 +91,16 @@ def process_component(staging_dir: str, name: str, spec_filename: str) -> dict: def main() -> int: - if len(sys.argv) != 3: + """Read inputs.json, process every component in parallel, and write results.json.""" + if len(sys.argv) != EXPECTED_ARG_COUNT: print(f"usage: {sys.argv[0]} ", file=sys.stderr) return 1 staging_dir = sys.argv[1] max_workers = int(sys.argv[2]) - inputs_path = os.path.join(staging_dir, "inputs.json") + inputs_path = Path(staging_dir) / "inputs.json" - with open(inputs_path) as f: + with inputs_path.open() as f: inputs = json.load(f) # Mark all paths as git-safe (ownership mismatch between host and chroot). @@ -123,7 +132,7 @@ def main() -> int: name = futures[future] try: completed_results[name] = future.result() - except Exception as exc: + except Exception as exc: # noqa: BLE001 - record any worker failure as a per-component error completed_results[name] = { "name": name, "specFiles": "", @@ -138,9 +147,9 @@ def main() -> int: # Write results to a file in the staging directory rather than stdout. # This avoids bufio.Scanner token size limits in the Go caller, which # would truncate large JSON payloads (e.g., 7k components ≈ 560KB). - results_path = os.path.join(staging_dir, "results.json") + results_path = Path(staging_dir) / "results.json" - with open(results_path, "w") as results_file: + with results_path.open("w") as results_file: json.dump(results, results_file) return 0 diff --git a/magefiles/magecheckfix/checkfix.go b/magefiles/magecheckfix/checkfix.go index 63f91b3f7..3a302c4a2 100644 --- a/magefiles/magecheckfix/checkfix.go +++ b/magefiles/magecheckfix/checkfix.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "os" + "os/exec" "path" "strings" @@ -23,16 +24,20 @@ const ( TargetLint = "lint" TargetStatic = "static" TargetLicenses = "licenses" + TargetPython = "python" ) var ( - ErrCheck = errors.New("check failed") - ErrFix = errors.New("fix failed") - ErrLint = errors.New("lint failed") - ErrToolPath = errors.New("path not set for tool") + ErrCheck = errors.New("check failed") + ErrFix = errors.New("fix failed") + ErrLint = errors.New("lint failed") + ErrToolPath = errors.New("path not set for tool") + ErrRuffNotFound = errors.New("ruff not found on PATH") + ErrPyrightNotFnd = errors.New("pyright not found on PATH") + ErrTypeCheck = errors.New("type check failed") ) -// Check one of: [all, mod, lint, static, licenses]. +// Check one of: [all, mod, lint, static, licenses, python]. // BASH-COMPLETION: This is scanned by the bash completion script, keep it in sync with the script. func Check(target string) error { mg.SerialDeps(magesrc.Generate) @@ -41,7 +46,7 @@ func Check(target string) error { switch target { case TargetAll: - mg.SerialDeps(modCheck, lintCheck, static, licenseCheck) + mg.SerialDeps(modCheck, lintCheck, static, licenseCheck, pythonCheck) case TargetMod: mg.SerialDeps(modCheck) case TargetLint: @@ -50,9 +55,11 @@ func Check(target string) error { mg.SerialDeps(static) case TargetLicenses: mg.SerialDeps(licenseCheck) + case TargetPython: + mg.SerialDeps(pythonCheck) default: return fmt.Errorf("%w: unknown check target '%s'. Available targets: %v", - ErrCheck, target, []string{TargetAll, TargetMod, TargetLint, TargetStatic, TargetLicenses}) + ErrCheck, target, []string{TargetAll, TargetMod, TargetLint, TargetStatic, TargetLicenses, TargetPython}) } mageutil.MagePrintln(mageutil.MsgSuccess, "Done checking.") @@ -60,7 +67,7 @@ func Check(target string) error { return nil } -// Fix one of: [all, mod, lint]. +// Fix one of: [all, mod, lint, python]. // BASH-COMPLETION: This is scanned by the bash completion script, keep it in sync with the script. func Fix(target string) error { mg.SerialDeps(magesrc.Generate) @@ -69,14 +76,16 @@ func Fix(target string) error { switch target { case TargetAll: - mg.SerialDeps(modFix, lintFix) + mg.SerialDeps(modFix, lintFix, pythonFix) case TargetMod: mg.SerialDeps(modFix) case TargetLint: mg.SerialDeps(lintFix) + case TargetPython: + mg.SerialDeps(pythonFix) default: return fmt.Errorf("%w: unknown fix target '%s'. Available targets: %v", - ErrFix, target, []string{TargetAll, TargetMod, TargetLint}) + ErrFix, target, []string{TargetAll, TargetMod, TargetLint, TargetPython}) } mageutil.MagePrintln(mageutil.MsgSuccess, "Done fixing.") @@ -238,6 +247,114 @@ func editorconfigCheck() error { return nil } +// findRuff locates the ruff executable on the PATH. ruff is a Python tool and is not managed via the Go +// tools modules, so developers must install it themselves (the VS Code ruff extension bundles a copy that +// is not exposed on the PATH). +func findRuff() (string, error) { + ruffPath, err := exec.LookPath("ruff") + if err != nil { + return "", fmt.Errorf("%w: install it with 'pip install ruff' or see "+ + "https://docs.astral.sh/ruff/installation/: %w", ErrRuffNotFound, err) + } + + return ruffPath, nil +} + +// findPyright locates the pyright executable on the PATH. Like ruff, pyright is a Python tool that is not +// managed via the Go tools modules, so developers must install it themselves. +func findPyright() (string, error) { + pyrightPath, err := exec.LookPath("pyright") + if err != nil { + return "", fmt.Errorf("%w: install it with 'pip install pyright' or see "+ + "https://microsoft.github.io/pyright/#/installation: %w", ErrPyrightNotFnd, err) + } + + return pyrightPath, nil +} + +func pythonCheck() error { + mg.SerialDeps(ruffCheck, pyrightCheck) + + return nil +} + +func ruffCheck() error { + mageutil.MagePrintln(mageutil.MsgStart, "Linting Python with ruff...") + + ruffPath, err := findRuff() + if err != nil { + return mageutil.PrintAndReturnError("Failed to find ruff.", ErrCheck, err) + } + + // Lint check. -q suppresses the "All checks passed!" banner on success; violations are still + // printed on failure. + if err := sh.RunV(ruffPath, "check", "-q", "."); err != nil { + return mageutil.PrintAndReturnError( + "Python lint issues found. Please run 'mage fix python' to auto-fix.", ErrCheck, ErrLint) + } + + // Format check. -q suppresses the "N files already formatted" banner on success. + if err := sh.RunV(ruffPath, "format", "--check", "-q", "."); err != nil { + return mageutil.PrintAndReturnError( + "Python files are not formatted. Please run 'mage fix python' to format them.", ErrCheck, ErrLint) + } + + mageutil.MagePrintln(mageutil.MsgSuccess, "Done linting Python.") + + return nil +} + +func pyrightCheck() error { + mageutil.MagePrintln(mageutil.MsgStart, "Type-checking Python with pyright...") + + pyrightPath, err := findPyright() + if err != nil { + return mageutil.PrintAndReturnError("Failed to find pyright.", ErrCheck, err) + } + + // pyright always prints a summary line even on success, so capture its output and only surface + // it when there's a problem (mirrors golangciLintCheck above to keep checks quiet on success). + output, err := sh.Output(pyrightPath) + if err != nil { + if output != "" { + for _, line := range strings.Split(output, "\n") { + mageutil.MagePrintln(mageutil.MsgInfo, line) + } + } + + return mageutil.PrintAndReturnError( + "Python type errors found; please review the errors displayed above.", ErrCheck, ErrTypeCheck) + } + + mageutil.MagePrintln(mageutil.MsgSuccess, "Done type-checking Python.") + + return nil +} + +func pythonFix() error { + mageutil.MagePrintln(mageutil.MsgStart, "Fixing Python formatting...") + + ruffPath, err := findRuff() + if err != nil { + return mageutil.PrintAndReturnError("Failed to find ruff.", ErrFix, err) + } + + // Auto-fix lint issues. + if err := sh.RunV(ruffPath, "check", "--fix", "."); err != nil { + return mageutil.PrintAndReturnError( + "Failed to auto-fix all Python lint issues; please review any errors displayed above.", ErrFix, err) + } + + // Format. + if err := sh.RunV(ruffPath, "format", "."); err != nil { + return mageutil.PrintAndReturnError("Failed to format Python files.", ErrFix, err) + } + + mageutil.MagePrintln(mageutil.MsgSuccess, "Done fixing Python.") + + return nil +} + // licenseCheck checks the licenses of the dependencies and writes the results to a CSV file. func licenseCheck() error { mg.SerialDeps(mageutil.CreateLicenseDir) diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 000000000..6fe8889cd --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,18 @@ +{ + "exclude": [ + "**/node_modules", + "**/__pycache__", + "**/.venv", + "**/venv", + "out", + "**/.*" + ], + "typeCheckingMode": "standard", + "pythonVersion": "3.12", + "pythonPlatform": "Linux", + "reportUntypedFunctionDecorator": "error", + "reportMissingTypeStubs": "warning", + "reportMissingParameterType": "error", + "reportMissingTypeArgument": "error", + "reportUnknownParameterType": "error" +} diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 000000000..a5af63c07 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,26 @@ +extend-exclude = ["out"] + +line-length = 120 +target-version = "py312" + +[lint] +select = ["ALL"] # Everything +ignore = [ + "COM812", # Conflicts with formatter. + "S603", # Check for validation of input to subprocess calls, almost always a false positive + "S607", # Requiring abspath for all binaries used is very restrictive + "T201", # These scripts are fine to use `print` for output +] + +# We want D401 still, even though google style omits it. +extend-select = ["D401"] + +fixable = ["ALL"] + +[lint.isort] +# Just force this, its better than dealing with a mishmash of modules that have annotations +# and those that don't +required-imports = ["from __future__ import annotations"] + +[lint.pydocstyle] +convention = "google" From d47299257d42797625be4054ab9493d4cd5a40a7 Mon Sep 17 00:00:00 2001 From: Nan Liu Date: Tue, 7 Jul 2026 21:44:56 +0000 Subject: [PATCH 13/20] refactor(overlays): Rework OverlayMetadata to use typed URLRef list commits and enum upstream-status - Rename OverlayCategoryBackportDistGit -> OverlayCategoryUpstreamBackport (value 'backport-dist-git' -> 'upstream-backport'). Widens the category to cover Fedora dist-git and native OSS backports alike. - Replace BugRef with reusable URLRef (URL-carrying struct with room for tracker/source-specific fields). Metadata.Commits is now []URLRef instead of []string; Metadata.Bugs is []URLRef. - Replace tri-state Upstreamable *bool with typed OverlayUpstreamStatus enum (upstreamed, upstreamable, needs-upstream-hook, inapplicable); empty string = not-yet-assessed. - Regenerate schema and scenario snapshots; update overlays.md user reference to match the new field names, enum values, and TOML examples. --- docs/user/reference/config/overlays.md | 62 ++++++-- .../azldev/core/components/resolver_test.go | 2 + internal/projectconfig/overlay_file_test.go | 41 +++-- internal/projectconfig/overlay_metadata.go | 118 ++++++++++---- .../projectconfig/overlay_metadata_test.go | 150 ++++++++++++++---- ...ainer_config_generate-schema_stdout_1.snap | 58 ++++--- ...shots_config_generate-schema_stdout_1.snap | 58 ++++--- schemas/azldev.schema.json | 58 ++++--- 8 files changed, 389 insertions(+), 158 deletions(-) diff --git a/docs/user/reference/config/overlays.md b/docs/user/reference/config/overlays.md index 8db2ea772..9ff05e67b 100644 --- a/docs/user/reference/config/overlays.md +++ b/docs/user/reference/config/overlays.md @@ -83,15 +83,15 @@ Overlays can carry an optional `metadata` table that documents *why* the overlay | Field | TOML Key | Description | |-------|----------|-------------| | Category | `category` | **Required.** Classification of the overlay's intent. See the table below. | -| Commits | `commits` | List of upstream commit URLs (Fedora dist-git or upstream project) that this overlay backports or implements. Each entry must be an absolute http(s) URL. | -| Bugs | `bugs` | List of bug-tracker references. Each entry is a table with a required `url`. See [Bug references](#bug-references). | -| Upstreamable | `upstreamable` | Boolean indicating whether this change can be upstreamed: `true` or `false`. Omit the field when upstreamability has not yet been assessed. | +| Commits | `commits` | List of upstream commit references (Fedora dist-git or upstream project) that this overlay backports or implements. Each entry is a table with a required `url` (an absolute http(s) URL). See [URL references](#url-references). | +| Bugs | `bugs` | List of bug-tracker references. Each entry is a table with a required `url`. See [URL references](#url-references). | +| Upstream status | `upstream-status` | **Required.** Classifies the overlay's relationship to its upstream project. One of `upstreamed`, `upstreamable`, `needs-upstream-hook`, `inapplicable`, or `unknown`. Use `unknown` if the assessment has not been made yet — reviewers should push for a definite value before approving. See [Upstream status](#upstream-status). | ### Categories | Category | When to use | |----------|-------------| -| `backport-dist-git` | Fix backported from (or being upstreamed to) a dist-git or upstream project. Self-resolves when AZL bumps past it. Requires at least one entry in `commits`. | +| `upstream-backport` | Fix backported from an upstream source (Fedora dist-git or the component's OSS project) that AZL will inherit once it bumps past the fix. Self-resolves on version bump. Requires at least one entry in `commits` pointing to the upstream change. `upstream-status` must be `upstreamed` or `upstreamable` — any other value contradicts the category. See [Upstream status](#upstream-status). | | `azl-pruning` | Removing content from a component for AZL: dependencies that are not shipped, unneeded features, subpackages, or files. | | `azl-compatibility` | Making a component work in the AZL build/runtime environment: toolchain and mock adjustments, and similar compatibility fixes that are not themselves backports. | | `azl-dep-missing-workaround` | Working around a runtime or build dependency that has not yet been imported into AZL (or is unavailable on a given target). Drop the overlay once the dependency lands. | @@ -102,13 +102,13 @@ Overlays can carry an optional `metadata` table that documents *why* the overlay | `azl-release-management` | Release-tag and changelog mechanics. | | `azl-platform-adaptation` | Architecture-specific adjustments. | -### Bug references +### URL references -The `bugs` field is a list of references to issue-tracker entries. Each entry is a table with a single required field: +The `commits` and `bugs` fields are lists of references to external resources. Each entry is a table with a single required field: | Field | TOML Key | Description | |-------|----------|-------------| -| URL | `url` | **Required.** HTTP(S) link to the bug entry. | +| URL | `url` | **Required.** HTTP(S) link to the referenced resource. | Example: @@ -129,6 +129,42 @@ bugs = [ ] ``` +### Upstream status + +`upstream-status` classifies the overlay's relationship to its upstream project. It answers "why are we carrying this?" and "what would it take to drop it?" Reviewers use it to decide whether to push back on a change or ask for an upstream link. + +| Value | Meaning | +|-------|---------| +| `upstreamed` | The change is already in Fedora / the upstream project. The overlay is carried only until AZL bumps past the fix. | +| `upstreamable` | The change could be sent upstream as-is. Reviewers should ask the author to link the upstream PR. | +| `needs-upstream-hook` | An AZL specialization that today requires invasive spec edits; upstream could add a `bcond`, `%if`, or config knob that would let us drop the overlay. Reviewers should ask whether the hook can be upstreamed instead of the change itself. | +| `inapplicable` | A permanent AZL-only deviation. Reviewers should push back on why we have to fork upstream forever. | +| `unknown` | The author has not yet assessed the overlay's upstream story. Reviewers should push for a definite status before approving. | + +`upstream-status` is required whenever `[metadata]` is present. On an `upstream-backport` overlay only `upstreamed` and `upstreamable` are allowed — any other value (`needs-upstream-hook`, `inapplicable`, `unknown`) is a validation error. + +#### `upstreamable` vs. `needs-upstream-hook` + +Both statuses mean "there is an upstream action available," but they differ in *what* would go upstream: + +- **`upstreamable`** — the patch we're carrying is itself upstream-shaped. Sending the same (or a close-to-identical) diff to Fedora / the upstream project would plausibly be accepted. The overlay contributor should open the upstream PR and link it, so we can drop this once it lands. +- **`needs-upstream-hook`** — the change is AZL-specific (e.g. hard-coding `Vendor: Microsoft`, disabling a feature only we don't want, swapping a default path) and would *not* be accepted upstream as-is. But upstream could add a `bcond`, `%if`, or config knob so that it can be configured downstream without patching the spec. + +In short: `upstreamable` upstreams the change; `needs-upstream-hook` upstreams a mechanism that makes the change unnecessary. + +Example: + +```toml +[[components.mypackage.overlays]] +type = "spec-set-tag" +tag = "Vendor" +value = "Microsoft" + + [components.mypackage.overlays.metadata] + category = "azl-branding-policy" + upstream-status = "inapplicable" +``` + ### Inline metadata example TOML inline tables (`metadata = { ... }`) must fit on a single line. When the metadata has more than one or two fields, use a sub-table (`[components..overlays.metadata]`) so each field gets its own line: @@ -141,8 +177,9 @@ regex = "autoreconf -i" replacement = "autoreconf -fi" [components.xclock.overlays.metadata] - category = "backport-dist-git" - commits = ["https://src.fedoraproject.org/rpms/xclock/c/1e407488"] + category = "upstream-backport" + upstream-status = "upstreamed" + commits = [{ url = "https://src.fedoraproject.org/rpms/xclock/c/1e407488" }] ``` For short metadata, the single-line inline form is also valid: @@ -152,7 +189,7 @@ For short metadata, the single-line inline form is also valid: type = "spec-set-tag" tag = "Vendor" value = "Microsoft" -metadata = { category = "azl-branding-policy" } +metadata = { category = "azl-branding-policy", upstream-status = "inapplicable" } ``` ## Per-file overlay format @@ -196,8 +233,9 @@ Per-overlay `metadata` is **not allowed** inside an overlay file — the file-le ```toml # overlays/0001-cve-2024-1234.overlay.toml [metadata] -category = "backport-dist-git" -commits = ["https://src.fedoraproject.org/rpms/mypackage/c/abc123def456"] +category = "upstream-backport" +upstream-status = "upstreamed" +commits = [{ url = "https://src.fedoraproject.org/rpms/mypackage/c/abc123def456" }] bugs = [{ url = "https://bugzilla.redhat.com/show_bug.cgi?id=12345" }] [[overlays]] diff --git a/internal/app/azldev/core/components/resolver_test.go b/internal/app/azldev/core/components/resolver_test.go index 39a49a4f9..bf3b6280f 100644 --- a/internal/app/azldev/core/components/resolver_test.go +++ b/internal/app/azldev/core/components/resolver_test.go @@ -714,6 +714,7 @@ func TestFindComponents_SpecDiscoveredComponentInheritsOverlayFilesFromDefaults( []byte(` [metadata] category = "azl-branding-policy" +upstream-status = "inapplicable" [[overlays]] type = "spec-set-tag" @@ -757,6 +758,7 @@ func TestFindComponents_SpecPathFilterInheritsOverlayFilesFromGroupDefaults(t *t []byte(` [metadata] category = "azl-branding-policy" +upstream-status = "inapplicable" [[overlays]] type = "spec-set-tag" diff --git a/internal/projectconfig/overlay_file_test.go b/internal/projectconfig/overlay_file_test.go index 408ac4ce6..05207c3c0 100644 --- a/internal/projectconfig/overlay_file_test.go +++ b/internal/projectconfig/overlay_file_test.go @@ -17,8 +17,9 @@ import ( const validBackportOverlayFile = ` [metadata] -category = "backport-dist-git" -commits = ["https://src.fedoraproject.org/rpms/ant/c/4ca7a3b"] +category = "upstream-backport" +upstream-status = "upstreamed" +commits = [{ url = "https://src.fedoraproject.org/rpms/ant/c/4ca7a3b" }] [[overlays]] description = "Remove openjdk21 binding" @@ -52,6 +53,7 @@ func TestLoadOverlayFiles_StampsMetadataAndPreservesOrder(t *testing.T) { []byte(` [metadata] category = "azl-branding-policy" +upstream-status = "inapplicable" [[overlays]] type = "spec-set-tag" @@ -70,14 +72,14 @@ value = "Microsoft" // 0001-* contributes first. assert.Equal(t, ComponentOverlaySearchAndReplaceInSpec, loaded[0].Type) require.NotNil(t, loaded[0].Metadata) - assert.Equal(t, OverlayCategoryBackportDistGit, loaded[0].Metadata.Category) + assert.Equal(t, OverlayCategoryUpstreamBackport, loaded[0].Metadata.Category) assert.Equal(t, ComponentOverlayRemoveSubpackage, loaded[1].Type) require.NotNil(t, loaded[1].Metadata) - assert.Equal(t, OverlayCategoryBackportDistGit, loaded[1].Metadata.Category) + assert.Equal(t, OverlayCategoryUpstreamBackport, loaded[1].Metadata.Category) // Mutating one overlay's metadata must not affect another (each must own a copy). - loaded[0].Metadata.Commits = append(loaded[0].Metadata.Commits, "mutated") + loaded[0].Metadata.Commits = append(loaded[0].Metadata.Commits, URLRef{URL: "https://example.com/mutated"}) assert.Len(t, loaded[1].Metadata.Commits, 1, "each overlay must own its metadata copy") // 0002-* comes second. @@ -103,6 +105,7 @@ func TestLoadOverlayFiles_SortsMatchesByBasename(t *testing.T) { require.NoError(t, fileutils.WriteFile(ctx.FS(), relativeToFullPathFirstOverlay, []byte(` [metadata] category = "azl-branding-policy" +upstream-status = "inapplicable" [[overlays]] type = "spec-set-tag" @@ -113,6 +116,7 @@ value = "second" require.NoError(t, fileutils.WriteFile(ctx.FS(), filenameFirstOverlay, []byte(` [metadata] category = "azl-branding-policy" +upstream-status = "inapplicable" [[overlays]] type = "spec-set-tag" @@ -123,6 +127,7 @@ value = "first" require.NoError(t, fileutils.WriteFile(ctx.FS(), tiedBasenameFirstOverlay, []byte(` [metadata] category = "azl-branding-policy" +upstream-status = "inapplicable" [[overlays]] type = "spec-set-tag" @@ -133,6 +138,7 @@ value = "third" require.NoError(t, fileutils.WriteFile(ctx.FS(), tiedBasenameSecondOverlay, []byte(` [metadata] category = "azl-branding-policy" +upstream-status = "inapplicable" [[overlays]] type = "spec-set-tag" @@ -210,12 +216,13 @@ func TestLoadOverlayFiles_RejectsPerOverlayMetadata(t *testing.T) { []byte(` [metadata] category = "azl-compatibility" +upstream-status = "inapplicable" [[overlays]] type = "spec-set-tag" tag = "Vendor" value = "Microsoft" -metadata = { category = "azl-branding-policy" } +metadata = { category = "azl-branding-policy", upstream-status = "inapplicable" } `), fileperms.PrivateFile)) _, err := loadOverlayFiles(ctx.FS(), "/project", []string{overlayGlob(overlayDir)}, false) @@ -231,6 +238,7 @@ func TestLoadOverlayFiles_RejectsEmptyFile(t *testing.T) { []byte(` [metadata] category = "azl-compatibility" +upstream-status = "inapplicable" `), fileperms.PrivateFile)) _, err := loadOverlayFiles(ctx.FS(), "/project", []string{overlayGlob(overlayDir)}, false) @@ -246,7 +254,7 @@ func TestLoadOverlayFiles_RejectsInvalidMetadata(t *testing.T) { []byte(` [metadata] # Missing category. -commits = ["abc"] +commits = [{ url = "https://example.com/abc" }] [[overlays]] type = "spec-set-tag" @@ -268,6 +276,7 @@ func TestLoadOverlayFiles_RejectsInvalidOverlay(t *testing.T) { []byte(` [metadata] category = "azl-branding-policy" +upstream-status = "inapplicable" [[overlays]] type = "spec-set-tag" @@ -294,7 +303,7 @@ func TestLoadOverlayFile_PermissiveTolerates_InvalidMetadata(t *testing.T) { []byte(` [metadata] # Missing category. -commits = ["abc"] +commits = [{ url = "https://example.com/abc" }] [[overlays]] type = "spec-set-tag" @@ -318,8 +327,9 @@ func TestLoadOverlayFiles_ResolvesSourceRelativeToOverlayFile(t *testing.T) { filepath.Join(overlayDir, "0001-patch.overlay.toml"), []byte(` [metadata] -category = "backport-dist-git" -commits = ["https://src.fedoraproject.org/rpms/foo/c/abc"] +category = "upstream-backport" +upstream-status = "upstreamed" +commits = [{ url = "https://src.fedoraproject.org/rpms/foo/c/abc" }] [[overlays]] type = "patch-add" @@ -388,7 +398,8 @@ func TestExpandResolvedOverlayFiles_AppendsAfterInlineOverlays(t *testing.T) { Tag: "Vendor", Value: "Microsoft", Metadata: &OverlayMetadata{ - Category: OverlayCategoryAZLBrandingPolicy, + Category: OverlayCategoryAZLBrandingPolicy, + UpstreamStatus: OverlayUpstreamStatusInapplicable, }, }, }, @@ -408,11 +419,11 @@ func TestExpandResolvedOverlayFiles_AppendsAfterInlineOverlays(t *testing.T) { // metadata stamped onto each. assert.Equal(t, ComponentOverlaySearchAndReplaceInSpec, expanded.Overlays[1].Type) require.NotNil(t, expanded.Overlays[1].Metadata) - assert.Equal(t, OverlayCategoryBackportDistGit, expanded.Overlays[1].Metadata.Category) + assert.Equal(t, OverlayCategoryUpstreamBackport, expanded.Overlays[1].Metadata.Category) assert.Equal(t, ComponentOverlayRemoveSubpackage, expanded.Overlays[2].Type) require.NotNil(t, expanded.Overlays[2].Metadata) - assert.Equal(t, OverlayCategoryBackportDistGit, expanded.Overlays[2].Metadata.Category) + assert.Equal(t, OverlayCategoryUpstreamBackport, expanded.Overlays[2].Metadata.Category) } func TestExpandResolvedOverlayFiles_NoopWhenUnset(t *testing.T) { @@ -445,7 +456,7 @@ func TestExpandResolvedOverlayFiles_AcceptsAbsoluteGlob(t *testing.T) { require.Len(t, expanded.Overlays, 2, "absolute glob is not re-rooted under cfg.dir") require.NotNil(t, expanded.Overlays[0].Metadata) - assert.Equal(t, OverlayCategoryBackportDistGit, expanded.Overlays[0].Metadata.Category) + assert.Equal(t, OverlayCategoryUpstreamBackport, expanded.Overlays[0].Metadata.Category) } func TestExpandResolvedOverlayFiles_RequiresReferenceDirForRelativePattern(t *testing.T) { @@ -481,5 +492,5 @@ func TestExpandResolvedOverlayFiles_DefaultPatternUsesConcreteComponentConfigDir require.Len(t, expanded.Overlays, 2) assert.Empty(t, expanded.OverlayFiles) require.NotNil(t, expanded.Overlays[0].Metadata) - assert.Equal(t, OverlayCategoryBackportDistGit, expanded.Overlays[0].Metadata.Category) + assert.Equal(t, OverlayCategoryUpstreamBackport, expanded.Overlays[0].Metadata.Category) } diff --git a/internal/projectconfig/overlay_metadata.go b/internal/projectconfig/overlay_metadata.go index 1aa81685a..4bc5c3029 100644 --- a/internal/projectconfig/overlay_metadata.go +++ b/internal/projectconfig/overlay_metadata.go @@ -16,10 +16,10 @@ import ( type OverlayCategory string const ( - // OverlayCategoryBackportDistGit applies a fix backported from (or being upstreamed to) - // a dist-git or upstream project. The overlay can be dropped once AZL bumps its upstream - // pin past the fix. - OverlayCategoryBackportDistGit OverlayCategory = "backport-dist-git" + // OverlayCategoryUpstreamBackport applies a fix backported from an upstream source + // (Fedora dist-git or the component's OSS project) that AZL will inherit once its + // upstream pin bumps past the fix. + OverlayCategoryUpstreamBackport OverlayCategory = "upstream-backport" // OverlayCategoryAZLPruning removes content from a component for AZL: dependencies that // are not shipped, unneeded features, subpackages, or files. Replaces the more granular // historical 'azl-dependency-pruning' and 'azl-feature-disablement' categories. @@ -56,7 +56,7 @@ const ( // //nolint:gochecknoglobals // effectively a constant; Go doesn't allow const slices. var allOverlayCategories = []OverlayCategory{ - OverlayCategoryBackportDistGit, + OverlayCategoryUpstreamBackport, OverlayCategoryAZLPruning, OverlayCategoryAZLCompatibility, OverlayCategoryAZLDepMissingWorkaround, @@ -73,12 +73,55 @@ func (c OverlayCategory) IsValid() bool { return slices.Contains(allOverlayCategories, c) } -// BugRef is a typed reference to an issue-tracker entry related to an overlay. Today -// it carries only a URL; the struct form leaves room for tracker-specific metadata to -// be added later without breaking the on-disk schema. -type BugRef struct { - // URL is the http(s) link to the bug entry. Required. - URL string `toml:"url" json:"url" validate:"required,http_url" jsonschema:"required,format=uri,pattern=^https?://,title=URL,description=HTTP(S) link to the bug entry"` +// OverlayUpstreamStatus classifies an overlay's relationship to its upstream project. +// It distinguishes "already upstream", "could be upstreamed", "needs an upstream +// mechanism first", "permanent AZL-only deviation", and "not yet assessed". +type OverlayUpstreamStatus string + +const ( + // OverlayUpstreamStatusUpstreamed indicates the change is already in Fedora / the + // upstream project. The overlay is carried only until AZL bumps past the fix. + OverlayUpstreamStatusUpstreamed OverlayUpstreamStatus = "upstreamed" + // OverlayUpstreamStatusUpstreamable indicates the change could be sent upstream + // as-is. Reviewers should ask the author to link the upstream PR. + OverlayUpstreamStatusUpstreamable OverlayUpstreamStatus = "upstreamable" + // OverlayUpstreamStatusNeedsUpstreamHook indicates an AZL specialization that today + // requires invasive spec edits; upstream could add a bcond / ifdef / config knob + // that would let us drop the overlay. Reviewers should ask whether the hook can be + // upstreamed instead of the change itself. + OverlayUpstreamStatusNeedsUpstreamHook OverlayUpstreamStatus = "needs-upstream-hook" + // OverlayUpstreamStatusInapplicable indicates a permanent AZL-only deviation. + // Reviewers should push back on why we have to fork upstream forever. + OverlayUpstreamStatusInapplicable OverlayUpstreamStatus = "inapplicable" + // OverlayUpstreamStatusUnknown indicates the author has not yet assessed the + // overlay's upstream story. Reviewers should push for a definite status before + // approving. + OverlayUpstreamStatusUnknown OverlayUpstreamStatus = "unknown" +) + +// allOverlayUpstreamStatuses lists every recognized [OverlayUpstreamStatus] value. +// +//nolint:gochecknoglobals // effectively a constant; Go doesn't allow const slices. +var allOverlayUpstreamStatuses = []OverlayUpstreamStatus{ + OverlayUpstreamStatusUpstreamed, + OverlayUpstreamStatusUpstreamable, + OverlayUpstreamStatusNeedsUpstreamHook, + OverlayUpstreamStatusInapplicable, + OverlayUpstreamStatusUnknown, +} + +// IsValid reports whether s is one of the recognized [OverlayUpstreamStatus] values. +func (s OverlayUpstreamStatus) IsValid() bool { + return slices.Contains(allOverlayUpstreamStatuses, s) +} + +// URLRef is a typed reference to an external resource (an upstream commit, issue- +// tracker entry, or similar). Today it carries only a URL; the struct form leaves +// room for source-specific metadata to be added later without breaking the on-disk +// schema. +type URLRef struct { + // URL is the http(s) link to the referenced resource. Required. + URL string `toml:"url" json:"url" validate:"required,http_url" jsonschema:"required,format=uri,pattern=^https?://,title=URL,description=HTTP(S) link to the referenced resource"` } // OverlayMetadata describes the intent and provenance of an overlay. It is documentation @@ -87,19 +130,21 @@ type BugRef struct { // are optional but constrained by category-specific rules (see [OverlayMetadata.Validate]). type OverlayMetadata struct { // Category classifies the overlay's intent. Required. - Category OverlayCategory `toml:"category" json:"category" jsonschema:"required,enum=backport-dist-git,enum=azl-pruning,enum=azl-compatibility,enum=azl-dep-missing-workaround,enum=azl-branding-policy,enum=azl-disable-flaky-tests,enum=azl-disable-unsupported-tests,enum=azl-security-compliance,enum=azl-release-management,enum=azl-platform-adaptation,title=Category,description=Classification of the overlay's intent"` + Category OverlayCategory `toml:"category" json:"category" jsonschema:"required,enum=upstream-backport,enum=azl-pruning,enum=azl-compatibility,enum=azl-dep-missing-workaround,enum=azl-branding-policy,enum=azl-disable-flaky-tests,enum=azl-disable-unsupported-tests,enum=azl-security-compliance,enum=azl-release-management,enum=azl-platform-adaptation,title=Category,description=Classification of the overlay's intent"` - // Commits lists URLs of upstream commits (typically Fedora dist-git or upstream-project - // commits) that this overlay backports or references. - Commits []string `toml:"commits,omitempty" json:"commits,omitempty" validate:"omitempty,dive,http_url" jsonschema:"title=Commits,description=URLs of upstream commits this overlay backports or references"` + // Commits references upstream commits (typically Fedora dist-git or upstream-project + // commits) that this overlay backports or references. Each entry must carry an + // http(s) URL (see [URLRef]). + Commits []URLRef `toml:"commits,omitempty" json:"commits,omitempty" validate:"omitempty,dive" jsonschema:"title=Commits,description=Upstream commits this overlay backports or references"` // Bugs holds references to issue-tracker entries related to this overlay. Each entry - // must carry an http(s) URL (see [BugRef]). - Bugs []BugRef `toml:"bugs,omitempty" json:"bugs,omitempty" validate:"omitempty,dive" jsonschema:"title=Bug references,description=References to issue-tracker entries related to this overlay"` + // must carry an http(s) URL (see [URLRef]). + Bugs []URLRef `toml:"bugs,omitempty" json:"bugs,omitempty" validate:"omitempty,dive" jsonschema:"title=Bug references,description=References to issue-tracker entries related to this overlay"` - // Upstreamable records whether this overlay's change can be upstreamed. It is omitted - // (nil) when upstreamability has not yet been assessed. - Upstreamable *bool `toml:"upstreamable,omitempty" json:"upstreamable,omitempty" jsonschema:"title=Upstreamable,description=Whether this overlay's change can be upstreamed; omit if not yet assessed"` + // UpstreamStatus classifies the overlay's relationship to its upstream project. + // Required. Use [OverlayUpstreamStatusUnknown] when the assessment has not been + // made yet; reviewers should push for a definite status before approving. + UpstreamStatus OverlayUpstreamStatus `toml:"upstream-status" json:"upstreamStatus" jsonschema:"required,title=Upstream status,description=Classifies the overlay's relationship to its upstream project,enum=upstreamed,enum=upstreamable,enum=needs-upstream-hook,enum=inapplicable,enum=unknown"` } // clone returns a deep copy of the metadata. It is used to stamp a single file-level @@ -115,11 +160,6 @@ func (m *OverlayMetadata) clone() *OverlayMetadata { cloned.Commits = slices.Clone(m.Commits) cloned.Bugs = slices.Clone(m.Bugs) - if m.Upstreamable != nil { - upstreamable := *m.Upstreamable - cloned.Upstreamable = &upstreamable - } - return &cloned } @@ -141,15 +181,39 @@ func (m *OverlayMetadata) Validate() error { return fmt.Errorf("unknown overlay category %#q", string(m.Category)) } - // 'backport-dist-git' is the only category that imposes an extra required field; all + // 'upstream-backport' is the only category that imposes an extra required field; all // other categories require nothing beyond a valid 'category'. - if m.Category == OverlayCategoryBackportDistGit && len(m.Commits) == 0 { + if m.Category == OverlayCategoryUpstreamBackport && len(m.Commits) == 0 { return fmt.Errorf( "overlay category %#q requires at least one entry in 'commits'", string(m.Category), ) } + if m.UpstreamStatus == "" { + return errors.New("'metadata' requires 'upstream-status'") + } + + if !m.UpstreamStatus.IsValid() { + return fmt.Errorf("unknown overlay upstream-status %#q", string(m.UpstreamStatus)) + } + + // 'upstream-backport' asserts the change is already in upstream or could be + // (either already-merged Fedora / OSS commits, or a change the author intends to + // send upstream next). Any [OverlayUpstreamStatus] other than + // [OverlayUpstreamStatusUpstreamed] or [OverlayUpstreamStatusUpstreamable] + // contradicts the category. + if m.Category == OverlayCategoryUpstreamBackport && + m.UpstreamStatus != OverlayUpstreamStatusUpstreamed && + m.UpstreamStatus != OverlayUpstreamStatusUpstreamable { + return fmt.Errorf( + "overlay category %#q implies the change is already upstream or upstreamable; "+ + "'upstream-status' value %#q is contradictory (allowed: %#q or %#q)", + string(m.Category), string(m.UpstreamStatus), + string(OverlayUpstreamStatusUpstreamed), string(OverlayUpstreamStatusUpstreamable), + ) + } + if err := metadataValidator.Struct(m); err != nil { return fmt.Errorf("invalid overlay metadata:\n%w", err) } diff --git a/internal/projectconfig/overlay_metadata_test.go b/internal/projectconfig/overlay_metadata_test.go index 22e52ac49..46ce0a1f3 100644 --- a/internal/projectconfig/overlay_metadata_test.go +++ b/internal/projectconfig/overlay_metadata_test.go @@ -7,7 +7,6 @@ import ( "testing" "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" - "github.com/samber/lo" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -19,79 +18,127 @@ func TestOverlayMetadata_Validate(t *testing.T) { errorContains string }{ { - name: "backport-dist-git with commits is valid", + name: "upstream-backport with commits is valid", metadata: projectconfig.OverlayMetadata{ - Category: projectconfig.OverlayCategoryBackportDistGit, - Commits: []string{"https://src.fedoraproject.org/rpms/xclock/c/abc123"}, + Category: projectconfig.OverlayCategoryUpstreamBackport, + Commits: []projectconfig.URLRef{ + {URL: "https://src.fedoraproject.org/rpms/xclock/c/abc123"}, + }, + UpstreamStatus: projectconfig.OverlayUpstreamStatusUpstreamed, }, }, { - name: "backport-dist-git requires commits", + name: "upstream-backport requires commits", metadata: projectconfig.OverlayMetadata{ - Category: projectconfig.OverlayCategoryBackportDistGit, + Category: projectconfig.OverlayCategoryUpstreamBackport, + UpstreamStatus: projectconfig.OverlayUpstreamStatusUpstreamed, }, errorContains: "commits", }, { - name: "backport-dist-git with bug refs valid", + name: "upstream-backport with bug refs valid", metadata: projectconfig.OverlayMetadata{ - Category: projectconfig.OverlayCategoryBackportDistGit, - Commits: []string{"https://src.fedoraproject.org/rpms/xclock/c/abc123"}, - Bugs: []projectconfig.BugRef{ + Category: projectconfig.OverlayCategoryUpstreamBackport, + Commits: []projectconfig.URLRef{ + {URL: "https://src.fedoraproject.org/rpms/xclock/c/abc123"}, + }, + Bugs: []projectconfig.URLRef{ {URL: "https://github.com/example/repo/issues/1"}, }, + UpstreamStatus: projectconfig.OverlayUpstreamStatusUpstreamed, }, }, { - name: "azl-branding-policy needs no extras", + name: "azl-branding-policy with inapplicable status is valid", metadata: projectconfig.OverlayMetadata{ - Category: projectconfig.OverlayCategoryAZLBrandingPolicy, + Category: projectconfig.OverlayCategoryAZLBrandingPolicy, + UpstreamStatus: projectconfig.OverlayUpstreamStatusInapplicable, }, }, { - name: "azl-dep-missing-workaround needs no extras", + name: "azl-dep-missing-workaround with unknown status is valid", metadata: projectconfig.OverlayMetadata{ - Category: projectconfig.OverlayCategoryAZLDepMissingWorkaround, + Category: projectconfig.OverlayCategoryAZLDepMissingWorkaround, + UpstreamStatus: projectconfig.OverlayUpstreamStatusUnknown, }, }, { name: "azl-branding-policy may carry bugs and commits", metadata: projectconfig.OverlayMetadata{ Category: projectconfig.OverlayCategoryAZLBrandingPolicy, - Bugs: []projectconfig.BugRef{ + Bugs: []projectconfig.URLRef{ {URL: "https://bugzilla.redhat.com/show_bug.cgi?id=2234567"}, {URL: "https://github.com/example/repo/issues/2"}, }, - Commits: []string{"https://github.com/example/repo/commit/deadbeef"}, - Upstreamable: lo.ToPtr(true), + Commits: []projectconfig.URLRef{ + {URL: "https://github.com/example/repo/commit/deadbeef"}, + }, + UpstreamStatus: projectconfig.OverlayUpstreamStatusUpstreamable, + }, + }, + { + name: "upstream-status inapplicable is valid", + metadata: projectconfig.OverlayMetadata{ + Category: projectconfig.OverlayCategoryAZLCompatibility, + UpstreamStatus: projectconfig.OverlayUpstreamStatusInapplicable, }, }, { - name: "upstreamable false is valid", + name: "upstream-status needs-upstream-hook is valid", metadata: projectconfig.OverlayMetadata{ - Category: projectconfig.OverlayCategoryAZLCompatibility, - Upstreamable: lo.ToPtr(false), + Category: projectconfig.OverlayCategoryAZLPruning, + UpstreamStatus: projectconfig.OverlayUpstreamStatusNeedsUpstreamHook, }, }, + { + name: "upstream-status upstreamed is valid", + metadata: projectconfig.OverlayMetadata{ + Category: projectconfig.OverlayCategoryUpstreamBackport, + Commits: []projectconfig.URLRef{ + {URL: "https://src.fedoraproject.org/rpms/xclock/c/abc123"}, + }, + UpstreamStatus: projectconfig.OverlayUpstreamStatusUpstreamed, + }, + }, + { + name: "unknown upstream-status is rejected", + metadata: projectconfig.OverlayMetadata{ + Category: projectconfig.OverlayCategoryAZLCompatibility, + UpstreamStatus: projectconfig.OverlayUpstreamStatus("bogus"), + }, + errorContains: "unknown overlay upstream-status", + }, { name: "missing category", metadata: projectconfig.OverlayMetadata{ - Commits: []string{"https://example.com/commit/abc"}, + Commits: []projectconfig.URLRef{ + {URL: "https://example.com/commit/abc"}, + }, + UpstreamStatus: projectconfig.OverlayUpstreamStatusUpstreamed, }, errorContains: "category", }, { name: "unknown category", metadata: projectconfig.OverlayMetadata{ - Category: projectconfig.OverlayCategory("bogus"), + Category: projectconfig.OverlayCategory("bogus"), + UpstreamStatus: projectconfig.OverlayUpstreamStatusUnknown, }, errorContains: "unknown overlay category", }, + { + name: "missing upstream-status", + metadata: projectconfig.OverlayMetadata{ + Category: projectconfig.OverlayCategoryAZLBrandingPolicy, + }, + errorContains: "upstream-status", + }, { name: "bug requires url", metadata: projectconfig.OverlayMetadata{ - Category: projectconfig.OverlayCategoryAZLCompatibility, - Bugs: []projectconfig.BugRef{{}}, + Category: projectconfig.OverlayCategoryAZLCompatibility, + Bugs: []projectconfig.URLRef{{}}, + UpstreamStatus: projectconfig.OverlayUpstreamStatusInapplicable, }, errorContains: "URL", }, @@ -99,19 +146,63 @@ func TestOverlayMetadata_Validate(t *testing.T) { name: "bug rejects non-http url", metadata: projectconfig.OverlayMetadata{ Category: projectconfig.OverlayCategoryAZLCompatibility, - Bugs: []projectconfig.BugRef{ + Bugs: []projectconfig.URLRef{ {URL: "ftp://example.com/bug/1"}, }, + UpstreamStatus: projectconfig.OverlayUpstreamStatusInapplicable, }, errorContains: "URL", }, { name: "commit url must be http", metadata: projectconfig.OverlayMetadata{ - Category: projectconfig.OverlayCategoryBackportDistGit, - Commits: []string{"not-a-url"}, + Category: projectconfig.OverlayCategoryUpstreamBackport, + Commits: []projectconfig.URLRef{{URL: "not-a-url"}}, + UpstreamStatus: projectconfig.OverlayUpstreamStatusUpstreamed, + }, + errorContains: "URL", + }, + { + name: "upstream-backport with explicit upstreamed status is valid", + metadata: projectconfig.OverlayMetadata{ + Category: projectconfig.OverlayCategoryUpstreamBackport, + Commits: []projectconfig.URLRef{ + {URL: "https://src.fedoraproject.org/rpms/xclock/c/abc123"}, + }, + UpstreamStatus: projectconfig.OverlayUpstreamStatusUpstreamed, + }, + }, + { + name: "upstream-backport with upstreamable status is valid", + metadata: projectconfig.OverlayMetadata{ + Category: projectconfig.OverlayCategoryUpstreamBackport, + Commits: []projectconfig.URLRef{ + {URL: "https://src.fedoraproject.org/rpms/xclock/c/abc123"}, + }, + UpstreamStatus: projectconfig.OverlayUpstreamStatusUpstreamable, + }, + }, + { + name: "upstream-backport with needs-upstream-hook status is contradictory", + metadata: projectconfig.OverlayMetadata{ + Category: projectconfig.OverlayCategoryUpstreamBackport, + Commits: []projectconfig.URLRef{ + {URL: "https://src.fedoraproject.org/rpms/xclock/c/abc123"}, + }, + UpstreamStatus: projectconfig.OverlayUpstreamStatusNeedsUpstreamHook, + }, + errorContains: "contradictory", + }, + { + name: "upstream-backport with inapplicable status is contradictory", + metadata: projectconfig.OverlayMetadata{ + Category: projectconfig.OverlayCategoryUpstreamBackport, + Commits: []projectconfig.URLRef{ + {URL: "https://src.fedoraproject.org/rpms/xclock/c/abc123"}, + }, + UpstreamStatus: projectconfig.OverlayUpstreamStatusInapplicable, }, - errorContains: "Commits", + errorContains: "contradictory", }, } @@ -137,7 +228,8 @@ func TestComponentOverlay_Validate_Metadata(t *testing.T) { Tag: "Vendor", Value: "Microsoft", Metadata: &projectconfig.OverlayMetadata{ - Category: projectconfig.OverlayCategoryAZLBrandingPolicy, + Category: projectconfig.OverlayCategoryAZLBrandingPolicy, + UpstreamStatus: projectconfig.OverlayUpstreamStatusInapplicable, }, } require.NoError(t, overlay.Validate()) diff --git a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap index 2cc69cad9..e5460d84b 100755 --- a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap @@ -3,22 +3,6 @@ "$id": "https://github.com/microsoft/azure-linux-dev-tools/internal/projectconfig/config-file", "$ref": "#/$defs/ConfigFile", "$defs": { - "BugRef": { - "properties": { - "url": { - "type": "string", - "pattern": "^https?://", - "format": "uri", - "title": "URL", - "description": "HTTP(S) link to the bug entry" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "url" - ] - }, "CheckConfig": { "properties": { "skip": { @@ -802,7 +786,7 @@ "category": { "type": "string", "enum": [ - "backport-dist-git", + "upstream-backport", "azl-pruning", "azl-compatibility", "azl-dep-missing-workaround", @@ -818,30 +802,38 @@ }, "commits": { "items": { - "type": "string" + "$ref": "#/$defs/URLRef" }, "type": "array", "title": "Commits", - "description": "URLs of upstream commits this overlay backports or references" + "description": "Upstream commits this overlay backports or references" }, "bugs": { "items": { - "$ref": "#/$defs/BugRef" + "$ref": "#/$defs/URLRef" }, "type": "array", "title": "Bug references", "description": "References to issue-tracker entries related to this overlay" }, - "upstreamable": { - "type": "boolean", - "title": "Upstreamable", - "description": "Whether this overlay's change can be upstreamed; omit if not yet assessed" + "upstream-status": { + "type": "string", + "enum": [ + "upstreamed", + "upstreamable", + "needs-upstream-hook", + "inapplicable", + "unknown" + ], + "title": "Upstream status", + "description": "Classifies the overlay's relationship to its upstream project" } }, "additionalProperties": false, "type": "object", "required": [ - "category" + "category", + "upstream-status" ] }, "PackageConfig": { @@ -1621,6 +1613,22 @@ }, "additionalProperties": false, "type": "object" + }, + "URLRef": { + "properties": { + "url": { + "type": "string", + "pattern": "^https?://", + "format": "uri", + "title": "URL", + "description": "HTTP(S) link to the referenced resource" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "url" + ] } }, "$comment": "This schema was auto-generated by: azldev config generate-schema" diff --git a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap index 2cc69cad9..e5460d84b 100755 --- a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap @@ -3,22 +3,6 @@ "$id": "https://github.com/microsoft/azure-linux-dev-tools/internal/projectconfig/config-file", "$ref": "#/$defs/ConfigFile", "$defs": { - "BugRef": { - "properties": { - "url": { - "type": "string", - "pattern": "^https?://", - "format": "uri", - "title": "URL", - "description": "HTTP(S) link to the bug entry" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "url" - ] - }, "CheckConfig": { "properties": { "skip": { @@ -802,7 +786,7 @@ "category": { "type": "string", "enum": [ - "backport-dist-git", + "upstream-backport", "azl-pruning", "azl-compatibility", "azl-dep-missing-workaround", @@ -818,30 +802,38 @@ }, "commits": { "items": { - "type": "string" + "$ref": "#/$defs/URLRef" }, "type": "array", "title": "Commits", - "description": "URLs of upstream commits this overlay backports or references" + "description": "Upstream commits this overlay backports or references" }, "bugs": { "items": { - "$ref": "#/$defs/BugRef" + "$ref": "#/$defs/URLRef" }, "type": "array", "title": "Bug references", "description": "References to issue-tracker entries related to this overlay" }, - "upstreamable": { - "type": "boolean", - "title": "Upstreamable", - "description": "Whether this overlay's change can be upstreamed; omit if not yet assessed" + "upstream-status": { + "type": "string", + "enum": [ + "upstreamed", + "upstreamable", + "needs-upstream-hook", + "inapplicable", + "unknown" + ], + "title": "Upstream status", + "description": "Classifies the overlay's relationship to its upstream project" } }, "additionalProperties": false, "type": "object", "required": [ - "category" + "category", + "upstream-status" ] }, "PackageConfig": { @@ -1621,6 +1613,22 @@ }, "additionalProperties": false, "type": "object" + }, + "URLRef": { + "properties": { + "url": { + "type": "string", + "pattern": "^https?://", + "format": "uri", + "title": "URL", + "description": "HTTP(S) link to the referenced resource" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "url" + ] } }, "$comment": "This schema was auto-generated by: azldev config generate-schema" diff --git a/schemas/azldev.schema.json b/schemas/azldev.schema.json index 2cc69cad9..e5460d84b 100644 --- a/schemas/azldev.schema.json +++ b/schemas/azldev.schema.json @@ -3,22 +3,6 @@ "$id": "https://github.com/microsoft/azure-linux-dev-tools/internal/projectconfig/config-file", "$ref": "#/$defs/ConfigFile", "$defs": { - "BugRef": { - "properties": { - "url": { - "type": "string", - "pattern": "^https?://", - "format": "uri", - "title": "URL", - "description": "HTTP(S) link to the bug entry" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "url" - ] - }, "CheckConfig": { "properties": { "skip": { @@ -802,7 +786,7 @@ "category": { "type": "string", "enum": [ - "backport-dist-git", + "upstream-backport", "azl-pruning", "azl-compatibility", "azl-dep-missing-workaround", @@ -818,30 +802,38 @@ }, "commits": { "items": { - "type": "string" + "$ref": "#/$defs/URLRef" }, "type": "array", "title": "Commits", - "description": "URLs of upstream commits this overlay backports or references" + "description": "Upstream commits this overlay backports or references" }, "bugs": { "items": { - "$ref": "#/$defs/BugRef" + "$ref": "#/$defs/URLRef" }, "type": "array", "title": "Bug references", "description": "References to issue-tracker entries related to this overlay" }, - "upstreamable": { - "type": "boolean", - "title": "Upstreamable", - "description": "Whether this overlay's change can be upstreamed; omit if not yet assessed" + "upstream-status": { + "type": "string", + "enum": [ + "upstreamed", + "upstreamable", + "needs-upstream-hook", + "inapplicable", + "unknown" + ], + "title": "Upstream status", + "description": "Classifies the overlay's relationship to its upstream project" } }, "additionalProperties": false, "type": "object", "required": [ - "category" + "category", + "upstream-status" ] }, "PackageConfig": { @@ -1621,6 +1613,22 @@ }, "additionalProperties": false, "type": "object" + }, + "URLRef": { + "properties": { + "url": { + "type": "string", + "pattern": "^https?://", + "format": "uri", + "title": "URL", + "description": "HTTP(S) link to the referenced resource" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "url" + ] } }, "$comment": "This schema was auto-generated by: azldev config generate-schema" From 299c55d4a35d0faa915a113822cc2bd943031082 Mon Sep 17 00:00:00 2001 From: Nan Liu Date: Thu, 9 Jul 2026 23:42:27 +0000 Subject: [PATCH 14/20] refactor(overlays): rename azl-dep-missing-workaround to azl-temp-workaround Broaden the category to cover any overlay that is explicitly intended to be dropped once an upstream or environmental fix lands, including (but not limited to) workarounds for dependencies not yet imported into AZL. Also broaden the azl-branding-policy to cover fixes for upstream code that hard-codes Fedora identity strings --- docs/user/reference/config/overlays.md | 6 ++--- internal/projectconfig/overlay_metadata.go | 22 ++++++++++--------- .../projectconfig/overlay_metadata_test.go | 4 ++-- ...ainer_config_generate-schema_stdout_1.snap | 2 +- ...shots_config_generate-schema_stdout_1.snap | 2 +- schemas/azldev.schema.json | 2 +- 6 files changed, 20 insertions(+), 18 deletions(-) diff --git a/docs/user/reference/config/overlays.md b/docs/user/reference/config/overlays.md index 9ff05e67b..40e7a6d4b 100644 --- a/docs/user/reference/config/overlays.md +++ b/docs/user/reference/config/overlays.md @@ -93,9 +93,9 @@ Overlays can carry an optional `metadata` table that documents *why* the overlay |----------|-------------| | `upstream-backport` | Fix backported from an upstream source (Fedora dist-git or the component's OSS project) that AZL will inherit once it bumps past the fix. Self-resolves on version bump. Requires at least one entry in `commits` pointing to the upstream change. `upstream-status` must be `upstreamed` or `upstreamable` — any other value contradicts the category. See [Upstream status](#upstream-status). | | `azl-pruning` | Removing content from a component for AZL: dependencies that are not shipped, unneeded features, subpackages, or files. | -| `azl-compatibility` | Making a component work in the AZL build/runtime environment: toolchain and mock adjustments, and similar compatibility fixes that are not themselves backports. | -| `azl-dep-missing-workaround` | Working around a runtime or build dependency that has not yet been imported into AZL (or is unavailable on a given target). Drop the overlay once the dependency lands. | -| `azl-branding-policy` | Fedora→Azure Linux name/path changes; RHEL/enterprise convention alignment. | +| `azl-compatibility` | Adapting a component to *how Azure Linux is built and shipped* — its build tooling, buildroot, infrastructure, and runtime ecosystem — when an upstream package builds or behaves incorrectly for AZL-specific reasons that are not branding, a missing dependency, architecture, or tests. Examples: quirks of the `azldev` source downloader, reproducibility fixes for AZL's multi-arch Koji `rpmdiff`, buildroot gaps where an AZL build option breaks a transitive dependency, and Fedora version-skew or config fixes for AZL targets. Use `upstream-status` to separate AZL-only concerns (`inapplicable`) from fixes upstream could also take (`upstreamable` / `upstreamed`). | +| `azl-temp-workaround` | Temporary workaround that is explicitly intended to be dropped once an upstream or environmental fix lands. Covers overlays working around a runtime or build dependency that has not yet been imported into AZL (or is unavailable on a given target), as well as any other transient workaround waiting on an external change. Drop the overlay once the fix lands. | +| `azl-branding-policy` | Fedora→Azure Linux identity differences: intentional name/path/vendor conventions **and** spec fixes for upstream code that hard-codes Fedora identity strings (e.g. `_vendor=redhat`, `-redhat-linux[-gnu]` triples, `redhat-linux-build` dirs). Covers both permanent branding choices and the fallout from AZL setting `_vendor=azurelinux`; use `upstream-status` to separate permanent choices (`inapplicable`) from upstreamable fallout (`upstreamable` / `needs-upstream-hook`). | | `azl-disable-flaky-tests` | Skipping tests that fail intermittently or due to environmental flakiness rather than a real problem with the component. | | `azl-disable-unsupported-tests` | Skipping tests that cannot meaningfully run in AZL's build/runtime environment (e.g. tests that require network access, root, or hardware that is unavailable in mock). | | `azl-security-compliance` | FIPS or crypto-policy changes. | diff --git a/internal/projectconfig/overlay_metadata.go b/internal/projectconfig/overlay_metadata.go index 4bc5c3029..0c07b3a35 100644 --- a/internal/projectconfig/overlay_metadata.go +++ b/internal/projectconfig/overlay_metadata.go @@ -26,15 +26,17 @@ const ( OverlayCategoryAZLPruning OverlayCategory = "azl-pruning" // OverlayCategoryAZLCompatibility makes a component work in the AZL build/runtime // environment: toolchain and mock adjustments and similar compatibility fixes that - // are not themselves backports. See [OverlayCategoryAZLDepMissingWorkaround] for - // workarounds for dependencies that have not yet been imported into AZL. + // are not themselves backports. OverlayCategoryAZLCompatibility OverlayCategory = "azl-compatibility" - // OverlayCategoryAZLDepMissingWorkaround works around a runtime or build dependency - // that has not yet been imported into AZL (or is unavailable on a given target). Drop - // the overlay once the dependency lands. - OverlayCategoryAZLDepMissingWorkaround OverlayCategory = "azl-dep-missing-workaround" - // OverlayCategoryAZLBrandingPolicy applies Fedora→AzureLinux name/path changes or - // RHEL/enterprise convention alignment. + // OverlayCategoryAZLTemporaryWorkaround marks overlays that are explicitly intended to be + // dropped once an upstream or environmental fix lands. This covers both overlays that + // work around a runtime or build dependency that has not yet been imported into AZL + // and any other transient workaround waiting on an external change. + OverlayCategoryAZLTemporaryWorkaround OverlayCategory = "azl-temp-workaround" + // OverlayCategoryAZLBrandingPolicy handles Fedora→Azure Linux identity differences: + // name/path/vendor conventions and fixes for specs that hard-code Fedora identity + // strings (e.g. `_vendor=redhat`, `redhat-linux` triples). Use `upstream-status` to + // distinguish permanent AZL choices from fallout that could be upstreamed. OverlayCategoryAZLBrandingPolicy OverlayCategory = "azl-branding-policy" // OverlayCategoryAZLDisableFlakyTests skips tests that fail intermittently or due to // environmental flakiness rather than a real problem with the component, @@ -59,7 +61,7 @@ var allOverlayCategories = []OverlayCategory{ OverlayCategoryUpstreamBackport, OverlayCategoryAZLPruning, OverlayCategoryAZLCompatibility, - OverlayCategoryAZLDepMissingWorkaround, + OverlayCategoryAZLTemporaryWorkaround, OverlayCategoryAZLBrandingPolicy, OverlayCategoryAZLDisableFlakyTests, OverlayCategoryAZLDisableUnsupportedTests, @@ -130,7 +132,7 @@ type URLRef struct { // are optional but constrained by category-specific rules (see [OverlayMetadata.Validate]). type OverlayMetadata struct { // Category classifies the overlay's intent. Required. - Category OverlayCategory `toml:"category" json:"category" jsonschema:"required,enum=upstream-backport,enum=azl-pruning,enum=azl-compatibility,enum=azl-dep-missing-workaround,enum=azl-branding-policy,enum=azl-disable-flaky-tests,enum=azl-disable-unsupported-tests,enum=azl-security-compliance,enum=azl-release-management,enum=azl-platform-adaptation,title=Category,description=Classification of the overlay's intent"` + Category OverlayCategory `toml:"category" json:"category" jsonschema:"required,enum=upstream-backport,enum=azl-pruning,enum=azl-compatibility,enum=azl-temp-workaround,enum=azl-branding-policy,enum=azl-disable-flaky-tests,enum=azl-disable-unsupported-tests,enum=azl-security-compliance,enum=azl-release-management,enum=azl-platform-adaptation,title=Category,description=Classification of the overlay's intent"` // Commits references upstream commits (typically Fedora dist-git or upstream-project // commits) that this overlay backports or references. Each entry must carry an diff --git a/internal/projectconfig/overlay_metadata_test.go b/internal/projectconfig/overlay_metadata_test.go index 46ce0a1f3..2d683bd2a 100644 --- a/internal/projectconfig/overlay_metadata_test.go +++ b/internal/projectconfig/overlay_metadata_test.go @@ -56,9 +56,9 @@ func TestOverlayMetadata_Validate(t *testing.T) { }, }, { - name: "azl-dep-missing-workaround with unknown status is valid", + name: "azl-temp-workaround with unknown status is valid", metadata: projectconfig.OverlayMetadata{ - Category: projectconfig.OverlayCategoryAZLDepMissingWorkaround, + Category: projectconfig.OverlayCategoryAZLTemporaryWorkaround, UpstreamStatus: projectconfig.OverlayUpstreamStatusUnknown, }, }, diff --git a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap index e5460d84b..c2ee238e2 100755 --- a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap @@ -789,7 +789,7 @@ "upstream-backport", "azl-pruning", "azl-compatibility", - "azl-dep-missing-workaround", + "azl-temp-workaround", "azl-branding-policy", "azl-disable-flaky-tests", "azl-disable-unsupported-tests", diff --git a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap index e5460d84b..c2ee238e2 100755 --- a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap @@ -789,7 +789,7 @@ "upstream-backport", "azl-pruning", "azl-compatibility", - "azl-dep-missing-workaround", + "azl-temp-workaround", "azl-branding-policy", "azl-disable-flaky-tests", "azl-disable-unsupported-tests", diff --git a/schemas/azldev.schema.json b/schemas/azldev.schema.json index e5460d84b..c2ee238e2 100644 --- a/schemas/azldev.schema.json +++ b/schemas/azldev.schema.json @@ -789,7 +789,7 @@ "upstream-backport", "azl-pruning", "azl-compatibility", - "azl-dep-missing-workaround", + "azl-temp-workaround", "azl-branding-policy", "azl-disable-flaky-tests", "azl-disable-unsupported-tests", From 58cb4842a5218c41fcde01fe5637a4e719841c95 Mon Sep 17 00:00:00 2001 From: Nan Liu Date: Tue, 23 Jun 2026 00:38:13 +0000 Subject: [PATCH 15/20] feat(config): add optional metadata to component groups Add an optional [component-groups..metadata] block reusing the existing OverlayMetadata schema (required category plus commits, bugs, and upstreamable). Metadata is documentation only and does not affect how group members are resolved or built. Validation is wired into ConfigFile.Validate. Regenerate JSON schema; update reference docs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../user/reference/config/component-groups.md | 22 +++++++++ internal/projectconfig/component.go | 5 ++ internal/projectconfig/configfile.go | 21 +++++++++ internal/projectconfig/loader_test.go | 46 +++++++++++++++++++ ...ainer_config_generate-schema_stdout_1.snap | 5 ++ ...shots_config_generate-schema_stdout_1.snap | 5 ++ schemas/azldev.schema.json | 5 ++ 7 files changed, 109 insertions(+) diff --git a/docs/user/reference/config/component-groups.md b/docs/user/reference/config/component-groups.md index 76974b1e8..6651a570a 100644 --- a/docs/user/reference/config/component-groups.md +++ b/docs/user/reference/config/component-groups.md @@ -7,11 +7,33 @@ Component groups organize related components together and let you apply shared d | Field | TOML Key | Type | Required | Description | |-------|----------|------|----------|-------------| | Description | `description` | string | No | Human-readable description of this group | +| Metadata | `metadata` | [OverlayMetadata](overlays.md) | No | Optional documentation metadata describing the group's intent and provenance | | Components | `components` | string array | No | List of component names that belong to this group | | Specs | `specs` | string array | No | Glob patterns for discovering local spec files to include as group members | | Excluded paths | `excluded-paths` | string array | No | Glob patterns for paths to exclude from spec discovery | | Default component config | `default-component-config` | [ComponentConfig](components.md) | No | Default configuration inherited by all components in this group | +## Metadata + +The optional `[component-groups..metadata]` table documents the group's intent and provenance. It shares exactly the same fields and validation rules as [overlay metadata](overlays.md): a required `category`, plus optional commit/bug links and an upstreamable assessment. It is documentation only — it does not affect how members are resolved or built. + +| Field | TOML Key | Type | Required | Description | +|-------|----------|------|----------|-------------| +| Category | `category` | enum | **Yes** | Classification of the group's intent (e.g. `backport-dist-git`, `azl-disable-flaky-tests`) | +| Commits | `commits` | string array (URLs) | No | Upstream commit URLs this group references; required when `category = "backport-dist-git"` | +| Bugs | `bugs` | array of `{ url = "..." }` tables | No | References to related issue-tracker entries; see [Bug references](overlays.md#bug-references) | +| Upstreamable | `upstreamable` | boolean | No | Whether the group's change can be upstreamed (`true`/`false`); omit when not yet assessed | + +```toml +[component-groups.check-skip-initial-failures] +description = "Components with check failures during initial distro bringup" +components = ["bats", "dnf", "git"] + +[component-groups.check-skip-initial-failures.metadata] +category = "azl-disable-flaky-tests" +# upstreamable omitted: upstreamability for this group has not been assessed yet. +``` + ## Component Membership Components are assigned to groups in two ways: diff --git a/internal/projectconfig/component.go b/internal/projectconfig/component.go index 4d74d570b..db889c065 100644 --- a/internal/projectconfig/component.go +++ b/internal/projectconfig/component.go @@ -149,6 +149,11 @@ type ComponentGroupConfig struct { // A human-friendly description of this component group. Description string `toml:"description,omitempty" json:"description,omitempty" jsonschema:"title=Description,description=Description of this component group"` + // Optional documentation metadata describing this component group's intent and provenance. + // It reuses the overlay metadata schema (see [OverlayMetadata]) and does not affect how the + // group's members are resolved or built. + Metadata *OverlayMetadata `toml:"metadata,omitempty" json:"metadata,omitempty" jsonschema:"title=Metadata,description=Optional documentation metadata for this component group"` + // List of explicitly included components, identified by name. Components []string `toml:"components,omitempty" json:"components,omitempty" jsonschema:"title=Components,description=List of component names that are members of this group"` diff --git a/internal/projectconfig/configfile.go b/internal/projectconfig/configfile.go index 5393edd9d..fff9c1d9d 100644 --- a/internal/projectconfig/configfile.go +++ b/internal/projectconfig/configfile.go @@ -103,6 +103,11 @@ func (f ConfigFile) Validate() error { } } + // Validate component group metadata. + if err := validateComponentGroupMetadata(f.ComponentGroups); err != nil { + return err + } + // Per-component snapshot timestamps are not allowed. Components inherit // the snapshot from the distro/group default-component-config or the // project's default-distro. Per-component snapshots would create @@ -181,6 +186,22 @@ func validateTestDefinitions(tests map[string]TestDefinition) error { return nil } +// validateComponentGroupMetadata validates the optional documentation metadata declared +// on each component group. +func validateComponentGroupMetadata(groups map[string]ComponentGroupConfig) error { + for groupName, group := range groups { + if group.Metadata == nil { + continue + } + + if err := group.Metadata.Validate(); err != nil { + return fmt.Errorf("invalid metadata on component group %#q:\n%w", groupName, err) + } + } + + return nil +} + func validateNewTestReferences(cfgFile ConfigFile) error { for groupName, group := range cfgFile.TestGroups { scope := fmt.Sprintf("test-group %#q tests", groupName) diff --git a/internal/projectconfig/loader_test.go b/internal/projectconfig/loader_test.go index afdc3cca3..b4677df7a 100644 --- a/internal/projectconfig/loader_test.go +++ b/internal/projectconfig/loader_test.go @@ -485,6 +485,52 @@ specs = ["SPECS/**/*.spec"] assert.Equal(t, []string{"core"}, config.GroupsByComponent["bar"]) } +func TestLoadAndResolveProjectConfig_ComponentGroupWithMetadata(t *testing.T) { + const configContents = ` +[component-groups.core] +specs = ["SPECS/**/*.spec"] + +[component-groups.core.metadata] +category = "backport-dist-git" +commits = ["https://example.com/commit/abc"] +bugs = [{ url = "https://example.com/bug/1" }] +upstreamable = true +` + + ctx := testctx.NewCtx() + require.NoError(t, fileutils.WriteFile(ctx.FS(), testConfigPath, []byte(configContents), fileperms.PrivateFile)) + + config, err := loadAndResolveProjectConfig(ctx.FS(), false, testConfigPath) + require.NoError(t, err) + + if assert.Contains(t, config.ComponentGroups, "core") { + group := config.ComponentGroups["core"] + require.NotNil(t, group.Metadata) + assert.Equal(t, OverlayCategoryBackportDistGit, group.Metadata.Category) + assert.Equal(t, []string{"https://example.com/commit/abc"}, group.Metadata.Commits) + assert.Equal(t, []BugRef{{URL: "https://example.com/bug/1"}}, group.Metadata.Bugs) + require.NotNil(t, group.Metadata.Upstreamable) + assert.True(t, *group.Metadata.Upstreamable) + } +} + +func TestLoadAndResolveProjectConfig_ComponentGroupMetadataMissingCategory(t *testing.T) { + const configContents = ` +[component-groups.core] +specs = ["SPECS/**/*.spec"] + +[component-groups.core.metadata] +bugs = [{ url = "https://example.com/bug/1" }] +` + + ctx := testctx.NewCtx() + require.NoError(t, fileutils.WriteFile(ctx.FS(), testConfigPath, []byte(configContents), fileperms.PrivateFile)) + + config, err := loadAndResolveProjectConfig(ctx.FS(), false, testConfigPath) + require.Error(t, err) + assert.Nil(t, config) +} + func TestLoadAndResolveProjectConfig_GroupsByComponent_MultipleGroups(t *testing.T) { testFiles := []struct { path string diff --git a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap index c2ee238e2..84da8173b 100755 --- a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap @@ -174,6 +174,11 @@ "title": "Description", "description": "Description of this component group" }, + "metadata": { + "$ref": "#/$defs/OverlayMetadata", + "title": "Metadata", + "description": "Optional documentation metadata for this component group" + }, "components": { "items": { "type": "string" diff --git a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap index c2ee238e2..84da8173b 100755 --- a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap @@ -174,6 +174,11 @@ "title": "Description", "description": "Description of this component group" }, + "metadata": { + "$ref": "#/$defs/OverlayMetadata", + "title": "Metadata", + "description": "Optional documentation metadata for this component group" + }, "components": { "items": { "type": "string" diff --git a/schemas/azldev.schema.json b/schemas/azldev.schema.json index c2ee238e2..84da8173b 100644 --- a/schemas/azldev.schema.json +++ b/schemas/azldev.schema.json @@ -174,6 +174,11 @@ "title": "Description", "description": "Description of this component group" }, + "metadata": { + "$ref": "#/$defs/OverlayMetadata", + "title": "Metadata", + "description": "Optional documentation metadata for this component group" + }, "components": { "items": { "type": "string" From 349f214669452a751c29d6c7a612d5602dee7559 Mon Sep 17 00:00:00 2001 From: Nan Liu Date: Fri, 10 Jul 2026 01:25:28 +0000 Subject: [PATCH 16/20] fix: repair main after component-groups metadata + overlay-metadata API drift The component-groups metadata PR was based on the pre-rework OverlayMetadata API and landed after that rework, leaving main red: - loader_test.go referenced removed identifiers (`OverlayCategoryBackportDistGit`, `BugRef`, `Upstreamable`) and inline TOML using the old `upstreamable` field. Migrated it to `OverlayCategoryUpstreamBackport`, `URLRef`, and `upstream-status`. - component-groups.md linked to a nonexistent `#bug-references` anchor in overlays.md. Updated to the actual `#url-references` heading. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/user/reference/config/component-groups.md | 12 ++++++------ internal/projectconfig/loader_test.go | 15 +++++++-------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/docs/user/reference/config/component-groups.md b/docs/user/reference/config/component-groups.md index 6651a570a..0bdd58ecf 100644 --- a/docs/user/reference/config/component-groups.md +++ b/docs/user/reference/config/component-groups.md @@ -15,14 +15,14 @@ Component groups organize related components together and let you apply shared d ## Metadata -The optional `[component-groups..metadata]` table documents the group's intent and provenance. It shares exactly the same fields and validation rules as [overlay metadata](overlays.md): a required `category`, plus optional commit/bug links and an upstreamable assessment. It is documentation only — it does not affect how members are resolved or built. +The optional `[component-groups..metadata]` table documents the group's intent and provenance. It shares exactly the same fields and validation rules as [overlay metadata](overlays.md): a required `category`, a required `upstream-status`, and optional commit/bug links. It is documentation only — it does not affect how members are resolved or built. | Field | TOML Key | Type | Required | Description | |-------|----------|------|----------|-------------| -| Category | `category` | enum | **Yes** | Classification of the group's intent (e.g. `backport-dist-git`, `azl-disable-flaky-tests`) | -| Commits | `commits` | string array (URLs) | No | Upstream commit URLs this group references; required when `category = "backport-dist-git"` | -| Bugs | `bugs` | array of `{ url = "..." }` tables | No | References to related issue-tracker entries; see [Bug references](overlays.md#bug-references) | -| Upstreamable | `upstreamable` | boolean | No | Whether the group's change can be upstreamed (`true`/`false`); omit when not yet assessed | +| Category | `category` | enum | **Yes** | Classification of the group's intent (e.g. `upstream-backport`, `azl-disable-flaky-tests`); see [Categories](overlays.md#categories) for the full list | +| Upstream status | `upstream-status` | enum | **Yes** | Relationship to upstream (`upstreamed`, `upstreamable`, `needs-upstream-hook`, `inapplicable`, `unknown`); see [Upstream status](overlays.md#upstream-status). Must be `upstreamed` or `upstreamable` when `category = "upstream-backport"` | +| Commits | `commits` | array of `{ url = "..." }` tables | Sometimes | Upstream commit references this group backports; required when `category = "upstream-backport"`. See [URL references](overlays.md#url-references) | +| Bugs | `bugs` | array of `{ url = "..." }` tables | No | References to related issue-tracker entries; see [URL references](overlays.md#url-references) | ```toml [component-groups.check-skip-initial-failures] @@ -31,7 +31,7 @@ components = ["bats", "dnf", "git"] [component-groups.check-skip-initial-failures.metadata] category = "azl-disable-flaky-tests" -# upstreamable omitted: upstreamability for this group has not been assessed yet. +upstream-status = "upstreamable" ``` ## Component Membership diff --git a/internal/projectconfig/loader_test.go b/internal/projectconfig/loader_test.go index b4677df7a..cd3dd0c3a 100644 --- a/internal/projectconfig/loader_test.go +++ b/internal/projectconfig/loader_test.go @@ -491,10 +491,10 @@ func TestLoadAndResolveProjectConfig_ComponentGroupWithMetadata(t *testing.T) { specs = ["SPECS/**/*.spec"] [component-groups.core.metadata] -category = "backport-dist-git" -commits = ["https://example.com/commit/abc"] +category = "upstream-backport" +commits = [{ url = "https://example.com/commit/abc" }] bugs = [{ url = "https://example.com/bug/1" }] -upstreamable = true +upstream-status = "upstreamable" ` ctx := testctx.NewCtx() @@ -506,11 +506,10 @@ upstreamable = true if assert.Contains(t, config.ComponentGroups, "core") { group := config.ComponentGroups["core"] require.NotNil(t, group.Metadata) - assert.Equal(t, OverlayCategoryBackportDistGit, group.Metadata.Category) - assert.Equal(t, []string{"https://example.com/commit/abc"}, group.Metadata.Commits) - assert.Equal(t, []BugRef{{URL: "https://example.com/bug/1"}}, group.Metadata.Bugs) - require.NotNil(t, group.Metadata.Upstreamable) - assert.True(t, *group.Metadata.Upstreamable) + assert.Equal(t, OverlayCategoryUpstreamBackport, group.Metadata.Category) + assert.Equal(t, []URLRef{{URL: "https://example.com/commit/abc"}}, group.Metadata.Commits) + assert.Equal(t, []URLRef{{URL: "https://example.com/bug/1"}}, group.Metadata.Bugs) + assert.Equal(t, OverlayUpstreamStatusUpstreamable, group.Metadata.UpstreamStatus) } } From 8925fdb90af70cafa99c297faa86ada2992192bc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:12:48 -0700 Subject: [PATCH 17/20] build(deps): bump the dependabot-gomod-minor group with 3 updates (#267) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 6 +++--- go.sum | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 42cf7525d..b76f31030 100644 --- a/go.mod +++ b/go.mod @@ -27,13 +27,13 @@ require ( github.com/google/uuid v1.6.0 github.com/h2non/gock v1.2.0 github.com/invopop/jsonschema v0.14.0 - github.com/jedib0t/go-pretty/v6 v6.8.1 + github.com/jedib0t/go-pretty/v6 v6.8.2 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 - github.com/klauspost/compress v1.18.6 + github.com/klauspost/compress v1.19.0 github.com/knqyf263/go-rpm-version v0.0.0-20240918084003-2afd7dc6a38f github.com/lmittmann/tint v1.1.3 github.com/magefile/mage v1.17.2 - github.com/mark3labs/mcp-go v0.55.0 + github.com/mark3labs/mcp-go v0.55.1 github.com/mattn/go-isatty v0.0.22 github.com/mitchellh/hashstructure/v2 v2.0.2 github.com/moby/moby/api v1.55.0 diff --git a/go.sum b/go.sum index 807421802..e727fcd48 100644 --- a/go.sum +++ b/go.sum @@ -179,14 +179,14 @@ github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8i github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= -github.com/jedib0t/go-pretty/v6 v6.8.1 h1:0fkCNhjrX0zPpwkWaDYU5VMrygg41Tu197mWILIJoqQ= -github.com/jedib0t/go-pretty/v6 v6.8.1/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= +github.com/jedib0t/go-pretty/v6 v6.8.2 h1:FmKNr1GOyot/zqNQplE8HLhFguJaeHJTCArntnI4uxE= +github.com/jedib0t/go-pretty/v6 v6.8.2/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= -github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= -github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= +github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/knqyf263/go-rpm-version v0.0.0-20240918084003-2afd7dc6a38f h1:xt29M2T6STgldg+WEP51gGePQCsQvklmP2eIhPIBK3g= @@ -211,8 +211,8 @@ github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40= github.com/magefile/mage v1.17.2/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mark3labs/mcp-go v0.55.0 h1:lJfz2aoctiwK+sI991+uIYwmKNIBciI+O7zsyDsa4U8= -github.com/mark3labs/mcp-go v0.55.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas= +github.com/mark3labs/mcp-go v0.55.1 h1:GLYqNm9qdMGPhCtK4g1t1y1vhAPfayOBuaibDi4mrSA= +github.com/mark3labs/mcp-go v0.55.1/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas= github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg= github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= From 4aa3d00592922445464593b39c27ebe6303a3459 Mon Sep 17 00:00:00 2001 From: Antonio Salinas Date: Mon, 13 Jul 2026 12:41:47 -0400 Subject: [PATCH 18/20] feat(source-files): generative source dependencies (#271) Co-authored-by: Antonio Salinas --- docs/user/reference/config/components.md | 8 +- .../cmds/component/history_customizations.go | 7 + .../cmds/component/history_internal_test.go | 3 +- .../app/azldev/core/sources/sourceprep.go | 21 +- .../azldev/core/sources/sourceprep_test.go | 5 + internal/projectconfig/component.go | 15 +- internal/projectconfig/configfile.go | 46 +++- internal/projectconfig/configfile_test.go | 92 ++++++++ .../sourceproviders/customsourceprovider.go | 198 ++++++++++++++++-- .../customsourceprovider_internal_test.go | 130 +++++++++++- .../sourceproviders/fedorasourceprovider.go | 6 +- .../fedorasourceprovider_test.go | 25 +-- .../sourceproviders/sourcemanager.go | 85 +++++--- .../sourcemanager_internal_test.go | 144 +++++++++++++ .../sourceproviders/sourcemanager_test.go | 28 --- ...ainer_config_generate-schema_stdout_1.snap | 8 + ...shots_config_generate-schema_stdout_1.snap | 8 + scenario/custom_source_test.go | 106 ++++++++++ schemas/azldev.schema.json | 8 + 19 files changed, 824 insertions(+), 119 deletions(-) create mode 100644 internal/providers/sourceproviders/sourcemanager_internal_test.go create mode 100644 scenario/custom_source_test.go diff --git a/docs/user/reference/config/components.md b/docs/user/reference/config/components.md index ccf70d22b..5ae822132 100644 --- a/docs/user/reference/config/components.md +++ b/docs/user/reference/config/components.md @@ -352,12 +352,15 @@ origin = { type = "download", uri = "https://example.com/repo/.../shimx64.efi Use `origin.type = "custom"` when a source archive must be assembled or modified (e.g. stripping sensitive test fixtures from an upstream tarball). azldev runs the script inside a fresh mock chroot — the script **must write all output to `/azldev-gen/output/`**, which azldev packages into the archive named by `filename`. Network access is always enabled; the mock config comes from the project's default distro. -The `script` and `mock-packages` fields are nested under `[origin]`: +Custom sources are regenerated on every source preparation rather than restored from lookaside. The generated archive is validated against its configured hash, so changes to the script or its inputs fail with a hash mismatch until the hash is intentionally refreshed. + +The `script`, `mock-packages`, and `inputs` fields are nested under `[origin]`: | Field | TOML Key | Type | Required | Description | |-------|----------|------|----------|-------------| | Script | `origin.script` | string | **Yes** | Script filename (relative to the component's spec dir) to run in mock. Required for `origin.type = "custom"`. | | Mock packages | `origin.mock-packages` | array of string | No | Extra RPM packages to install in the mock chroot before the script runs. | +| Inputs | `origin.inputs` | array of string | No | Unique filenames to make available in the mock chroot before the script runs. Each file must already be present in the fetched source output directory — upstream source tarballs, sidecar files (patches, scripts), and any earlier `source-files` entries are all placed there by the upstream fetch before custom scripts run. | On first use, omit `hash` and run `prep-sources --allow-no-hashes` to generate the archive and print its hash, then copy it into the TOML. @@ -369,8 +372,11 @@ hash = "abc123..." # from: prep-sources --allow-no-hashes origin.type = "custom" origin.script = "gen-yara-stripped.sh" # relative to the component's spec directory origin.mock-packages = ["cmake"] # omit if not needed +origin.inputs = ["yara-4.5.4.tar.gz"] # available to the script as ./yara-4.5.4.tar.gz ``` +> **Note:** Upstream source tarballs are automatically available as inputs before running custom generation scripts. There is no need to re-declare an upstream file in `source-files` to use it as an input. + ### Replacing an upstream `sources` entry A `source-files` entry whose `filename` collides with an upstream `sources` entry is an error by default. Set `replace-upstream = true` (with a non-empty `replace-reason`) to intentionally substitute it: diff --git a/internal/app/azldev/cmds/component/history_customizations.go b/internal/app/azldev/cmds/component/history_customizations.go index ad5de7061..1e7f5be7e 100644 --- a/internal/app/azldev/cmds/component/history_customizations.go +++ b/internal/app/azldev/cmds/component/history_customizations.go @@ -236,6 +236,13 @@ func appendSourceFileItems( Value: pkg, }) } + + for _, input := range sourceFile.Origin.Inputs { + items = append(items, CustomizationItem{ + Kind: "source-files.inputs", + Value: input, + }) + } } return items diff --git a/internal/app/azldev/cmds/component/history_internal_test.go b/internal/app/azldev/cmds/component/history_internal_test.go index 33e39a246..5a7a9e507 100644 --- a/internal/app/azldev/cmds/component/history_internal_test.go +++ b/internal/app/azldev/cmds/component/history_internal_test.go @@ -142,10 +142,11 @@ func TestCustomizationCollectorsCoverEveryFingerprintableField(t *testing.T) { "SourceFileReference.ReplaceUpstream": "source-files.replace-upstream", "SourceFileReference.Origin": "delegates to Origin walk", - // Origin -- Script and MockPackages are fingerprint-relevant; Type and Uri + // Origin -- Script, MockPackages, and Inputs are fingerprint-relevant; Type and Uri // are tagged fingerprint:"-" (download location, not build input). "Origin.Script": "source-files.script", "Origin.MockPackages": "source-files.mock-packages", + "Origin.Inputs": "source-files.inputs", } actualFields := make(map[string]bool) diff --git a/internal/app/azldev/core/sources/sourceprep.go b/internal/app/azldev/core/sources/sourceprep.go index 617dcbd81..c391ae611 100644 --- a/internal/app/azldev/core/sources/sourceprep.go +++ b/internal/app/azldev/core/sources/sourceprep.go @@ -215,17 +215,6 @@ func NewPreparer( func (p *sourcePreparerImpl) PrepareSources( ctx context.Context, component components.Component, outputDir string, applyOverlays bool, ) error { - // Use the source manager to fetch source files (archives, patches, etc.) - // Skip this step when skipLookaside is set — source tarballs are not needed - // for rendering and are the most expensive download. - if !p.skipLookaside { - err := p.sourceManager.FetchFiles(ctx, component, outputDir) - if err != nil { - return fmt.Errorf("failed to fetch source files for component %#q:\n%w", - component.GetName(), err) - } - } - // Preserve the upstream .git directory only when dist-git creation is // requested via --with-git. This is required so that overlay commits can be // appended on top of the upstream commit log during synthetic history generation. @@ -238,13 +227,21 @@ func (p *sourcePreparerImpl) PrepareSources( fetchOpts = append(fetchOpts, sourceproviders.WithSkipLookaside()) } - // Use the source manager to fetch the component (spec file and sidecar files). + // Fetch the component first (spec, sidecar files, and upstream source tarballs). err := p.sourceManager.FetchComponent(ctx, component, outputDir, fetchOpts...) if err != nil { return fmt.Errorf("failed to fetch sources for component %#q:\n%w", component.GetName(), err) } + // Fetch custom and downloaded source files. + if !p.skipLookaside { + if err := p.sourceManager.FetchFiles(ctx, component, outputDir); err != nil { + return fmt.Errorf("failed to fetch source files for component %#q:\n%w", + component.GetName(), err) + } + } + if applyOverlays { err := p.applyOverlaysToSources(ctx, component, outputDir) if err != nil { diff --git a/internal/app/azldev/core/sources/sourceprep_test.go b/internal/app/azldev/core/sources/sourceprep_test.go index 45a9d2868..a0a6f319a 100644 --- a/internal/app/azldev/core/sources/sourceprep_test.go +++ b/internal/app/azldev/core/sources/sourceprep_test.go @@ -108,6 +108,8 @@ func TestPrepareSources_SourceManagerError(t *testing.T) { expectedErr := errors.New("failed to fetch files") component.EXPECT().GetName().AnyTimes().Return("test-component") + component.EXPECT().GetConfig().AnyTimes().Return(&projectconfig.ComponentConfig{}) + sourceManager.EXPECT().FetchComponent(gomock.Any(), component, testOutputDir, gomock.Any()).Return(nil) sourceManager.EXPECT().FetchFiles(gomock.Any(), component, testOutputDir).Return(expectedErr) preparer, err := sources.NewPreparer(sourceManager, ctx.FS(), ctx, ctx) @@ -633,8 +635,11 @@ func TestDiffSources_FetchError(t *testing.T) { require.NoError(t, fileutils.MkdirAll(ctx.FS(), baseDir)) component.EXPECT().GetName().AnyTimes().Return("test-component") + component.EXPECT().GetConfig().AnyTimes().Return(&projectconfig.ComponentConfig{}) expectedErr := errors.New("network failure") + + sourceManager.EXPECT().FetchComponent(gomock.Any(), component, gomock.Any(), gomock.Any()).Return(nil) sourceManager.EXPECT().FetchFiles(gomock.Any(), component, gomock.Any()).Return(expectedErr) preparer, err := sources.NewPreparer(sourceManager, ctx.FS(), ctx, ctx) diff --git a/internal/projectconfig/component.go b/internal/projectconfig/component.go index db889c065..180656824 100644 --- a/internal/projectconfig/component.go +++ b/internal/projectconfig/component.go @@ -66,17 +66,24 @@ type Origin struct { // MockPackages is a list of RPM package names to install in the mock chroot before // running [Origin.Script]. Only valid when [Origin.Type] is 'custom'. MockPackages []string `toml:"mock-packages,omitempty" json:"mockPackages,omitempty" jsonschema:"title=Mock packages,description=RPM packages to install in the mock chroot before running the generation script. Only valid when origin type is 'custom'."` + + // Inputs is a list of source-output filenames to copy next to [Origin.Script] + // before it runs. Each entry must be a plain filename. Only valid when + // [Origin.Type] is 'custom'. + Inputs []string `toml:"inputs,omitempty" json:"inputs,omitempty" jsonschema:"title=Inputs,description=Source-output filenames to make available next to the generation script before it runs. Only valid when origin type is 'custom'."` } // HashInclude implements the hashstructure [Includable] interface so that -// [Origin.Script] and [Origin.MockPackages] are omitted from the component -// fingerprint when they hold their zero values. +// [Origin.Script], [Origin.MockPackages], and [Origin.Inputs] are omitted from +// the component fingerprint when they hold their zero values. func (o Origin) HashInclude(field string, _ any) (bool, error) { switch field { case "Script": return o.Script != "", nil case "MockPackages": return len(o.MockPackages) > 0, nil + case "Inputs": + return len(o.Inputs) > 0, nil } return true, nil @@ -114,10 +121,10 @@ type SourceFileReference struct { // HashInclude implements the hashstructure [Includable] interface so that // [SourceFileReference.Origin] is omitted from the component fingerprint when -// neither [Origin.Script] nor [Origin.MockPackages] are set. +// none of [Origin.Script], [Origin.MockPackages], or [Origin.Inputs] are set. func (r SourceFileReference) HashInclude(field string, _ any) (bool, error) { if field == "Origin" { - return r.Origin.Script != "" || len(r.Origin.MockPackages) > 0, nil + return r.Origin.Script != "" || len(r.Origin.MockPackages) > 0 || len(r.Origin.Inputs) > 0, nil } return true, nil diff --git a/internal/projectconfig/configfile.go b/internal/projectconfig/configfile.go index fff9c1d9d..1e1ee1d15 100644 --- a/internal/projectconfig/configfile.go +++ b/internal/projectconfig/configfile.go @@ -449,11 +449,14 @@ func validateReplaceUpstream(ref SourceFileReference, componentName string) erro return nil } -// validateCustomSourceRef enforces the pairing rules for the 'script' and 'mock-packages' -// fields on a [SourceFileReference]: +// validateCustomSourceRef enforces the pairing rules for the 'script', 'mock-packages', +// and 'inputs' fields on a [SourceFileReference]: // - 'script' is required when 'origin.type' is 'custom'. // - 'script' must be empty when 'origin.type' is not 'custom'. // - 'mock-packages' must be empty when 'origin.type' is not 'custom'. +// - 'inputs' must be empty when 'origin.type' is not 'custom'. +// - each 'inputs' entry must be a valid filename (no path separators). +// - each 'inputs' entry must be unique. func validateCustomSourceRef(ref SourceFileReference, componentName string) error { if ref.Origin.Type == OriginTypeCustom { if ref.Origin.Script == "" { @@ -469,6 +472,10 @@ func validateCustomSourceRef(ref SourceFileReference, componentName string) erro ref.Origin.Script, ref.Filename, componentName, err) } + if err := validateCustomSourceInputs(ref, componentName); err != nil { + return err + } + return nil } @@ -486,6 +493,41 @@ func validateCustomSourceRef(ref SourceFileReference, componentName string) erro ref.Filename, componentName, string(ref.Origin.Type)) } + if len(ref.Origin.Inputs) > 0 { + return fmt.Errorf( + "source file %#q in component %#q has 'inputs' set but origin type is %#q; "+ + "'inputs' is only valid when origin type is 'custom'", + ref.Filename, componentName, string(ref.Origin.Type)) + } + + return nil +} + +func validateCustomSourceInputs(ref SourceFileReference, componentName string) error { + seen := make(map[string]bool, len(ref.Origin.Inputs)) + + for _, input := range ref.Origin.Inputs { + if err := fileutils.ValidateFilename(input); err != nil { + return fmt.Errorf( + "invalid 'inputs' entry %#q for source file %#q in component %#q:\n%w", + input, ref.Filename, componentName, err) + } + + if seen[input] { + return fmt.Errorf( + "duplicate 'inputs' entry %#q for source file %#q in component %#q; each input filename must be unique", + input, ref.Filename, componentName) + } + + seen[input] = true + + if input == ref.Origin.Script { + return fmt.Errorf( + "'inputs' entry %#q for source file %#q in component %#q conflicts with 'script' filename", + input, ref.Filename, componentName) + } + } + return nil } diff --git a/internal/projectconfig/configfile_test.go b/internal/projectconfig/configfile_test.go index 21c7db578..9e7c12b1c 100644 --- a/internal/projectconfig/configfile_test.go +++ b/internal/projectconfig/configfile_test.go @@ -859,3 +859,95 @@ func TestValidateCustomSourceRef_InvalidScriptFilename(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "script") } + +func TestValidateCustomSourceRef_ValidInputs(t *testing.T) { + file := projectconfig.ConfigFile{ + Components: map[string]projectconfig.ComponentConfig{ + "comp": { + SourceFiles: []projectconfig.SourceFileReference{ + { + Filename: "gen.tar.gz", + Origin: projectconfig.Origin{ + Type: projectconfig.OriginTypeCustom, + Script: "gen.sh", + Inputs: []string{"upstream.tar.gz", "fix.patch"}, + }, + }, + }, + }, + }, + } + assert.NoError(t, file.Validate()) +} + +func TestValidateCustomSourceRef_DuplicateInputs(t *testing.T) { + file := projectconfig.ConfigFile{ + Components: map[string]projectconfig.ComponentConfig{ + "comp": { + SourceFiles: []projectconfig.SourceFileReference{ + { + Filename: "gen.tar.gz", + Origin: projectconfig.Origin{ + Type: projectconfig.OriginTypeCustom, + Script: "gen.sh", + Inputs: []string{"upstream.tar.gz", "upstream.tar.gz"}, + }, + }, + }, + }, + }, + } + + err := file.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate 'inputs' entry") + assert.Contains(t, err.Error(), "upstream.tar.gz") + assert.Contains(t, err.Error(), "gen.tar.gz") + assert.Contains(t, err.Error(), "comp") +} + +func TestValidateCustomSourceRef_InputsOnDownloadOrigin(t *testing.T) { + file := projectconfig.ConfigFile{ + Components: map[string]projectconfig.ComponentConfig{ + "comp": { + SourceFiles: []projectconfig.SourceFileReference{ + { + Filename: "src.tar.gz", + Origin: projectconfig.Origin{ + Type: projectconfig.OriginTypeURI, + Uri: "https://example.com/src.tar.gz", + Inputs: []string{"other.tar.gz"}, + }, + }, + }, + }, + }, + } + err := file.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "'inputs'") + assert.Contains(t, err.Error(), "custom") +} + +func TestValidateCustomSourceRef_InvalidInputFilename(t *testing.T) { + file := projectconfig.ConfigFile{ + Components: map[string]projectconfig.ComponentConfig{ + "comp": { + SourceFiles: []projectconfig.SourceFileReference{ + { + Filename: "gen.tar.gz", + Origin: projectconfig.Origin{ + Type: projectconfig.OriginTypeCustom, + Script: "gen.sh", + Inputs: []string{"../escape.tar.gz"}, // path traversal attempt + }, + }, + }, + }, + }, + } + err := file.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "'inputs'") + assert.Contains(t, err.Error(), "escape.tar.gz") +} diff --git a/internal/providers/sourceproviders/customsourceprovider.go b/internal/providers/sourceproviders/customsourceprovider.go index 0f2efcfbe..f60c7622b 100644 --- a/internal/providers/sourceproviders/customsourceprovider.go +++ b/internal/providers/sourceproviders/customsourceprovider.go @@ -6,8 +6,11 @@ package sourceproviders import ( "context" "fmt" + "io" "log/slog" + "os" "path/filepath" + "strings" "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/components" "github.com/microsoft/azure-linux-dev-tools/internal/global/opctx" @@ -16,6 +19,7 @@ import ( "github.com/microsoft/azure-linux-dev-tools/internal/utils/archive" "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileperms" "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils" + "github.com/spf13/afero" ) // customGenScriptDir is the path inside the mock chroot where the generation script @@ -26,12 +30,19 @@ const customGenScriptDir = "/azldev-gen/script" // must write its output. const customGenOutputDir = "/azldev-gen/output" +// maxCustomScriptOutputBytes limits the retained stdout and stderr from a custom +// generation script. The tail is retained because failures are typically reported +// near the end of command output. +const maxCustomScriptOutputBytes = 64 * 1024 + // customFileSourceProvider implements [FileSourceProvider] for source files with // [projectconfig.OriginTypeCustom]. It executes a user-supplied script inside a // fresh mock chroot and packages the output directory as a deterministic archive. type customFileSourceProvider struct { - fs opctx.FS - runner *mock.Runner + dryRunnable opctx.DryRunnable + fs opctx.FS + runner *mock.Runner + verbose bool } var _ FileSourceProvider = (*customFileSourceProvider)(nil) @@ -51,7 +62,7 @@ func (p *customFileSourceProvider) GetFile( destPath := filepath.Join(destDirPath, ref.Filename) - return generateCustomSourceFile(ctx, p.fs, p.runner, component, &ref, destPath) + return generateCustomSourceFile(ctx, p.dryRunnable, p.fs, p.runner, p.verbose, component, &ref, destPath) } // generateCustomSourceFile runs a single [projectconfig.SourceFileReference] through a @@ -61,8 +72,10 @@ func (p *customFileSourceProvider) GetFile( // is created for each call so bind mounts and package installs do not bleed across invocations. func generateCustomSourceFile( ctx context.Context, + dryRunnable opctx.DryRunnable, fs opctx.FS, baseRunner *mock.Runner, + verbose bool, component components.Component, ref *projectconfig.SourceFileReference, destPath string, @@ -72,6 +85,12 @@ func generateCustomSourceFile( "script", ref.Origin.Script, "component", component.GetName()) + if dryRunnable.DryRun() { + slog.Info("Dry run; skipping custom source file generation", "filename", ref.Filename) + + return nil + } + specDir, err := resolveComponentSpecDir(component) if err != nil { return fmt.Errorf("failed to resolve spec directory for component %#q:\n%w", @@ -93,6 +112,19 @@ func generateCustomSourceFile( defer cleanup() + // Stage declared input files next to the script so script authors can refer to + // them by filename without knowing azldev's internal mount paths. + destDirPath := filepath.Dir(destPath) + + if len(ref.Origin.Inputs) > 0 { + if inputsErr := stageInputFiles( + dryRunnable, fs, ref.Origin.Inputs, destDirPath, scriptTmpDir, ref.Origin.Script, + ); inputsErr != nil { + return fmt.Errorf("failed to resolve inputs for custom source %#q:\n%w", + ref.Filename, inputsErr) + } + } + // Package the output directory as a deterministic archive whose format is // inferred from the filename extension (e.g., .tar.gz, .tar.xz). comp, compErr := archive.DetectCompression(ref.Filename) @@ -104,18 +136,13 @@ func generateCustomSourceFile( // Clone the base runner so bind mounts added here don't persist to other calls. // Network access is always enabled — custom source scripts commonly need to // download upstream tarballs or toolchain artifacts. - runner := baseRunner.Clone() - runner.EnableNetwork() - runner.WithUnprivileged() - runner.AddBindMount(scriptTmpDir, customGenScriptDir) - runner.AddBindMount(genOutputTmpDir, customGenOutputDir) + runner := buildCustomRunner(baseRunner, scriptTmpDir, genOutputTmpDir) - if err := execScriptInChroot(ctx, runner, ref); err != nil { + if err := execScriptInChroot(ctx, runner, verbose, ref); err != nil { return err } - // Ensure the destination directory exists. FetchFiles runs before FetchComponent, - // so the output directory may not have been created yet when this is called. + // Ensure the destination directory exists before writing the archive. if mkdirErr := fileutils.MkdirAll(fs, filepath.Dir(destPath)); mkdirErr != nil { return fmt.Errorf("failed to create destination directory for %#q:\n%w", ref.Filename, mkdirErr) @@ -207,11 +234,25 @@ func prepareStagingDirs( return scriptDir, outputDir, cleanup, nil } +// buildCustomRunner returns a clone of baseRunner configured with the standard bind mounts +// for custom source generation. Network access is always enabled; the unprivileged flag is +// set so the script runs as the 'mockbuild' user rather than root. +func buildCustomRunner(baseRunner *mock.Runner, scriptTmpDir, genOutputTmpDir string) *mock.Runner { + runner := baseRunner.Clone() + runner.EnableNetwork() + runner.WithUnprivileged() + runner.AddBindMount(scriptTmpDir, customGenScriptDir) + runner.AddBindMount(genOutputTmpDir, customGenOutputDir) + + return runner +} + // execScriptInChroot initialises a fresh mock root, optionally installs extra packages, // runs the generation script, and scrubs the root on return. func execScriptInChroot( ctx context.Context, runner *mock.Runner, + verbose bool, ref *projectconfig.SourceFileReference, ) error { if initErr := runner.InitRoot(ctx); initErr != nil { @@ -235,17 +276,142 @@ func execScriptInChroot( } } - scriptChrootPath := filepath.Join(customGenScriptDir, ref.Origin.Script) - - cmd, cmdErr := runner.CmdInChroot(ctx, []string{scriptChrootPath}, false /* interactive */) + // Use positional parameters so the script name is never re-parsed as shell code + // ($1=scriptDir, $2=scriptName; '--' sets $0 and keeps bash from consuming them). + cmd, cmdErr := runner.CmdInChroot(ctx, []string{ + "sh", "-c", `cd "$1" && ./"$2"`, "--", customGenScriptDir, ref.Origin.Script, + }, false /* interactive */) if cmdErr != nil { return fmt.Errorf("failed to create chroot command for generating %#q:\n%w", ref.Filename, cmdErr) } + stdout := newOutputTail(maxCustomScriptOutputBytes) + stderr := newOutputTail(maxCustomScriptOutputBytes) + + if verbose { + cmd.SetStdout(io.MultiWriter(os.Stdout, stdout)) + cmd.SetStderr(io.MultiWriter(os.Stderr, stderr)) + } else { + cmd.SetStdout(stdout) + cmd.SetStderr(stderr) + } + if runErr := cmd.Run(ctx); runErr != nil { - return fmt.Errorf("generation script %#q failed for source %#q:\n%w", - ref.Origin.Script, ref.Filename, runErr) + scriptOutput := formatCustomScriptOutput(stdout.String(), stderr.String()) + + return fmt.Errorf("generation script %#q failed for source %#q%s\n%w", + ref.Origin.Script, ref.Filename, scriptOutput, runErr) + } + + return nil +} + +// outputTail retains the last limit bytes written to it. It implements [io.Writer] +// so command output can be captured without unbounded memory growth. +type outputTail struct { + buf []byte + limit int + truncated bool +} + +func newOutputTail(limit int) *outputTail { + return &outputTail{buf: make([]byte, 0, limit), limit: limit} +} + +func (b *outputTail) Write(data []byte) (n int, err error) { + n = len(data) + if n >= b.limit { + b.truncated = b.truncated || n > b.limit || len(b.buf) > 0 + b.buf = append(b.buf[:0], data[n-b.limit:]...) + + return n, nil + } + + if overflow := len(b.buf) + n - b.limit; overflow > 0 { + b.buf = append(b.buf[:0], b.buf[overflow:]...) + b.truncated = true + } + + b.buf = append(b.buf, data...) + + return n, nil +} + +func (b *outputTail) String() string { + if b.truncated { + return fmt.Sprintf("[output truncated; showing last %d bytes]\n%s", b.limit, b.buf) + } + + return string(b.buf) +} + +func formatCustomScriptOutput(stdout, stderr string) string { + var output strings.Builder + + if stdout != "" { + fmt.Fprintf(&output, "\nstdout:\n%s", stdout) + } + + if stderr != "" { + fmt.Fprintf(&output, "\nstderr:\n%s", stderr) + } + + return output.String() +} + +// stageInputFiles copies the files listed in inputs next to the generation script. +// +// All inputs must already be present in destDirPath, which contains the full set of upstream +// source tarballs, sidecar files, and any earlier 'source-files' entries fetched by +// [FetchComponent] and prior [FetchFiles] calls. If a filename is absent, an error is returned. +func stageInputFiles( + dryRunnable opctx.DryRunnable, + fs opctx.FS, + inputs []string, + destDirPath, scriptTmpDir, scriptName string, +) error { + for _, filename := range inputs { + if err := fileutils.ValidateFilename(filename); err != nil { + return fmt.Errorf("invalid input filename %#q:\n%w", filename, err) + } + + if filename == scriptName { + return fmt.Errorf("input file %#q conflicts with generation script filename", filename) + } + + sourcePath := filepath.Join(destDirPath, filename) + + if lstater, ok := fs.(afero.Lstater); ok { + fileInfo, lstatCalled, lstatErr := lstater.LstatIfPossible(sourcePath) + if lstatErr != nil { + return fmt.Errorf("failed to inspect input file %#q:\n%w", filename, lstatErr) + } + + if lstatCalled && fileInfo.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("input file %#q must not be a symbolic link", filename) + } + } + + exists, statErr := fileutils.Exists(fs, sourcePath) + if statErr != nil { + return fmt.Errorf("failed to stat input file %#q:\n%w", filename, statErr) + } + + if !exists { + return fmt.Errorf( + "input file %#q not found in output directory %#q", + filename, destDirPath) + } + + stagedPath := filepath.Join(scriptTmpDir, filename) + + if writeErr := fileutils.CopyFile( + dryRunnable, fs, sourcePath, stagedPath, + fileutils.CopyFileOptions{PreserveFileMode: true}, + ); writeErr != nil { + return fmt.Errorf("failed to stage input file %#q:\n%w", filename, writeErr) + } } return nil diff --git a/internal/providers/sourceproviders/customsourceprovider_internal_test.go b/internal/providers/sourceproviders/customsourceprovider_internal_test.go index e51ba6488..547d66e51 100644 --- a/internal/providers/sourceproviders/customsourceprovider_internal_test.go +++ b/internal/providers/sourceproviders/customsourceprovider_internal_test.go @@ -5,10 +5,12 @@ package sourceproviders import ( "context" + "os" "path/filepath" "testing" "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/components/components_testutils" + "github.com/microsoft/azure-linux-dev-tools/internal/global/testctx" "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileperms" "github.com/spf13/afero" @@ -18,9 +20,12 @@ import ( ) func TestCustomFileSourceProvider_GetFile_NonCustomOriginReturnsNotFound(t *testing.T) { + ctx := testctx.NewCtx() + provider := &customFileSourceProvider{ - fs: afero.NewMemMapFs(), - runner: nil, // never reached + dryRunnable: ctx, + fs: ctx.FS(), + runner: nil, // never reached } ctrl := gomock.NewController(t) @@ -35,10 +40,55 @@ func TestCustomFileSourceProvider_GetFile_NonCustomOriginReturnsNotFound(t *test assert.ErrorIs(t, err, ErrNotFound) } +func TestOutputTail(t *testing.T) { + tests := []struct { + name string + limit int + writes []string + expected string + }{ + { + name: "retains complete output within limit", + limit: 5, + writes: []string{"abc", "de"}, + expected: "abcde", + }, + { + name: "retains tail after multiple writes", + limit: 5, + writes: []string{"abc", "defg"}, + expected: "[output truncated; showing last 5 bytes]\ncdefg", + }, + { + name: "retains tail of a single oversized write", + limit: 4, + writes: []string{"abcdef"}, + expected: "[output truncated; showing last 4 bytes]\ncdef", + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + tail := newOutputTail(testCase.limit) + + for _, write := range testCase.writes { + written, err := tail.Write([]byte(write)) + require.NoError(t, err) + assert.Equal(t, len(write), written) + } + + assert.Equal(t, testCase.expected, tail.String()) + }) + } +} + func TestCustomFileSourceProvider_GetFile_MissingScriptReturnsError(t *testing.T) { + ctx := testctx.NewCtx() + provider := &customFileSourceProvider{ - fs: afero.NewMemMapFs(), - runner: nil, // never reached — script stat check fails first + dryRunnable: ctx, + fs: ctx.FS(), + runner: nil, // never reached — script stat check fails first } ctrl := gomock.NewController(t) @@ -123,3 +173,75 @@ func TestPrepareStagingDirs_MissingScriptReturnsError(t *testing.T) { assert.Contains(t, err.Error(), "missing.sh") assert.Nil(t, cleanup) } + +func TestStageInputFiles_FoundInDestDirPreservesMode(t *testing.T) { + ctx := testctx.NewCtx() + memFS := ctx.FS() + + const fileContent = "upstream tarball data" + + require.NoError(t, afero.WriteFile(memFS, "/output/upstream.tar.gz", []byte(fileContent), fileperms.PublicExecutable)) + + err := stageInputFiles(ctx, memFS, []string{"upstream.tar.gz"}, "/output", "/script", "gen.sh") + require.NoError(t, err) + + data, readErr := afero.ReadFile(memFS, "/script/upstream.tar.gz") + require.NoError(t, readErr) + assert.Equal(t, fileContent, string(data)) + + info, statErr := memFS.Stat("/script/upstream.tar.gz") + require.NoError(t, statErr) + assert.Equal(t, fileperms.PublicExecutable, info.Mode().Perm()) +} + +func TestStageInputFiles_NotFoundReturnsError(t *testing.T) { + ctx := testctx.NewCtx() + memFS := ctx.FS() + + // No files written — destDirPath is empty. + err := stageInputFiles(ctx, memFS, []string{"missing.tar.gz"}, "/output", "/script", "gen.sh") + require.Error(t, err) + assert.Contains(t, err.Error(), "missing.tar.gz") + assert.Contains(t, err.Error(), "/output") +} + +func TestStageInputFiles_SymlinkRejected(t *testing.T) { + fileSystem := afero.NewOsFs() + dir := t.TempDir() + targetPath := filepath.Join(dir, "target.tar.gz") + inputPath := filepath.Join(dir, "input.tar.gz") + + require.NoError(t, os.WriteFile(targetPath, []byte("input"), fileperms.PublicFile)) + require.NoError(t, os.Symlink(targetPath, inputPath)) + + err := stageInputFiles( + testctx.NewCtx(), fileSystem, []string{"input.tar.gz"}, dir, filepath.Join(dir, "script"), "gen.sh") + require.Error(t, err) + assert.Contains(t, err.Error(), "input.tar.gz") + assert.Contains(t, err.Error(), "symbolic link") +} + +func TestStageInputFiles_ScriptNameConflictReturnsError(t *testing.T) { + ctx := testctx.NewCtx() + memFS := ctx.FS() + + err := stageInputFiles(ctx, memFS, []string{"gen.sh"}, "/output", "/script", "gen.sh") + require.Error(t, err) + assert.Contains(t, err.Error(), "conflicts") +} + +func TestStageInputFiles_InvalidFilenameReturnsError(t *testing.T) { + ctx := testctx.NewCtx() + memFS := ctx.FS() + + err := stageInputFiles(ctx, memFS, []string{"../escape.tar.gz"}, "/output", "/script", "gen.sh") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid input filename") +} + +func TestFormatCustomScriptOutput(t *testing.T) { + output := formatCustomScriptOutput("stdout line\n", "stderr line\n") + + assert.Contains(t, output, "stdout:\nstdout line") + assert.Contains(t, output, "stderr:\nstderr line") +} diff --git a/internal/providers/sourceproviders/fedorasourceprovider.go b/internal/providers/sourceproviders/fedorasourceprovider.go index 344e90a9b..688c66e25 100644 --- a/internal/providers/sourceproviders/fedorasourceprovider.go +++ b/internal/providers/sourceproviders/fedorasourceprovider.go @@ -141,7 +141,7 @@ func (g *FedoraSourcesProviderImpl) GetComponent( } // Collect filenames from source-files config so the lookaside extractor can skip them. - // These files were already fetched by FetchFiles and take precedence over upstream versions. + // [SourceManager.FetchFiles] acquires the configured versions after component fetching. sourceFiles := component.GetConfig().SourceFiles skipFileNames := make([]string, len(sourceFiles)) @@ -178,7 +178,8 @@ func (g *FedoraSourcesProviderImpl) processClonedRepo( } // Extract sources from repo (downloads lookaside files into the temp dir). - // Files in skipFilenames are not downloaded — they were already fetched by FetchFiles. + // Files in skipFilenames are not downloaded because [SourceManager.FetchFiles] + // provides the configured versions after component fetching. // Skip this step entirely when SkipLookaside is set (e.g., during rendering). if !opts.SkipLookaside { err := g.downloader.ExtractSourcesFromRepo( @@ -195,7 +196,6 @@ func (g *FedoraSourcesProviderImpl) processClonedRepo( } // Copy files from temp dir to destination, skipping files that already exist. - // This preserves any files downloaded by FetchFiles, giving them precedence. copyOptions := fileutils.CopyDirOptions{ CopyFileOptions: fileutils.CopyFileOptions{ PreserveFileMode: true, diff --git a/internal/providers/sourceproviders/fedorasourceprovider_test.go b/internal/providers/sourceproviders/fedorasourceprovider_test.go index 0d45e8fe4..13968bda1 100644 --- a/internal/providers/sourceproviders/fedorasourceprovider_test.go +++ b/internal/providers/sourceproviders/fedorasourceprovider_test.go @@ -220,7 +220,7 @@ func TestGetComponentFromGit(t *testing.T) { assert.Equal(t, "tarball content", string(tarballContent)) }) - t.Run("existing file in destination skips lookaside download", func(t *testing.T) { + t.Run("configured replacement skips upstream lookaside download", func(t *testing.T) { ctrl := gomock.NewController(t) httpDownloader, err := downloader.NewHTTPDownloader( @@ -288,26 +288,23 @@ func TestGetComponentFromGit(t *testing.T) { mockComponent.EXPECT().GetConfig().AnyTimes().Return(&projectconfig.ComponentConfig{ Name: testPackageName, SourceFiles: []projectconfig.SourceFileReference{ - {Filename: testFileName}, + { + Filename: testFileName, + ReplaceUpstream: true, + ReplaceReason: "use configured replacement", + }, }, }) - // Pre-populate destination with the file — simulating a prior FetchFiles download - err = fileutils.MkdirAll(env.FS(), testDestDir) - require.NoError(t, err) - - preExistingContent := []byte("azure-linux-signed-binary") - err = fileutils.WriteFile(env.FS(), testDestDir+"/"+testFileName, preExistingContent, fileperms.PublicFile) - require.NoError(t, err) - - // Should succeed — the file is in skipFilenames so the 404 lookaside download is skipped + // The upstream hash would return 404 from the test server. Success proves the + // configured filename suppressed that upstream lookaside download. err = provider.GetComponent(context.Background(), mockComponent, testDestDir) require.NoError(t, err) - // Verify the pre-existing file was preserved (not overwritten by git repo version) - content, err := fileutils.ReadFile(env.FS(), testDestDir+"/"+testFileName) + // The skipped upstream lookaside artifact was not materialized. + exists, err := fileutils.Exists(env.FS(), testDestDir+"/"+testFileName) require.NoError(t, err) - assert.Equal(t, preExistingContent, content) + assert.False(t, exists) }) t.Run("successful extraction without lookaside sources", func(t *testing.T) { diff --git a/internal/providers/sourceproviders/sourcemanager.go b/internal/providers/sourceproviders/sourcemanager.go index 4731c61c9..209417091 100644 --- a/internal/providers/sourceproviders/sourcemanager.go +++ b/internal/providers/sourceproviders/sourcemanager.go @@ -315,8 +315,10 @@ func (m *sourceManager) createFileProviders(env *azldev.Env) { } m.fileProviders = append(m.fileProviders, &customFileSourceProvider{ - fs: m.fs, - runner: mock.NewRunner(env, mockConfigPath), + dryRunnable: m.dryRunnable, + fs: m.fs, + runner: mock.NewRunner(env, mockConfigPath), + verbose: env.Verbose(), }) slog.Debug("Registered custom file source provider", "mockConfig", mockConfigPath) @@ -366,13 +368,13 @@ func (m *sourceManager) FetchFiles( } // fetchSourceFile acquires a single source file using the following priority order: -// 1. Lookaside cache — if hash info is available, attempt a cached download first. -// This applies to all origin types, including 'custom', so a previously generated -// and cached archive avoids a full mock regeneration. -// 2. File providers — handles origin types that require local generation (e.g. 'custom'). -// 3. Configured download origin — final fallback for 'download' origin types. +// 1. Custom file provider — custom sources are always regenerated so their configured +// hashes validate the current script and inputs. +// 2. Lookaside cache — non-custom files with hash info attempt a cached download first. +// 3. File providers — handles other provider-supported origin types. +// 4. Configured download origin — final fallback for 'download' origin types. // -// When disable-origins is set, step 3 is skipped and only lookaside and file providers apply. +// When disable-origins is set, step 4 is skipped and only lookaside and file providers apply. func (m *sourceManager) fetchSourceFile( ctx context.Context, httpDownloader downloader.Downloader, @@ -387,34 +389,12 @@ func (m *sourceManager) fetchSourceFile( destPath := filepath.Join(destDirPath, fileRef.Filename) - sourceExists, err := fileutils.Exists(m.fs, destPath) - if err != nil { - return fmt.Errorf("failed to check existence of destination file %#q:\n%w", destPath, err) - } - - if sourceExists { - slog.Debug("Source file already exists, skipping download", - "filename", fileRef.Filename, - "path", destPath) - + // Try the lookaside cache first for non-custom files if hash info is available. + // Custom files are always regenerated so stale configured hashes are detected. + if m.trySourceFileLookaside(ctx, httpDownloader, component, fileRef, destPath) { return nil } - // Try the lookaside cache first if hash info is available. This applies to - // all origin types, including 'custom' — if the generated archive is already - // cached in the lookaside (as it will be after the first run), we skip the - // expensive mock generation entirely. - if fileRef.Hash != "" && fileRef.HashType != "" { - lookasideErr := m.tryLookasideDownload(ctx, httpDownloader, component, fileRef, destPath) - if lookasideErr == nil { - return nil - } - - slog.Debug("Lookaside cache download failed", - "filename", fileRef.Filename, - "error", lookasideErr) - } - // Try each registered file provider. Providers return [ErrNotFound] to signal // they don't handle this reference; any other error is fatal. for _, provider := range m.fileProviders { @@ -452,6 +432,32 @@ func (m *sourceManager) fetchSourceFile( return m.fetchFromDownloadOrigin(ctx, httpDownloader, fileRef, destPath) } +// trySourceFileLookaside attempts a cached download for non-custom files with hash information. +// Custom files deliberately bypass lookaside so they are regenerated and validated each time. +func (m *sourceManager) trySourceFileLookaside( + ctx context.Context, + httpDownloader downloader.Downloader, + component components.Component, + fileRef *projectconfig.SourceFileReference, + destPath string, +) bool { + if fileRef.Origin.Type == projectconfig.OriginTypeCustom || + fileRef.Hash == "" || fileRef.HashType == "" { + return false + } + + lookasideErr := m.tryLookasideDownload(ctx, httpDownloader, component, fileRef, destPath) + if lookasideErr == nil { + return true + } + + slog.Debug("Lookaside cache download failed", + "filename", fileRef.Filename, + "error", lookasideErr) + + return false +} + // tryLookasideDownload attempts to download a source file from the lookaside cache. // Returns nil on success, or an error if the download fails. func (m *sourceManager) tryLookasideDownload( @@ -686,7 +692,18 @@ func (m *sourceManager) downloadLookasideSources( packageName := resolvePackageName(component) - err := m.lookasideDownloader.ExtractSourcesFromRepo(ctx, destDirPath, packageName, m.lookasideBaseURI, nil) + // Collect filenames from 'source-files' config so the lookaside extractor skips them. + // These files are managed by FetchFiles (custom generation or explicit download origins) + // and must not be overwritten by a same-named upstream lookaside entry. This mirrors + // the same skip-list built in [FedoraSourcesProviderImpl.GetComponent]. + sourceFiles := component.GetConfig().SourceFiles + + skipFilenames := make([]string, len(sourceFiles)) + for i := range sourceFiles { + skipFilenames[i] = sourceFiles[i].Filename + } + + err := m.lookasideDownloader.ExtractSourcesFromRepo(ctx, destDirPath, packageName, m.lookasideBaseURI, skipFilenames) if err != nil { return fmt.Errorf("failed to extract sources from lookaside cache:\n%w", err) } diff --git a/internal/providers/sourceproviders/sourcemanager_internal_test.go b/internal/providers/sourceproviders/sourcemanager_internal_test.go new file mode 100644 index 000000000..3abcf66bf --- /dev/null +++ b/internal/providers/sourceproviders/sourcemanager_internal_test.go @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package sourceproviders + +import ( + "context" + "path/filepath" + "testing" + + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/components" + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/components/components_testutils" + "github.com/microsoft/azure-linux-dev-tools/internal/global/opctx" + "github.com/microsoft/azure-linux-dev-tools/internal/global/testctx" + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/downloader/downloader_test" + "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/retry" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +type regeneratingFileProvider struct { + fs opctx.FS + called bool +} + +func (p *regeneratingFileProvider) GetFile( + _ context.Context, + _ components.Component, + ref projectconfig.SourceFileReference, + destDirPath string, +) error { + p.called = true + + return fileutils.WriteFile(p.fs, filepath.Join(destDirPath, ref.Filename), nil, fileperms.PublicFile) +} + +func TestFetchSourceFile_ConfiguredSourceReplacesExistingFile(t *testing.T) { + const ( + destDir = "/output" + filename = "source.tar.gz" + // SHA-256 of the empty replacement produced by each test acquisition path. + emptyHash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ) + + tests := []struct { + name string + origin projectconfig.Origin + useProvider bool + downloadURL string + hash string + expectError string + }{ + { + name: "custom source is always regenerated", + origin: projectconfig.Origin{Type: projectconfig.OriginTypeCustom}, + useProvider: true, + hash: emptyHash, + }, + { + name: "custom source with wrong hash fails validation", + origin: projectconfig.Origin{Type: projectconfig.OriginTypeCustom}, + useProvider: true, + hash: "0000000000000000000000000000000000000000000000000000000000000000", + expectError: "hash validation failed", + }, + { + name: "download source replaces upstream file", + origin: projectconfig.Origin{ + Type: projectconfig.OriginTypeURI, + Uri: "https://origin.example.com/source.tar.gz", + }, + downloadURL: "https://example.com/test-component/source.tar.gz/sha256/" + + emptyHash + "/source.tar.gz", + hash: emptyHash, + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + ctx := testctx.NewCtx() + ctrl := gomock.NewController(t) + component := components_testutils.NewMockComponent(ctrl) + component.EXPECT().GetName().AnyTimes().Return("test-component") + component.EXPECT().GetConfig().AnyTimes().Return(&projectconfig.ComponentConfig{}) + + httpDownloader := downloader_test.NewMockDownloader(ctrl) + if testCase.downloadURL != "" { + httpDownloader.EXPECT(). + Download(gomock.Any(), testCase.downloadURL, filepath.Join(destDir, filename)). + DoAndReturn(func(_ context.Context, _, destPath string) error { + return fileutils.WriteFile(ctx.FS(), destPath, nil, fileperms.PublicFile) + }) + } + + provider := ®eneratingFileProvider{fs: ctx.FS()} + + var fileProviders []FileSourceProvider + + if testCase.useProvider { + fileProviders = []FileSourceProvider{provider} + } + + manager := &sourceManager{ + dryRunnable: ctx, + fs: ctx.FS(), + lookasideBaseURI: "https://example.com/$pkg/$filename/$hashtype/$hash/$filename", + retryConfig: retry.Disabled(), + fileProviders: fileProviders, + } + + require.NoError(t, fileutils.MkdirAll(ctx.FS(), destDir)) + require.NoError(t, fileutils.WriteFile( + ctx.FS(), filepath.Join(destDir, filename), []byte("upstream content"), fileperms.PublicFile)) + + ref := &projectconfig.SourceFileReference{ + Filename: filename, + Hash: testCase.hash, + HashType: fileutils.HashTypeSHA256, + Origin: testCase.origin, + ReplaceUpstream: true, + ReplaceReason: "use configured replacement", + } + + err := manager.fetchSourceFile(t.Context(), httpDownloader, component, ref, destDir) + assert.Equal(t, testCase.useProvider, provider.called) + + if testCase.expectError != "" { + require.ErrorContains(t, err, testCase.expectError) + + return + } + + require.NoError(t, err) + + content, err := fileutils.ReadFile(ctx.FS(), filepath.Join(destDir, filename)) + require.NoError(t, err) + assert.Empty(t, content) + }) + } +} diff --git a/internal/providers/sourceproviders/sourcemanager_test.go b/internal/providers/sourceproviders/sourcemanager_test.go index 6297b6832..d7bf67d66 100644 --- a/internal/providers/sourceproviders/sourcemanager_test.go +++ b/internal/providers/sourceproviders/sourcemanager_test.go @@ -267,34 +267,6 @@ func TestSourceManager_FetchFiles_NoSourceFiles(t *testing.T) { require.NoError(t, err) } -func TestSourceManager_FetchFiles_ExistingFile(t *testing.T) { - env := testutils.NewTestEnv(t) - ctrl := gomock.NewController(t) - component := components_testutils.NewMockComponent(ctrl) - - require.NoError(t, env.TestFS.MkdirAll(testDestDir, fileperms.PrivateDir)) - - destPath := filepath.Join(testDestDir, testSourceTarball) - require.NoError(t, fileutils.WriteFile(env.TestFS, destPath, []byte("existing content"), fileperms.PrivateFile)) - - componentConfig := &projectconfig.ComponentConfig{ - SourceFiles: []projectconfig.SourceFileReference{{ - Filename: testSourceTarball, - Hash: "abc123", - HashType: fileutils.HashTypeSHA256, - }}, - } - - component.EXPECT().GetName().AnyTimes().Return("test-component") - component.EXPECT().GetConfig().AnyTimes().Return(componentConfig) - - sourceManager, err := sourceproviders.NewSourceManager(env.Env, testDefaultDistro()) - require.NoError(t, err) - - err = sourceManager.FetchFiles(t.Context(), component, testDestDir) - require.NoError(t, err) -} - func TestSourceManager_FetchFiles_Errors(t *testing.T) { tests := []struct { name string diff --git a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap index 84da8173b..e4615dcb7 100755 --- a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap @@ -778,6 +778,14 @@ "type": "array", "title": "Mock packages", "description": "RPM packages to install in the mock chroot before running the generation script. Only valid when origin type is 'custom'." + }, + "inputs": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Inputs", + "description": "Source-output filenames to make available next to the generation script before it runs. Only valid when origin type is 'custom'." } }, "additionalProperties": false, diff --git a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap index 84da8173b..e4615dcb7 100755 --- a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap @@ -778,6 +778,14 @@ "type": "array", "title": "Mock packages", "description": "RPM packages to install in the mock chroot before running the generation script. Only valid when origin type is 'custom'." + }, + "inputs": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Inputs", + "description": "Source-output filenames to make available next to the generation script before it runs. Only valid when origin type is 'custom'." } }, "additionalProperties": false, diff --git a/scenario/custom_source_test.go b/scenario/custom_source_test.go new file mode 100644 index 000000000..d2c4a0076 --- /dev/null +++ b/scenario/custom_source_test.go @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//go:build scenario + +package scenario_tests + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils" + "github.com/microsoft/azure-linux-dev-tools/scenario/internal/cmdtest" + "github.com/microsoft/azure-linux-dev-tools/scenario/internal/projecttest" + "github.com/stretchr/testify/require" +) + +func TestCustomSourceRegenerationDetectsStaleHash(t *testing.T) { + if testing.Short() { + t.Skip("skipping long test") + } + + const ( + componentName = "custom-source" + scriptName = "generate.sh" + archiveName = "generated.tar.gz" + ) + + spec := projecttest.NewSpec(projecttest.WithName(componentName)) + project := projecttest.NewDynamicTestProject( + projecttest.AddSpec(spec), + projecttest.AddComponent(&projectconfig.ComponentConfig{ + Name: componentName, + Spec: projectconfig.SpecSource{ + SourceType: projectconfig.SpecSourceTypeLocal, + Path: filepath.Join("specs", componentName, componentName+".spec"), + }, + SourceFiles: []projectconfig.SourceFileReference{ + { + Filename: archiveName, + HashType: fileutils.HashTypeSHA512, + Origin: projectconfig.Origin{ + Type: projectconfig.OriginTypeCustom, + Script: scriptName, + }, + }, + }, + }), + projecttest.AddFile(filepath.Join("specs", componentName, scriptName), `#!/bin/sh +set -eu +printf 'version-one\n' > /azldev-gen/output/payload.txt +`), + projecttest.UseTestDefaultConfigs(), + ) + + projectStagingDir := t.TempDir() + project.Serialize(t, projectStagingDir) + + testScript := ` +set -eux + +azldev -C project -v component prep-sources -p custom-source \ + -o prepared --without-git --allow-no-hashes + +test "$(tar -xOf prepared/generated.tar.gz payload.txt)" = "version-one" + +GENERATED_HASH="$(sha512sum prepared/generated.tar.gz | awk '{print $1}')" +sed -i "/hash-type =/a hash = \"$GENERATED_HASH\"" project/azldev.toml +sed -i 's/version-one/version-two/' project/specs/custom-source/generate.sh + +if azldev -C project -v component prep-sources -p custom-source \ + -o prepared --without-git --force 2>second-run.stderr; then + echo "expected regenerated custom source to fail hash validation" >&2 + exit 1 +fi + +grep -q "hash validation failed" second-run.stderr +test "$(tar -xOf prepared/generated.tar.gz payload.txt)" = "version-two" +` + + scenarioTest := cmdtest.NewScenarioTest(). + WithScript(strings.NewReader(testScript)). + AddDirRecursive(t, "project", projectStagingDir). + AddDirRecursive(t, projecttest.TestDefaultConfigsSubdir, projecttest.TestDefaultConfigsDir()) + + results, err := scenarioTest. + InContainer(). + WithPrivilege(). + WithNetwork(). + Run(t) + require.NoError(t, err) + + t.Logf("Standard output:\n%s", results.Stdout) + t.Logf("Standard error:\n%s", results.Stderr) + results.AssertZeroExitCode(t) + + stderrPath := filepath.Join(results.Workdir, "second-run.stderr") + require.FileExists(t, stderrPath) + + stderr, err := os.ReadFile(stderrPath) + require.NoError(t, err) + require.Contains(t, string(stderr), "hash validation failed") +} diff --git a/schemas/azldev.schema.json b/schemas/azldev.schema.json index 84da8173b..e4615dcb7 100644 --- a/schemas/azldev.schema.json +++ b/schemas/azldev.schema.json @@ -778,6 +778,14 @@ "type": "array", "title": "Mock packages", "description": "RPM packages to install in the mock chroot before running the generation script. Only valid when origin type is 'custom'." + }, + "inputs": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Inputs", + "description": "Source-output filenames to make available next to the generation script before it runs. Only valid when origin type is 'custom'." } }, "additionalProperties": false, From af8c411409c52ed050dced8040a0c695685e2c3a Mon Sep 17 00:00:00 2001 From: Daniel McIlvaney Date: Mon, 13 Jul 2026 13:32:58 -0700 Subject: [PATCH 19/20] fix(mcp): fix issues with the MCP server mode (#272) --- internal/app/azldev/cmds/component/changed.go | 2 +- internal/app/azldev/cmds/component/history.go | 2 +- internal/app/azldev/cmds/component/list.go | 2 +- internal/app/azldev/cmds/config/dump.go | 2 +- internal/app/azldev/cmds/config/schema.go | 2 +- internal/app/azldev/cmds/image/list.go | 2 +- internal/app/azldev/cmds/pkg/list.go | 2 +- internal/app/azldev/command.go | 34 ++- internal/app/azldev/core/mcp/mcpserver.go | 168 +++++++++++-- .../core/mcp/mcpserver_internal_test.go | 221 ++++++++++++++++++ .../TestMCPServerMode_1.snap.json | 14 +- 11 files changed, 419 insertions(+), 32 deletions(-) create mode 100644 internal/app/azldev/core/mcp/mcpserver_internal_test.go diff --git a/internal/app/azldev/cmds/component/changed.go b/internal/app/azldev/cmds/component/changed.go index 63a1b896d..c74cd49e3 100644 --- a/internal/app/azldev/cmds/component/changed.go +++ b/internal/app/azldev/cmds/component/changed.go @@ -94,7 +94,7 @@ detected via lock file presence in the compared refs when using -a.`, // it inspects historical locks at arbitrary refs. _ = cmd.Flags().MarkHidden("skip-lock-validation") - azldev.ExportAsMCPTool(cmd) + azldev.ExportAsReadOnlyMCPTool(cmd) return cmd } diff --git a/internal/app/azldev/cmds/component/history.go b/internal/app/azldev/cmds/component/history.go index 2ea21ef5e..e5389683f 100644 --- a/internal/app/azldev/cmds/component/history.go +++ b/internal/app/azldev/cmds/component/history.go @@ -131,7 +131,7 @@ hand-picking entries to document.`, // History is read-only; the lock validation flag is meaningless here. _ = cmd.Flags().MarkHidden("skip-lock-validation") - azldev.ExportAsMCPTool(cmd) + azldev.ExportAsReadOnlyMCPTool(cmd) return cmd } diff --git a/internal/app/azldev/cmds/component/list.go b/internal/app/azldev/cmds/component/list.go index ac348c810..04cc0e7f2 100644 --- a/internal/app/azldev/cmds/component/list.go +++ b/internal/app/azldev/cmds/component/list.go @@ -53,7 +53,7 @@ Component name patterns support glob syntax (*, ?, []).`, ValidArgsFunction: components.GenerateComponentNameCompletions, } - azldev.ExportAsMCPTool(cmd) + azldev.ExportAsReadOnlyMCPTool(cmd) components.AddComponentFilterOptionsToCommand(cmd, &options.ComponentFilter) diff --git a/internal/app/azldev/cmds/config/dump.go b/internal/app/azldev/cmds/config/dump.go index 4550f43dc..0fa58c437 100644 --- a/internal/app/azldev/cmds/config/dump.go +++ b/internal/app/azldev/cmds/config/dump.go @@ -91,7 +91,7 @@ issues or inspecting effective values.`, cmd.Flags().VarP(&configDumpFormat, "format", "f", "Output format {json, toml}") - azldev.ExportAsMCPTool(cmd) + azldev.ExportAsReadOnlyMCPTool(cmd) return cmd } diff --git a/internal/app/azldev/cmds/config/schema.go b/internal/app/azldev/cmds/config/schema.go index fc09a70ab..857224e60 100644 --- a/internal/app/azldev/cmds/config/schema.go +++ b/internal/app/azldev/cmds/config/schema.go @@ -43,7 +43,7 @@ editors or linters that support JSON Schema validation of TOML files.`, }, } - azldev.ExportAsMCPTool(cmd) + azldev.ExportAsReadOnlyMCPTool(cmd) return cmd } diff --git a/internal/app/azldev/cmds/image/list.go b/internal/app/azldev/cmds/image/list.go index 0c42af0fd..52ff3dd45 100644 --- a/internal/app/azldev/cmds/image/list.go +++ b/internal/app/azldev/cmds/image/list.go @@ -93,7 +93,7 @@ If no patterns are provided, all images are listed.`, ValidArgsFunction: generateImageNameCompletions, } - azldev.ExportAsMCPTool(cmd) + azldev.ExportAsReadOnlyMCPTool(cmd) return cmd } diff --git a/internal/app/azldev/cmds/pkg/list.go b/internal/app/azldev/cmds/pkg/list.go index 07258423d..3bf36a6d2 100644 --- a/internal/app/azldev/cmds/pkg/list.go +++ b/internal/app/azldev/cmds/pkg/list.go @@ -124,7 +124,7 @@ and are emitted as empty arrays (never null) when there are no memberships.`, // Help shells complete '--rpm-file' with .json paths. _ = cmd.MarkFlagFilename("rpm-file", "json") - azldev.ExportAsMCPTool(cmd) + azldev.ExportAsReadOnlyMCPTool(cmd) return cmd } diff --git a/internal/app/azldev/command.go b/internal/app/azldev/command.go index 1880b332c..977dee40e 100644 --- a/internal/app/azldev/command.go +++ b/internal/app/azldev/command.go @@ -36,6 +36,15 @@ var ErrInvalidUsage = errors.New("invalid usage") // should be enabled in MCP server mode. The value associated with the key is ignored. const CmdAnnotationMCPEnabled = "azldev.mcp.enabled" +// CmdAnnotationMCPReadOnly is the [cobra.Command] annotation key used to indicate that a command, +// when exposed as an MCP tool, is read-only (does not mutate any state). MCP clients may use this +// hint to auto-approve the tool. The value associated with the key is ignored. +const CmdAnnotationMCPReadOnly = "azldev.mcp.readonly" + +// cmdMCPAnnotationValue is the placeholder value stored for MCP command annotations; only the +// presence of the key matters. +const cmdMCPAnnotationValue = "true" + type ( cobraRunFuncType = func(command *cobra.Command, args []string) error @@ -147,14 +156,17 @@ func GetEnvFromCommand(cmd *cobra.Command) (*Env, error) { } // ExportAsMCPTool updates the provided command (and all descendant commands), -// opting it into being advertised as a tool in MCP server mode. +// opting it into being advertised as a tool in MCP server mode. Use it for commands +// that can write, but only ever to a caller-provided output path (e.g. 'docs markdown', +// 'component diff-sources --output-file'); use [ExportAsReadOnlyMCPTool] for commands +// that never modify their environment so clients can auto-approve them. func ExportAsMCPTool(cmd *cobra.Command) { if cmd.Annotations == nil { cmd.Annotations = make(map[string]string) } // The value doesn't matter. - cmd.Annotations[CmdAnnotationMCPEnabled] = "true" + cmd.Annotations[CmdAnnotationMCPEnabled] = cmdMCPAnnotationValue // If the command has subcommands, then recursively opt them in as well. for _, subCmd := range cmd.Commands() { @@ -162,6 +174,24 @@ func ExportAsMCPTool(cmd *cobra.Command) { } } +// ExportAsReadOnlyMCPTool is like [ExportAsMCPTool], but additionally marks the command (and all +// descendant commands) as read-only -- it does not modify its environment -- so that MCP clients +// can advertise and auto-approve them as non-mutating tools. +func ExportAsReadOnlyMCPTool(cmd *cobra.Command) { + if cmd.Annotations == nil { + cmd.Annotations = make(map[string]string) + } + + // The values don't matter. + cmd.Annotations[CmdAnnotationMCPEnabled] = cmdMCPAnnotationValue + cmd.Annotations[CmdAnnotationMCPReadOnly] = cmdMCPAnnotationValue + + // If the command has subcommands, then recursively opt them in as well. + for _, subCmd := range cmd.Commands() { + ExportAsReadOnlyMCPTool(subCmd) + } +} + // Displays the results of a command in the appropriate format to stdout. func reportResults(env *Env, results interface{}) error { switch env.defaultReportFormat { diff --git a/internal/app/azldev/core/mcp/mcpserver.go b/internal/app/azldev/core/mcp/mcpserver.go index 519483195..c111edc5f 100644 --- a/internal/app/azldev/core/mcp/mcpserver.go +++ b/internal/app/azldev/core/mcp/mcpserver.go @@ -19,11 +19,13 @@ import ( "github.com/samber/lo" "github.com/spf13/cobra" "github.com/spf13/pflag" + "go.szostok.io/version" ) -// Performs the file download requested by options. +// RunMCPServer starts the azldev MCP server, advertising the exported commands as +// tools over stdio until the context is canceled. func RunMCPServer(env *azldev.Env, cmd *cobra.Command) error { - srv := server.NewMCPServer(cmd.Short, "1.0.0", + srv := server.NewMCPServer(cmd.Root().Name(), version.Get().Version, server.WithLogging(), server.WithRecovery(), server.WithResourceCapabilities(true, true), @@ -47,6 +49,13 @@ func RunMCPServer(env *azldev.Env, cmd *cobra.Command) error { stdioServer := server.NewStdioServer(srv) + // handleToolCall runs the shared cobra command tree and swaps the process-global + // os.Stdout to capture output, so two handlers must not run at once. mcp-go + // dispatches tool calls to a worker pool (default 5) -- an agent firing parallel + // skill/tool calls would hit it -- so pin the pool to a single worker and let calls + // serialize instead of corrupting each other's output. + server.WithWorkerPoolSize(1)(stdioServer) + slog.Info("Starting MCP server") // Run the server until canceled. @@ -79,6 +88,12 @@ func addToolForCmd(env *azldev.Env, srv *server.MCPServer, leaf *cobra.Command) toolOptions = append(toolOptions, mcp.WithDescription(toolDesc)) + // Mirror our read-only annotation (set by [azldev.ExportAsReadOnlyMCPTool]) into the MCP tool + // schema so that clients can treat and auto-approve the tool as non-mutating. + if _, readOnly := leaf.Annotations[azldev.CmdAnnotationMCPReadOnly]; readOnly { + toolOptions = append(toolOptions, mcp.WithReadOnlyHintAnnotation(true)) + } + flags := getAllFlagDefs(leaf) for _, flag := range flags { var propOptions []mcp.PropertyOption @@ -132,6 +147,8 @@ func addToolForCmd(env *azldev.Env, srv *server.MCPServer, leaf *cobra.Command) func handleToolCall( env *azldev.Env, cmd *cobra.Command, ) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + flagDefaults := captureFlagDefaults(getAllFlagDefs(cmd)) + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { slog.Info("Invoking tool", "tool", cmd.Name(), "params", request.Params.Arguments) @@ -148,6 +165,10 @@ func handleToolCall( }, nil } + if err := restoreFlagDefaults(flagDefaults, args); err != nil { + return nil, fmt.Errorf("failed to reset command flags:\n%w", err) + } + for key, val := range args { if key == "args" { continue @@ -165,32 +186,147 @@ func handleToolCall( slog.Info("Executing command", "args", fullArgs) cmd.Root().SetArgs(fullArgs) - reader, writer, err := os.Pipe() - if err != nil { - return nil, fmt.Errorf("failed to create pipe for command output:\n%w", err) + // LIMITATION: the [azldev.Env] here is created once when the MCP server starts and is reused + // for every tool call. Per-call global flags parsed by this Execute (e.g. '--dry-run', + // '--project') update the App's flag fields but are NOT re-threaded into this reused Env, so + // env.DryRun() and the loaded config still reflect the server's startup values. As a result a + // mutating tool invoked with '--dry-run=true' would still write. Until the MCP rework gives + // each call its own Env, only expose commands that do not modify managed project state + // (specs, locks, config) on their own: read-only commands (marked with + // [azldev.ExportAsReadOnlyMCPTool]), or ones that write solely to a caller-provided output + // path, such as 'docs markdown' or 'component diff-sources --output-file'. + capturedText, execErr := captureStdout(func() error { + reportFile := env.ReportFile() + defer env.SetReportFile(reportFile) + + env.SetReportFile(os.Stdout) // os.Stdout is the capture pipe for the duration of this call + + return cmd.Root().Execute() + }) + if execErr != nil { + slog.Error("Error executing command", "error", execErr) + + errorText := capturedText + if errorText != "" && !strings.HasSuffix(errorText, "\n") { + errorText += "\n" + } + + errorText += execErr.Error() + + result := mcp.NewToolResultText(errorText) + result.IsError = true + + return result, nil + } + + return mcp.NewToolResultText(capturedText), nil + } +} + +type flagDefault struct { + flag *pflag.Flag + value string + sliceValues []string + changed bool +} + +func (value flagDefault) restore(supplied bool) error { + if sliceValue, ok := value.flag.Value.(pflag.SliceValue); ok { + sliceValues := value.sliceValues + if supplied { + sliceValues = nil } - origStdout := os.Stdout - os.Stdout = writer + if err := sliceValue.Replace(sliceValues); err != nil { + return fmt.Errorf("failed to restore slice value:\n%w", err) + } - env.SetReportFile(writer) + return nil + } - err = cmd.Root().Execute() + if err := value.flag.Value.Set(value.value); err != nil { + return fmt.Errorf("failed to restore value:\n%w", err) + } - os.Stdout = origStdout + return nil +} - if err != nil { - slog.Error("Error executing command", "error", err) +func captureFlagDefaults(flags []*pflag.Flag) []flagDefault { + defaults := make([]flagDefault, 0, len(flags)) - return mcp.NewToolResultText(err.Error()), nil + for _, flag := range flags { + value := flagDefault{ + flag: flag, + value: flag.Value.String(), + changed: flag.Changed, + } + if sliceValue, ok := flag.Value.(pflag.SliceValue); ok { + value.sliceValues = append([]string(nil), sliceValue.GetSlice()...) + } + + defaults = append(defaults, value) + } + + return defaults +} + +func restoreFlagDefaults(defaults []flagDefault, args map[string]any) error { + for _, value := range defaults { + _, supplied := args[value.flag.Name] + if err := value.restore(supplied); err != nil { + return fmt.Errorf("failed to reset flag %#q:\n%w", value.flag.Name, err) } - writer.Close() + value.flag.Changed = value.changed + } - capturedText, _ := io.ReadAll(reader) + return nil +} - return mcp.NewToolResultText(string(capturedText)), nil +// captureStdout runs action with os.Stdout redirected to a pipe and returns everything +// written to it. The pipe is drained by a concurrent goroutine so that output larger +// than the OS pipe buffer (~64KB, e.g. 'config dump' on a large distro) does not block +// the write and hang the caller. Not safe for concurrent use: it mutates the global +// os.Stdout, so callers must serialize (the MCP server pins its worker pool to one). +func captureStdout(action func() error) (string, error) { + reader, writer, err := os.Pipe() + if err != nil { + return "", fmt.Errorf("failed to create pipe for command output:\n%w", err) } + + origStdout := os.Stdout + os.Stdout = writer + + captured := make(chan []byte, 1) + + go func() { + data, _ := io.ReadAll(reader) + captured <- data + }() + + cleanedUp := false + cleanup := func() string { + if cleanedUp { + return "" + } + + cleanedUp = true + os.Stdout = origStdout + _ = writer.Close() // signal EOF so the drain goroutine finishes + output := <-captured + _ = reader.Close() + + return string(output) + } + + defer func() { + _ = cleanup() + }() + + actionErr := action() + output := cleanup() + + return output, actionErr } func getLeafCommands(cmd *cobra.Command) []*cobra.Command { diff --git a/internal/app/azldev/core/mcp/mcpserver_internal_test.go b/internal/app/azldev/core/mcp/mcpserver_internal_test.go new file mode 100644 index 000000000..a77084153 --- /dev/null +++ b/internal/app/azldev/core/mcp/mcpserver_internal_test.go @@ -0,0 +1,221 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package mcp + +import ( + "bytes" + "errors" + "fmt" + "os" + "strings" + "testing" + "time" + + mcpapi "github.com/mark3labs/mcp-go/mcp" + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/testutils" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHandleToolCallMarksCommandError(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + root := &cobra.Command{Use: "azldev"} + cmd := &cobra.Command{ + Use: "fail", + RunE: func(_ *cobra.Command, _ []string) error { + fmt.Fprint(os.Stdout, "partial") + + return errors.New("boom") + }, + } + root.AddCommand(cmd) + + result, err := handleToolCall(testEnv.Env, cmd)(t.Context(), mcpapi.CallToolRequest{ + Params: mcpapi.CallToolParams{Arguments: map[string]any{}}, + }) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.IsError) + require.Len(t, result.Content, 1) + text, ok := result.Content[0].(mcpapi.TextContent) + require.True(t, ok) + assert.Equal(t, "partial\nboom", text.Text) +} + +func TestHandleToolCallRestoresReportFile(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + reportOutput := &bytes.Buffer{} + testEnv.Env.SetReportFile(reportOutput) + + root := &cobra.Command{Use: "azldev"} + cmd := &cobra.Command{ + Use: "report", + RunE: func(_ *cobra.Command, _ []string) error { + fmt.Fprint(testEnv.Env.ReportFile(), "captured") + + return nil + }, + } + root.AddCommand(cmd) + + result, err := handleToolCall(testEnv.Env, cmd)(t.Context(), mcpapi.CallToolRequest{ + Params: mcpapi.CallToolParams{Arguments: map[string]any{}}, + }) + + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, result.Content, 1) + text, ok := result.Content[0].(mcpapi.TextContent) + require.True(t, ok) + assert.Equal(t, "captured", text.Text) + assert.Same(t, reportOutput, testEnv.Env.ReportFile()) + _, writeErr := fmt.Fprint(testEnv.Env.ReportFile(), "later") + require.NoError(t, writeErr) + assert.Equal(t, "later", reportOutput.String()) +} + +func TestHandleToolCallRestoresReportFileAfterPanic(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + reportOutput := &bytes.Buffer{} + testEnv.Env.SetReportFile(reportOutput) + + root := &cobra.Command{Use: "azldev"} + cmd := &cobra.Command{ + Use: "panic", + Run: func(_ *cobra.Command, _ []string) { + panic("boom") + }, + } + root.AddCommand(cmd) + handler := handleToolCall(testEnv.Env, cmd) + + assert.PanicsWithValue(t, "boom", func() { + _, _ = handler(t.Context(), mcpapi.CallToolRequest{ + Params: mcpapi.CallToolParams{Arguments: map[string]any{}}, + }) + }) + + assert.Same(t, reportOutput, testEnv.Env.ReportFile()) + _, writeErr := fmt.Fprint(testEnv.Env.ReportFile(), "later") + require.NoError(t, writeErr) + assert.Equal(t, "later", reportOutput.String()) +} + +func TestHandleToolCallResetsCommandFlags(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + root := &cobra.Command{Use: "azldev"} + + var ( + value string + items []string + ) + + cmd := &cobra.Command{ + Use: "show", + RunE: func(command *cobra.Command, _ []string) error { + fmt.Fprintf(os.Stdout, "%s:%t|%s:%t", + value, command.Flags().Changed("value"), + strings.Join(items, ","), command.Flags().Changed("item")) + + return nil + }, + } + cmd.Flags().StringVar(&value, "value", "default", "value to print") + cmd.Flags().StringArrayVar(&items, "item", []string{"base"}, "item to print") + root.AddCommand(cmd) + handler := handleToolCall(testEnv.Env, cmd) + + first, err := handler(t.Context(), mcpapi.CallToolRequest{ + Params: mcpapi.CallToolParams{Arguments: map[string]any{ + "value": "first", + "item": "one", + }}, + }) + require.NoError(t, err) + require.Len(t, first.Content, 1) + firstText, isText := first.Content[0].(mcpapi.TextContent) + require.True(t, isText) + assert.Equal(t, "first:true|one:true", firstText.Text) + + second, err := handler(t.Context(), mcpapi.CallToolRequest{ + Params: mcpapi.CallToolParams{Arguments: map[string]any{}}, + }) + require.NoError(t, err) + require.Len(t, second.Content, 1) + secondText, isText := second.Content[0].(mcpapi.TextContent) + require.True(t, isText) + assert.Equal(t, "default:false|base:false", secondText.Text) + + third, err := handler(t.Context(), mcpapi.CallToolRequest{ + Params: mcpapi.CallToolParams{Arguments: map[string]any{"item": "two"}}, + }) + require.NoError(t, err) + require.Len(t, third.Content, 1) + thirdText, isText := third.Content[0].(mcpapi.TextContent) + require.True(t, isText) + assert.Equal(t, "default:false|two:true", thirdText.Text) +} + +// TestCaptureStdoutLargeOutput is a regression guard for a pipe deadlock: capturing +// output larger than the OS pipe buffer (~64KB) must not block. A command such as +// 'config dump -f json' on a large distro emits >1MB; without a concurrent drain +// the write blocks and hangs the server. The timeout turns a regression into a +// clean failure instead of a hang. +func TestCaptureStdoutLargeOutput(t *testing.T) { + want := strings.Repeat("x", 1<<20) // 1 MiB, well beyond the pipe buffer + + type result struct { + out string + err error + } + + done := make(chan result, 1) + + go func() { + out, err := captureStdout(func() error { + _, writeErr := fmt.Fprint(os.Stdout, want) + + return writeErr + }) + done <- result{out: out, err: err} + }() + + select { + case got := <-done: + require.NoError(t, got.err) + assert.Equal(t, want, got.out) + case <-time.After(10 * time.Second): + t.Fatal("captureStdout deadlocked on output larger than the pipe buffer") + } +} + +// TestCaptureStdoutReturnsFnError confirms captureStdout returns fn's error alongside +// whatever was written before it failed. +func TestCaptureStdoutReturnsFnError(t *testing.T) { + out, err := captureStdout(func() error { + fmt.Fprint(os.Stdout, "partial") + + return errors.New("boom") + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "boom") + assert.Equal(t, "partial", out) +} + +func TestCaptureStdoutRestoresStdoutAfterPanic(t *testing.T) { + origStdout := os.Stdout + + assert.PanicsWithValue(t, "boom", func() { + _, _ = captureStdout(func() error { + fmt.Fprint(os.Stdout, "partial") + + panic("boom") + }) + }) + + assert.Same(t, origStdout, os.Stdout) +} diff --git a/scenario/__snapshots__/TestMCPServerMode_1.snap.json b/scenario/__snapshots__/TestMCPServerMode_1.snap.json index 0972acfe5..876ca6e7c 100755 --- a/scenario/__snapshots__/TestMCPServerMode_1.snap.json +++ b/scenario/__snapshots__/TestMCPServerMode_1.snap.json @@ -8,7 +8,7 @@ "destructiveHint": true, "idempotentHint": false, "openWorldHint": true, - "readOnlyHint": false + "readOnlyHint": true }, "description": "Detect which components changed between two git refs", "inputSchema": { @@ -205,7 +205,7 @@ "destructiveHint": true, "idempotentHint": false, "openWorldHint": true, - "readOnlyHint": false + "readOnlyHint": true }, "description": "Report per-component change activity and customization detail", "inputSchema": { @@ -300,7 +300,7 @@ "destructiveHint": true, "idempotentHint": false, "openWorldHint": true, - "readOnlyHint": false + "readOnlyHint": true }, "description": "List components in this project", "inputSchema": { @@ -385,7 +385,7 @@ "destructiveHint": true, "idempotentHint": false, "openWorldHint": true, - "readOnlyHint": false + "readOnlyHint": true }, "description": "Dump the current configuration", "inputSchema": { @@ -457,7 +457,7 @@ "destructiveHint": true, "idempotentHint": false, "openWorldHint": true, - "readOnlyHint": false + "readOnlyHint": true }, "description": "Generates JSON schema for validating .toml config files", "inputSchema": { @@ -610,7 +610,7 @@ "destructiveHint": true, "idempotentHint": false, "openWorldHint": true, - "readOnlyHint": false + "readOnlyHint": true }, "description": "List images in this project", "inputSchema": { @@ -678,7 +678,7 @@ "destructiveHint": true, "idempotentHint": false, "openWorldHint": true, - "readOnlyHint": false + "readOnlyHint": true }, "description": "List resolved configuration for packages (RPMs and SRPMs)", "inputSchema": { From 6188a647d17d0249f3d0f7820c966dc8595bca2a Mon Sep 17 00:00:00 2001 From: bhagyapathak Date: Wed, 15 Jul 2026 16:15:19 +0530 Subject: [PATCH 20/20] feat(projectconfig): accept [tests] and [test-groups] schema in config files --- internal/app/azldev/cmds/component/query.go | 17 ++ .../app/azldev/cmds/component/query_test.go | 89 ++++++++++ internal/app/azldev/cmds/image/test.go | 165 ++++++++++++------ .../azldev/cmds/image/test_internal_test.go | 73 ++++++++ internal/projectconfig/configfile.go | 19 +- internal/projectconfig/configfile_test.go | 132 +++++++++----- internal/projectconfig/loader.go | 38 ++++ internal/projectconfig/loader_test.go | 35 ++++ internal/projectconfig/project.go | 12 ++ internal/projectconfig/tests.go | 112 ++++++++++++ 10 files changed, 587 insertions(+), 105 deletions(-) create mode 100644 internal/app/azldev/cmds/image/test_internal_test.go diff --git a/internal/app/azldev/cmds/component/query.go b/internal/app/azldev/cmds/component/query.go index 59985a733..557d60941 100644 --- a/internal/app/azldev/cmds/component/query.go +++ b/internal/app/azldev/cmds/component/query.go @@ -56,6 +56,10 @@ slower than 'list' but more informative.`, // componentDetails encapsulates detailed information about a component. type componentDetails struct { specs.ComponentSpecDetails + + // ResolvedTests lists concrete test names after expanding any component + // tests.tests refs and test-groups. + ResolvedTests []string `json:"resolvedTests,omitempty" table:"-"` } // Queries env for component details, in accordance with options. Returns the found components. @@ -72,6 +76,7 @@ func QueryComponents( } allDetails := make([]*componentDetails, 0, comps.Len()) + cfg := env.Config() for _, comp := range comps.Components() { spec := comp.GetSpec() @@ -85,6 +90,18 @@ func QueryComponents( ComponentSpecDetails: *specInfo, } + if cfg != nil { + resolvedTests, err := cfg.ResolveComponentTests(comp.GetConfig()) + if err != nil { + return nil, fmt.Errorf("failed to resolve tests for component %q:\n%w", comp.GetName(), err) + } + + details.ResolvedTests = make([]string, 0, len(resolvedTests)) + for _, resolvedTest := range resolvedTests { + details.ResolvedTests = append(details.ResolvedTests, resolvedTest.Name) + } + } + allDetails = append(allDetails, details) } diff --git a/internal/app/azldev/cmds/component/query_test.go b/internal/app/azldev/cmds/component/query_test.go index 9d605e024..222c7f5a6 100644 --- a/internal/app/azldev/cmds/component/query_test.go +++ b/internal/app/azldev/cmds/component/query_test.go @@ -80,3 +80,92 @@ func TestQueryComponents_OneComponent(t *testing.T) { result := results[0] assert.Equal(t, testComponentName, result.Name) } + +func TestQueryComponents_ResolvesComponentTests(t *testing.T) { + const ( + testComponentName = "test-component" + testSpecPath = "/path/to/spec" + ) + + testEnv := testutils.NewTestEnv(t) + testEnv.Config.Components[testComponentName] = projectconfig.ComponentConfig{ + Name: testComponentName, + Spec: projectconfig.SpecSource{ + SourceType: projectconfig.SpecSourceTypeLocal, + Path: testSpecPath, + }, + Tests: &projectconfig.ComponentTestsConfig{ + Tests: []projectconfig.TestRef{{Group: "runtime"}}, + }, + } + testEnv.Config.Tests = map[string]projectconfig.TestDefinition{ + "runtime-a": {Type: "pytest", Pytest: map[string]any{"test-paths": []string{"tests/a.py"}}}, + "runtime-b": {Type: "pytest", Pytest: map[string]any{"test-paths": []string{"tests/b.py"}}}, + } + testEnv.Config.TestGroups = map[string]projectconfig.TestGroup{ + "runtime": {Tests: []projectconfig.TestRef{{Name: "runtime-a"}, {Name: "runtime-b"}}}, + } + + // Pretend mock is present. + testEnv.CmdFactory.RegisterCommandInSearchPath(mock.MockBinary) + + // Mock the rpmspec command to return valid output. + testEnv.CmdFactory.RunAndGetOutputHandler = func(cmd *exec.Cmd) (string, error) { + return "name=test-component\nepoch=0\nversion=1.0.0\nrelease=1.azl4\n", nil + } + + options := component.QueryComponentsOptions{ + ComponentFilter: components.ComponentFilter{ + ComponentNamePatterns: []string{testComponentName}, + }, + } + + err := fileutils.WriteFile(testEnv.FS(), testSpecPath, []byte("test spec content"), fileperms.PublicFile) + require.NoError(t, err) + + results, err := component.QueryComponents(testEnv.Env, &options) + require.NoError(t, err) + require.Len(t, results, 1) + assert.Equal(t, []string{"runtime-a", "runtime-b"}, results[0].ResolvedTests) +} + +func TestQueryComponents_InvalidComponentTestRef(t *testing.T) { + const ( + testComponentName = "test-component" + testSpecPath = "/path/to/spec" + ) + + testEnv := testutils.NewTestEnv(t) + testEnv.Config.Components[testComponentName] = projectconfig.ComponentConfig{ + Name: testComponentName, + Spec: projectconfig.SpecSource{ + SourceType: projectconfig.SpecSourceTypeLocal, + Path: testSpecPath, + }, + Tests: &projectconfig.ComponentTestsConfig{ + Tests: []projectconfig.TestRef{{Name: "missing-test"}}, + }, + } + + // Pretend mock is present. + testEnv.CmdFactory.RegisterCommandInSearchPath(mock.MockBinary) + + // Mock the rpmspec command to return valid output. + testEnv.CmdFactory.RunAndGetOutputHandler = func(cmd *exec.Cmd) (string, error) { + return "name=test-component\nepoch=0\nversion=1.0.0\nrelease=1.azl4\n", nil + } + + options := component.QueryComponentsOptions{ + ComponentFilter: components.ComponentFilter{ + ComponentNamePatterns: []string{testComponentName}, + }, + } + + err := fileutils.WriteFile(testEnv.FS(), testSpecPath, []byte("test spec content"), fileperms.PublicFile) + require.NoError(t, err) + + _, err = component.QueryComponents(testEnv.Env, &options) + require.Error(t, err) + assert.ErrorContains(t, err, "failed to resolve tests for component") + assert.ErrorContains(t, err, "missing-test") +} diff --git a/internal/app/azldev/cmds/image/test.go b/internal/app/azldev/cmds/image/test.go index 9ea9ad542..61f9116cc 100644 --- a/internal/app/azldev/cmds/image/test.go +++ b/internal/app/azldev/cmds/image/test.go @@ -8,7 +8,6 @@ import ( "fmt" "log/slog" "path/filepath" - "slices" "sort" "strings" @@ -18,6 +17,7 @@ import ( "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils" "github.com/samber/lo" "github.com/spf13/cobra" + "github.com/pelletier/go-toml/v2" ) // ImageTestOptions holds the options for the 'image test' command. @@ -26,8 +26,8 @@ type ImageTestOptions struct { // test suites and optionally resolve the image artifact path. ImageName string - // TestSuites optionally selects specific test suites to run. When empty, all test - // suites associated with the image are run. + // TestSuites optionally selects specific test names or test-group names to run. + // When empty, all tests associated with the image are run. TestSuites []string // ImagePath is an optional explicit path to the image file. When empty, the image @@ -49,15 +49,14 @@ func NewImageTestCmd() *cobra.Command { cmd := &cobra.Command{ Use: "test IMAGE_NAME", Short: "Run tests against an Azure Linux image", - Long: `Run tests against an Azure Linux image using test suites defined in the + Long: `Run tests against an Azure Linux image using test definitions declared in the project configuration. -Test suites are defined in the [test-suites] section of azldev.toml and referenced -by images via the [images.NAME.tests] subtable. Each test suite specifies a type -and framework-specific configuration in a matching subtable. +Images may reference tests directly via [images.NAME.tests.tests] entries, or via +named [test-groups]. Legacy [test-suites] references are still supported. -By default, all test suites associated with the named image are run. Use ---test-suite to select specific suites (may be repeated). +By default, all tests associated with the named image are run. Use +--test-suite to select specific test names or test-group names (may be repeated). The image artifact can be specified explicitly with --image-path, or resolved automatically from the image name in the output directory. @@ -67,17 +66,17 @@ dependencies from pyproject.toml in the working directory, and runs pytest with the configured test paths and extra arguments. Use {image-path} in extra-args to insert the image path. Glob patterns (including **) in test-paths are expanded automatically.`, - Example: ` # Run all test suites for an image (artifact auto-resolved from output dir) + Example: ` # Run all tests for an image (artifact auto-resolved from output dir) azldev image test vm-base # Run all test suites with an explicit image path azldev image test vm-base --image-path ./out/images/vm-base/image.raw - # Run a specific test suite - azldev image test vm-base --test-suite common-vm-checks + # Run a specific test + azldev image test vm-base --test-suite static-image-checks - # Run multiple specific test suites - azldev image test vm-base --test-suite common-vm-checks --test-suite vm-base-checks + # Run multiple tests or a test-group + azldev image test vm-base --test-suite static-image-checks --test-suite vm-base-functional # Generate JUnit XML output azldev image test vm-base --junit-xml results.xml`, @@ -91,7 +90,7 @@ test-paths are expanded automatically.`, } cmd.Flags().StringSliceVar(&options.TestSuites, "test-suite", nil, - "Name of a test suite to run (may be repeated; defaults to all suites for the image)") + "Name of a test or test-group to run (may be repeated; defaults to all tests for the image)") cmd.Flags().StringVarP(&options.ImagePath, "image-path", "i", "", "Path to the disk image file (resolved from image name if not specified)") @@ -104,7 +103,7 @@ test-paths are expanded automatically.`, return cmd } -// runImageTest resolves which test suites to run and dispatches each one. +// runImageTest resolves which tests to run and dispatches each one. func runImageTest(env *azldev.Env, options *ImageTestOptions) error { cfg := env.Config() if cfg == nil { @@ -153,25 +152,31 @@ func runImageTest(env *azldev.Env, options *ImageTestOptions) error { options.JUnitXMLPath = absJUnitPath } - // Determine which test suites to run. - suiteNames := resolveTestSuiteNames(imageConfig, options.TestSuites) - - // Warn when explicitly requested suites are not referenced by the image config. - if len(options.TestSuites) > 0 { - warnUnassociatedSuites(options.ImageName, imageConfig, options.TestSuites) + resolvedTests, legacySuiteNames, err := resolveImageTestsToRun(cfg, imageConfig, options.TestSuites) + if err != nil { + return err } - if len(suiteNames) == 0 { - slog.Warn("No test suites to run for image", slog.String("image", options.ImageName)) + if len(resolvedTests) == 0 && len(legacySuiteNames) == 0 { + slog.Warn("No tests to run for image", slog.String("image", options.ImageName)) return nil } - // Resolve and run each test suite, continuing past failures so all suites get a chance - // to run. Config/resolution errors abort immediately since they indicate a broken setup. var testFailures []string - for _, suiteName := range suiteNames { + for _, resolvedTest := range resolvedTests { + if err := runResolvedTest(env, resolvedTest, imageConfig, options); err != nil { + slog.Error("Test failed", + slog.String("test", resolvedTest.Name), + slog.Any("error", err), + ) + + testFailures = append(testFailures, resolvedTest.Name) + } + } + + for _, suiteName := range legacySuiteNames { suiteConfig, err := resolveTestSuiteByName(cfg, suiteName) if err != nil { return err @@ -188,41 +193,99 @@ func runImageTest(env *azldev.Env, options *ImageTestOptions) error { } if len(testFailures) > 0 { - return fmt.Errorf("%d of %d test suite(s) failed: %s", - len(testFailures), len(suiteNames), strings.Join(testFailures, ", ")) + total := len(resolvedTests) + len(legacySuiteNames) + return fmt.Errorf("%d of %d test(s) failed: %s", + len(testFailures), total, strings.Join(testFailures, ", ")) } return nil } -// resolveTestSuiteNames determines which test suites to run. If explicit names are -// provided, they are used as-is. Otherwise, all test suites associated with the image -// are returned. -func resolveTestSuiteNames( - imageConfig *projectconfig.ImageConfig, explicitSuites []string, -) []string { - if len(explicitSuites) > 0 { - return explicitSuites + +func resolveImageTestsToRun( + cfg *projectconfig.ProjectConfig, + imageConfig *projectconfig.ImageConfig, + explicitSelectors []string, +) ([]projectconfig.ResolvedTest, []string, error) { + if imageConfig.Tests != nil && len(imageConfig.Tests.Tests) > 0 { + if len(explicitSelectors) > 0 { + resolvedTests, err := cfg.ResolveTestSelectors(explicitSelectors) + return resolvedTests, nil, err + } + + resolvedTests, err := cfg.ResolveImageTests(imageConfig) + return resolvedTests, nil, err + } + + if len(explicitSelectors) > 0 { + return nil, explicitSelectors, nil } - return imageConfig.TestNames() + return nil, imageConfig.TestNames(), nil } -// warnUnassociatedSuites logs a warning for each explicitly requested test suite -// that is not referenced by the image's test configuration. -func warnUnassociatedSuites( - imageName string, imageConfig *projectconfig.ImageConfig, explicitSuites []string, -) { - imageTestNames := imageConfig.TestNames() - - for _, name := range explicitSuites { - if !slices.Contains(imageTestNames, name) { - slog.Warn("Test suite is not associated with image", - slog.String("suite", name), - slog.String("image", imageName), - ) +func runResolvedTest( + env *azldev.Env, + resolvedTest projectconfig.ResolvedTest, + imageConfig *projectconfig.ImageConfig, + options *ImageTestOptions, +) error { + switch resolvedTest.Definition.Type { + case string(projectconfig.TestTypePytest): + suiteConfig, err := testDefinitionToSuiteConfig(resolvedTest) + if err != nil { + return err } + + return RunPytestSuite(env, suiteConfig, imageConfig, options) + + case string(projectconfig.TestTypeLisa): + return fmt.Errorf("LISA tests cannot be run locally via 'azldev image test'; test %#q must be run through the LISA infrastructure", resolvedTest.Name) + + case "tmt": + return fmt.Errorf("TMT tests cannot be run locally via 'azldev image test'; test %#q is metadata-only for external orchestration", resolvedTest.Name) + + default: + return fmt.Errorf("unsupported test type %#q for test %#q", resolvedTest.Definition.Type, resolvedTest.Name) + } +} + +func testDefinitionToSuiteConfig(resolvedTest projectconfig.ResolvedTest) (*projectconfig.TestSuiteConfig, error) { + pytestConfig, err := decodePytestConfig(resolvedTest.Definition.Pytest) + if err != nil { + return nil, fmt.Errorf("decode pytest config for test %#q:\n%w", resolvedTest.Name, err) + } + + suiteConfig := &projectconfig.TestSuiteConfig{ + Name: resolvedTest.Name, + Description: resolvedTest.Definition.Description, + Type: projectconfig.TestTypePytest, + Pytest: pytestConfig, + } + + if err := suiteConfig.Validate(); err != nil { + return nil, fmt.Errorf("invalid pytest test %#q:\n%w", resolvedTest.Name, err) } + + return suiteConfig, nil +} + +func decodePytestConfig(raw map[string]any) (*projectconfig.PytestConfig, error) { + if raw == nil { + return nil, fmt.Errorf("missing [pytest] subtable") + } + + bytes, err := toml.Marshal(raw) + if err != nil { + return nil, fmt.Errorf("marshal pytest config:\n%w", err) + } + + pytestConfig := &projectconfig.PytestConfig{} + if err := toml.Unmarshal(bytes, pytestConfig); err != nil { + return nil, fmt.Errorf("unmarshal pytest config:\n%w", err) + } + + return pytestConfig, nil } // resolveTestSuiteByName looks up a test suite by name in the project configuration. diff --git a/internal/app/azldev/cmds/image/test_internal_test.go b/internal/app/azldev/cmds/image/test_internal_test.go new file mode 100644 index 000000000..3d34c2181 --- /dev/null +++ b/internal/app/azldev/cmds/image/test_internal_test.go @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package image + +import ( + "testing" + + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/testutils" + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolveImageTestsToRun_UsesNewTestsRefs(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + testEnv.Config.Tests = map[string]projectconfig.TestDefinition{ + "static-image-checks": {Type: "pytest", Pytest: map[string]any{"working-dir": "/project/tests"}}, + "functional_core": {Type: "lisa", Lisa: map[string]any{"criteria": map[string]any{"priority": []any{1}}}}, + } + testEnv.Config.TestGroups = map[string]projectconfig.TestGroup{ + "vm-base-functional": {Tests: []projectconfig.TestRef{{Name: "functional_core"}}}, + } + + imageCfg := &projectconfig.ImageConfig{ + Tests: &projectconfig.ImageTestsConfig{ + Tests: []projectconfig.TestRef{ + {Name: "static-image-checks"}, + {Group: "vm-base-functional"}, + }, + }, + } + + resolved, legacy, err := resolveImageTestsToRun(testEnv.Config, imageCfg, nil) + require.NoError(t, err) + assert.Empty(t, legacy) + require.Len(t, resolved, 2) + assert.Equal(t, "static-image-checks", resolved[0].Name) + assert.Equal(t, "functional_core", resolved[1].Name) +} + +func TestResolveImageTestsToRun_FallsBackToLegacyTestSuites(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + imageCfg := &projectconfig.ImageConfig{ + Tests: &projectconfig.ImageTestsConfig{ + TestSuites: []projectconfig.TestSuiteRef{{Name: "smoke"}, {Name: "integration"}}, + }, + } + + resolved, legacy, err := resolveImageTestsToRun(testEnv.Config, imageCfg, nil) + require.NoError(t, err) + assert.Empty(t, resolved) + assert.Equal(t, []string{"smoke", "integration"}, legacy) +} + +func TestTestDefinitionToSuiteConfig_Pytest(t *testing.T) { + resolvedTest := projectconfig.ResolvedTest{ + Name: "static-image-checks", + Definition: projectconfig.TestDefinition{ + Type: "pytest", + Description: "offline validation", + Pytest: map[string]any{"working-dir": "/project/tests", "install": "pyproject"}, + }, + } + + suite, err := testDefinitionToSuiteConfig(resolvedTest) + require.NoError(t, err) + require.NotNil(t, suite) + assert.Equal(t, "static-image-checks", suite.Name) + require.NotNil(t, suite.Pytest) + assert.Equal(t, "/project/tests", suite.Pytest.WorkingDir) + assert.Equal(t, projectconfig.PytestInstallPyproject, suite.Pytest.Install) +} diff --git a/internal/projectconfig/configfile.go b/internal/projectconfig/configfile.go index 1e1ee1d15..515cbd3ee 100644 --- a/internal/projectconfig/configfile.go +++ b/internal/projectconfig/configfile.go @@ -150,10 +150,6 @@ func (f ConfigFile) Validate() error { return err } - if err := validateNewTestReferences(f); err != nil { - return err - } - return nil } @@ -202,32 +198,32 @@ func validateComponentGroupMetadata(groups map[string]ComponentGroupConfig) erro return nil } -func validateNewTestReferences(cfgFile ConfigFile) error { - for groupName, group := range cfgFile.TestGroups { +func validateNewTestReferences(tests map[string]TestDefinition, groups map[string]TestGroup, components map[string]ComponentConfig, images map[string]ImageConfig) error { + for groupName, group := range groups { scope := fmt.Sprintf("test-group %#q tests", groupName) - if err := validateTestGroupMembers(scope, group.Tests, cfgFile.Tests); err != nil { + if err := validateTestGroupMembers(scope, group.Tests, tests); err != nil { return err } } - for componentName, component := range cfgFile.Components { + for componentName, component := range components { if component.Tests == nil { continue } scope := fmt.Sprintf("component %#q tests.tests", componentName) - if err := validateTestRefList(scope, component.Tests.Tests, cfgFile.Tests, cfgFile.TestGroups); err != nil { + if err := validateTestRefList(scope, component.Tests.Tests, tests, groups); err != nil { return err } } - for imageName, image := range cfgFile.Images { + for imageName, image := range images { if image.Tests == nil { continue } scope := fmt.Sprintf("image %#q tests.tests", imageName) - if err := validateTestRefList(scope, image.Tests.Tests, cfgFile.Tests, cfgFile.TestGroups); err != nil { + if err := validateTestRefList(scope, image.Tests.Tests, tests, groups); err != nil { return err } } @@ -371,7 +367,6 @@ func validateTestRefList( return nil } - // validateSourceFiles checks 'source-files' configuration for a component: // - All filenames must be unique. // - Hash type must be a supported algorithm when specified. diff --git a/internal/projectconfig/configfile_test.go b/internal/projectconfig/configfile_test.go index 9e7c12b1c..bd2d0abf2 100644 --- a/internal/projectconfig/configfile_test.go +++ b/internal/projectconfig/configfile_test.go @@ -157,103 +157,99 @@ func TestProjectConfigFileValidation_LisaSelectionUnsupportedCriteriaKey(t *test assert.Contains(t, err.Error(), "unsupported selector") } -func TestProjectConfigFileValidation_UndefinedTestReferenceInGroup(t *testing.T) { - file := projectconfig.ConfigFile{ - TestGroups: map[string]projectconfig.TestGroup{ +func TestProjectConfigValidation_UndefinedTestReferenceInGroup(t *testing.T) { + cfg := projectconfig.NewProjectConfig() + cfg.TestGroups = map[string]projectconfig.TestGroup{ "bvt": { Tests: []projectconfig.TestRef{{Name: "does-not-exist"}}, }, - }, } - err := file.Validate() + err := cfg.Validate() require.Error(t, err) require.ErrorIs(t, err, projectconfig.ErrUndefinedTest) assert.Contains(t, err.Error(), "does-not-exist") } -func TestProjectConfigFileValidation_UndefinedTestGroupReferenceInComponent(t *testing.T) { - file := projectconfig.ConfigFile{ - Components: map[string]projectconfig.ComponentConfig{ +func TestProjectConfigValidation_UndefinedTestGroupReferenceInComponent(t *testing.T) { + cfg := projectconfig.NewProjectConfig() + cfg.Components = map[string]projectconfig.ComponentConfig{ "openssl": { Tests: &projectconfig.ComponentTestsConfig{ Tests: []projectconfig.TestRef{{Group: "missing-group"}}, }, }, - }, } - err := file.Validate() + err := cfg.Validate() require.Error(t, err) require.ErrorIs(t, err, projectconfig.ErrUndefinedTestGroup) assert.Contains(t, err.Error(), "missing-group") } -func TestProjectConfigFileValidation_InvalidTestReferenceShapeInImage(t *testing.T) { - file := projectconfig.ConfigFile{ - Images: map[string]projectconfig.ImageConfig{ +func TestProjectConfigValidation_InvalidTestReferenceShapeInImage(t *testing.T) { + cfg := projectconfig.NewProjectConfig() + cfg.Images = map[string]projectconfig.ImageConfig{ "base": { Tests: &projectconfig.ImageTestsConfig{ Tests: []projectconfig.TestRef{{Name: "smoke", Group: "bvt"}}, }, }, - }, - Tests: map[string]projectconfig.TestDefinition{ + } + cfg.Tests = map[string]projectconfig.TestDefinition{ "smoke": { Type: "pytest", Pytest: map[string]any{"working-dir": "tests"}, }, - }, - TestGroups: map[string]projectconfig.TestGroup{ + } + cfg.TestGroups = map[string]projectconfig.TestGroup{ "bvt": {Tests: []projectconfig.TestRef{{Name: "smoke"}}}, - }, } - err := file.Validate() + err := cfg.Validate() require.Error(t, err) require.ErrorIs(t, err, projectconfig.ErrInvalidTestRef) assert.Contains(t, err.Error(), "exactly one") } -func TestProjectConfigFileValidation_DuplicateTestReferenceInGroup(t *testing.T) { - file := projectconfig.ConfigFile{ - Tests: map[string]projectconfig.TestDefinition{ +func TestProjectConfigValidation_DuplicateTestReferenceInGroup(t *testing.T) { + cfg := projectconfig.NewProjectConfig() + cfg.Tests = map[string]projectconfig.TestDefinition{ "smoke": { Type: "pytest", Pytest: map[string]any{"working-dir": "tests"}, }, - }, - TestGroups: map[string]projectconfig.TestGroup{ + } + cfg.TestGroups = map[string]projectconfig.TestGroup{ "bvt": { Tests: []projectconfig.TestRef{ {Name: "smoke"}, {Name: "smoke"}, }, }, - }, } - err := file.Validate() + err := cfg.Validate() require.Error(t, err) require.ErrorIs(t, err, projectconfig.ErrDuplicateTestRef) assert.Contains(t, err.Error(), "duplicates") assert.Contains(t, err.Error(), "smoke") } -func TestProjectConfigFileValidation_DuplicateTestGroupReferenceInImage(t *testing.T) { - file := projectconfig.ConfigFile{ - Tests: map[string]projectconfig.TestDefinition{ +func TestProjectConfigValidation_DuplicateTestGroupReferenceInImage(t *testing.T) { + cfg := projectconfig.NewProjectConfig() + cfg.Tests = map[string]projectconfig.TestDefinition{ "smoke": { Type: "pytest", Pytest: map[string]any{"working-dir": "tests"}, }, - }, - TestGroups: map[string]projectconfig.TestGroup{ + } + cfg.TestGroups = map[string]projectconfig.TestGroup{ "bvt": { Tests: []projectconfig.TestRef{{Name: "smoke"}}, }, - }, - Images: map[string]projectconfig.ImageConfig{ + } + cfg.Images = map[string]projectconfig.ImageConfig{ "base": { Tests: &projectconfig.ImageTestsConfig{ Tests: []projectconfig.TestRef{ @@ -262,36 +258,88 @@ func TestProjectConfigFileValidation_DuplicateTestGroupReferenceInImage(t *testi }, }, }, - }, } - err := file.Validate() + err := cfg.Validate() require.Error(t, err) require.ErrorIs(t, err, projectconfig.ErrDuplicateTestRef) assert.Contains(t, err.Error(), "duplicates") assert.Contains(t, err.Error(), "bvt") } -func TestProjectConfigFileValidation_NestedTestGroupReferenceNotAllowed(t *testing.T) { - file := projectconfig.ConfigFile{ - Tests: map[string]projectconfig.TestDefinition{ +func TestProjectConfigValidation_NestedTestGroupReferenceNotAllowed(t *testing.T) { + cfg := projectconfig.NewProjectConfig() + cfg.Tests = map[string]projectconfig.TestDefinition{ "smoke": { Type: "pytest", Pytest: map[string]any{"working-dir": "tests"}, }, - }, - TestGroups: map[string]projectconfig.TestGroup{ + } + cfg.TestGroups = map[string]projectconfig.TestGroup{ "a": {Tests: []projectconfig.TestRef{{Group: "b"}}}, "b": {Tests: []projectconfig.TestRef{{Name: "smoke"}}}, - }, } - err := file.Validate() + err := cfg.Validate() require.Error(t, err) require.ErrorIs(t, err, projectconfig.ErrNestedTestGroupReference) assert.Contains(t, err.Error(), "is not allowed in [test-groups]") } +func TestProjectConfigResolveImageTests_ExpandsGroups(t *testing.T) { + cfg := projectconfig.NewProjectConfig() + cfg.Tests = map[string]projectconfig.TestDefinition{ + "static-image-checks": {Type: "pytest", Pytest: map[string]any{"working-dir": "tests"}}, + "functional_core": {Type: "lisa", Lisa: map[string]any{"criteria": map[string]any{"priority": []any{1}}}}, + "lisa_perf": {Type: "lisa", Lisa: map[string]any{"criteria": map[string]any{"area": "network", "category": "performance"}}}, + } + cfg.TestGroups = map[string]projectconfig.TestGroup{ + "vm-base-functional": {Tests: []projectconfig.TestRef{{Name: "functional_core"}}}, + "vm-base-performance": {Tests: []projectconfig.TestRef{{Name: "lisa_perf"}}}, + } + + imageCfg := &projectconfig.ImageConfig{ + Tests: &projectconfig.ImageTestsConfig{ + Tests: []projectconfig.TestRef{ + {Name: "static-image-checks"}, + {Group: "vm-base-functional"}, + {Group: "vm-base-performance"}, + }, + }, + } + + resolved, err := cfg.ResolveImageTests(imageCfg) + require.NoError(t, err) + require.Len(t, resolved, 3) + assert.Equal(t, []string{"static-image-checks", "functional_core", "lisa_perf"}, []string{ + resolved[0].Name, + resolved[1].Name, + resolved[2].Name, + }) +} + +func TestProjectConfigResolveComponentTests_ExpandsGroups(t *testing.T) { + cfg := projectconfig.NewProjectConfig() + cfg.Tests = map[string]projectconfig.TestDefinition{ + "bash-fedora-shell": {Type: "tmt", Tmt: map[string]any{"plan": "/plans/shell"}}, + } + cfg.TestGroups = map[string]projectconfig.TestGroup{ + "shell-tests": {Tests: []projectconfig.TestRef{{Name: "bash-fedora-shell"}}}, + } + + componentCfg := &projectconfig.ComponentConfig{ + Tests: &projectconfig.ComponentTestsConfig{ + Tests: []projectconfig.TestRef{{Group: "shell-tests"}}, + }, + } + + resolved, err := cfg.ResolveComponentTests(componentCfg) + require.NoError(t, err) + require.Len(t, resolved, 1) + assert.Equal(t, "bash-fedora-shell", resolved[0].Name) + assert.Equal(t, "tmt", resolved[0].Definition.Type) +} + func TestProjectConfigFileValidation_DefaultProjectInfo(t *testing.T) { file := projectconfig.ConfigFile{ Project: &projectconfig.ProjectInfo{}, diff --git a/internal/projectconfig/loader.go b/internal/projectconfig/loader.go index 31971f902..4a2f71b55 100644 --- a/internal/projectconfig/loader.go +++ b/internal/projectconfig/loader.go @@ -45,6 +45,8 @@ func loadAndResolveProjectConfig( GroupsByComponent: make(map[string][]string), PackageGroups: make(map[string]PackageGroupConfig), TestSuites: make(map[string]TestSuiteConfig), + Tests: make(map[string]TestDefinition), + TestGroups: make(map[string]TestGroup), } for _, configFilePath := range configFilePaths { @@ -146,6 +148,14 @@ func mergeConfigFile(resolvedCfg *ProjectConfig, loadedCfg *ConfigFile) error { return err } + if err := mergeTests(resolvedCfg, loadedCfg); err != nil { + return err + } + + if err := mergeTestGroups(resolvedCfg, loadedCfg); err != nil { + return err + } + if err := mergeResources(resolvedCfg, loadedCfg); err != nil { return err } @@ -319,6 +329,34 @@ func mergeTestSuites(resolvedCfg *ProjectConfig, loadedCfg *ConfigFile) error { return nil } +// mergeTests merges individual test definitions from a loaded config file into the +// resolved config. Duplicate test names are not allowed. +func mergeTests(resolvedCfg *ProjectConfig, loadedCfg *ConfigFile) error { + for testName, testDef := range loadedCfg.Tests { + if _, ok := resolvedCfg.Tests[testName]; ok { + return fmt.Errorf("%w: test %#q", ErrDuplicateTestSuites, testName) + } + + resolvedCfg.Tests[testName] = testDef.WithAbsolutePaths(loadedCfg.dir) + } + + return nil +} + +// mergeTestGroups merges named test groups from a loaded config file into the +// resolved config. Duplicate group names are not allowed. +func mergeTestGroups(resolvedCfg *ProjectConfig, loadedCfg *ConfigFile) error { + for groupName, group := range loadedCfg.TestGroups { + if _, ok := resolvedCfg.TestGroups[groupName]; ok { + return fmt.Errorf("%w: test group %#q", ErrDuplicateTestSuites, groupName) + } + + resolvedCfg.TestGroups[groupName] = group + } + + return nil +} + func loadProjectConfigWithIncludes( fs opctx.FS, filePath string, permissiveConfigParsing bool, seen map[string]bool, diff --git a/internal/projectconfig/loader_test.go b/internal/projectconfig/loader_test.go index cd3dd0c3a..d90c706d6 100644 --- a/internal/projectconfig/loader_test.go +++ b/internal/projectconfig/loader_test.go @@ -122,6 +122,41 @@ key = "value" assert.Equal(t, "/project/artifacts/logs", config.Project.LogDir) } +func TestLoadAndResolveProjectConfig_TestReferencesResolvedAcrossIncludedFiles(t *testing.T) { + testFiles := []struct { + path string + contents string + }{ + {testConfigPath, ` +includes = ["components.toml", "tests.toml"] +`}, + {"/project/components.toml", ` +[components.bash] +tests.tests = [{ name = "bash-fedora-shell" }] +`}, + {"/project/tests.toml", ` +[tests.bash-fedora-shell] +type = "tmt" +kind = "functional" + +[tests.bash-fedora-shell.tmt] +plan = "/plans/shell" +`}, + } + + ctx := testctx.NewCtx() + for _, testFile := range testFiles { + require.NoError(t, fileutils.MkdirAll(ctx.FS(), filepath.Dir(testFile.path))) + require.NoError(t, fileutils.WriteFile(ctx.FS(), testFile.path, []byte(testFile.contents), fileperms.PrivateFile)) + } + + config, err := loadAndResolveProjectConfig(ctx.FS(), false, testConfigPath) + require.NoError(t, err) + require.Contains(t, config.Components, "bash") + require.Contains(t, config.Tests, "bash-fedora-shell") + assert.Equal(t, TestKindFunctional, config.Tests["bash-fedora-shell"].Kind) +} + func TestLoadAndResolveProjectConfig_PermissiveParsing_IgnoresValidationError(t *testing.T) { // This config is structurally valid TOML but fails semantic validation: the // component group references a component that is not defined. diff --git a/internal/projectconfig/project.go b/internal/projectconfig/project.go index adb862aa0..7ed2c3f65 100644 --- a/internal/projectconfig/project.go +++ b/internal/projectconfig/project.go @@ -47,6 +47,12 @@ type ProjectConfig struct { // Definitions of test suites. TestSuites map[string]TestSuiteConfig `toml:"test-suites,omitempty" json:"testSuites,omitempty" jsonschema:"title=Test Suites,description=Mapping of test suite names to configurations"` + // Definitions of individual tests. + Tests map[string]TestDefinition `toml:"tests,omitempty" json:"tests,omitempty" jsonschema:"title=Tests,description=Mapping of test names to configurations"` + + // Definitions of named test groups. + TestGroups map[string]TestGroup `toml:"test-groups,omitempty" json:"testGroups,omitempty" jsonschema:"title=Test Groups,description=Mapping of test group names to configurations"` + // Root config file path; not serialized. RootConfigFilePath string `toml:"-" json:"-"` // Map from component names to groups they belong to; not serialized. @@ -65,6 +71,8 @@ func NewProjectConfig() ProjectConfig { GroupsByComponent: make(map[string][]string), PackageGroups: make(map[string]PackageGroupConfig), TestSuites: make(map[string]TestSuiteConfig), + Tests: make(map[string]TestDefinition), + TestGroups: make(map[string]TestGroup), } } @@ -87,6 +95,10 @@ func (cfg *ProjectConfig) Validate() error { return err } + if err := validateNewTestReferences(cfg.Tests, cfg.TestGroups, cfg.Components, cfg.Images); err != nil { + return err + } + if err := validateRpmRepos(cfg.Resources.RpmRepos); err != nil { return err } diff --git a/internal/projectconfig/tests.go b/internal/projectconfig/tests.go index 7e06b0d5f..680ab606f 100644 --- a/internal/projectconfig/tests.go +++ b/internal/projectconfig/tests.go @@ -13,6 +13,12 @@ import ( orderedmap "github.com/pb33f/ordered-map/v2" ) +// ResolvedTest is a concrete test definition resolved from a direct [tests.X] +// reference or from expansion of a [test-groups.X] reference. +type ResolvedTest struct { + Name string + Definition TestDefinition +} // TestKind indicates what kind of behavior a test exercises. type TestKind string @@ -70,6 +76,20 @@ type TestDefinition struct { Pytest map[string]any `toml:"pytest,omitempty" json:"pytest,omitempty" jsonschema:"title=Pytest config,description=pytest-specific configuration"` } +// WithAbsolutePaths returns a copy of the test definition with any relative +// paths in framework-specific subtables converted to absolute paths. +func (t TestDefinition) WithAbsolutePaths(referenceDir string) TestDefinition { + result := t + result.Lisa = cloneStringAnyMap(t.Lisa) + result.Tmt = cloneStringAnyMap(t.Tmt) + result.Pytest = cloneStringAnyMap(t.Pytest) + + if workingDir, ok := result.Pytest["working-dir"].(string); ok { + result.Pytest["working-dir"] = makeAbsolute(referenceDir, workingDir) + } + + return result +} // TestGroup is a [test-groups.X] declaration: a named bundle of test references that // images or components can target via a single name. type TestGroup struct { @@ -100,6 +120,98 @@ type ComponentTestsConfig struct { Tests []TestRef `toml:"tests,omitempty" json:"tests,omitempty" jsonschema:"title=Tests,description=Per-component test or test-group references"` } +// ResolveTestRefs expands a list of [TestRef] entries into concrete tests. +func (cfg *ProjectConfig) ResolveTestRefs(refs []TestRef) ([]ResolvedTest, error) { + resolved := make([]ResolvedTest, 0, len(refs)) + + for _, ref := range refs { + switch { + case ref.Name != "": + testDef, ok := cfg.Tests[ref.Name] + if !ok { + return nil, fmt.Errorf("%w: %#q", ErrUndefinedTest, ref.Name) + } + + resolved = append(resolved, ResolvedTest{Name: ref.Name, Definition: testDef}) + + case ref.Group != "": + group, ok := cfg.TestGroups[ref.Group] + if !ok { + return nil, fmt.Errorf("%w: %#q", ErrUndefinedTestGroup, ref.Group) + } + + for _, groupRef := range group.Tests { + if groupRef.Group != "" { + return nil, fmt.Errorf("%w: %#q", ErrNestedTestGroupReference, ref.Group) + } + + testDef, ok := cfg.Tests[groupRef.Name] + if !ok { + return nil, fmt.Errorf("%w: %#q", ErrUndefinedTest, groupRef.Name) + } + + resolved = append(resolved, ResolvedTest{Name: groupRef.Name, Definition: testDef}) + } + } + } + + return resolved, nil +} + +// ResolveTestSelectors resolves user-provided selectors, where each selector may +// reference either a concrete test or a test group. +func (cfg *ProjectConfig) ResolveTestSelectors(selectors []string) ([]ResolvedTest, error) { + refs := make([]TestRef, 0, len(selectors)) + + for _, selector := range selectors { + _, isTest := cfg.Tests[selector] + _, isGroup := cfg.TestGroups[selector] + + switch { + case isTest && isGroup: + return nil, fmt.Errorf("ambiguous test selector %#q matches both [tests] and [test-groups]", selector) + case isTest: + refs = append(refs, TestRef{Name: selector}) + case isGroup: + refs = append(refs, TestRef{Group: selector}) + default: + return nil, fmt.Errorf("unknown test selector %#q", selector) + } + } + + return cfg.ResolveTestRefs(refs) +} + +// ResolveImageTests expands the new-style test refs associated with an image. +func (cfg *ProjectConfig) ResolveImageTests(image *ImageConfig) ([]ResolvedTest, error) { + if image == nil || image.Tests == nil { + return nil, nil + } + + return cfg.ResolveTestRefs(image.Tests.Tests) +} + +// ResolveComponentTests expands the new-style test refs associated with a component. +func (cfg *ProjectConfig) ResolveComponentTests(component *ComponentConfig) ([]ResolvedTest, error) { + if component == nil || component.Tests == nil { + return nil, nil + } + + return cfg.ResolveTestRefs(component.Tests.Tests) +} + +func cloneStringAnyMap(input map[string]any) map[string]any { + if input == nil { + return nil + } + + result := make(map[string]any, len(input)) + for key, value := range input { + result[key] = value + } + + return result +} // Validate checks that exactly one framework subtable is set and it matches Type. func (t TestDefinition) Validate(testName string) error { if t.Type == "" {