From fd8346bdc9e5a9067c27f7d66520ced620a464cf Mon Sep 17 00:00:00 2001 From: Daniel McIlvaney Date: Fri, 22 May 2026 16:10:21 -0700 Subject: [PATCH] feat(overlays): handle empty sections explicitly --- 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 | 32 ++++- 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, 368 insertions(+), 20 deletions(-) diff --git a/docs/user/reference/config/overlays.md b/docs/user/reference/config/overlays.md index 55061118..db86f018 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` | @@ -150,6 +150,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 cfc23069..66cd2373 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 ea8ec435..98375723 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 7ec0b50e..29c7035d 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. @@ -202,6 +207,21 @@ func (c *ComponentOverlay) Validate() error { return fmt.Errorf("overlay type %#q does not accept %#q field: %s", c.Type, fieldName, 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. + requireSectionIfPackageSet := func() 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 + } + requireRelativePath := func(fieldName, value string) error { if value == "" { return missingField(fieldName) @@ -250,6 +270,10 @@ func (c *ComponentOverlay) Validate() error { if len(c.Lines) == 0 { return missingField("lines") } + + if err := requireSectionIfPackageSet(); err != nil { + return err + } case ComponentOverlaySearchAndReplaceInSpec: if c.Regex == "" { return missingField("regex") @@ -258,6 +282,10 @@ func (c *ComponentOverlay) Validate() error { if err := validateRegex(c.Regex, desc); err != nil { return err } + + if err := requireSectionIfPackageSet(); err != nil { + return err + } case ComponentOverlayPrependLinesToFile: if err := requireRelativePath("file", c.Filename); err != nil { return err diff --git a/internal/projectconfig/overlay_test.go b/internal/projectconfig/overlay_test.go index 84ccf200..f783e1f5 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: "package", + }, + { + 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 9eb824fc..bd211a23 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. Unlike +// [Spec.PrependLinesToSection] with an empty section, this does not interpret the lines as +// belonging to the global section; they are inserted verbatim above all existing content. +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. Unlike +// [Spec.AppendLinesToSection] with an empty section (which would insert before the first +// section header), this places the lines after the final line of the file. +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 1c63b806..959c5f10 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 7aa38d7e..73352928 100755 --- a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap @@ -239,12 +239,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 7aa38d7e..73352928 100755 --- a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap @@ -239,12 +239,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 7aa38d7e..73352928 100644 --- a/schemas/azldev.schema.json +++ b/schemas/azldev.schema.json @@ -239,12 +239,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",