Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion docs/user/reference/config/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ The `[[components.<name>.source-files]]` array defines additional source files t

### Origin

Two origin types are supported.
Three origin types are supported.

#### `"download"` — fetch from a URI

Expand Down Expand Up @@ -352,6 +352,42 @@ origin.script = "gen-yara-stripped.sh" # relative to the component's s
origin.mock-packages = ["cmake"] # omit if not needed
```

#### `"overlay"` — record a post-overlay hash

Used when archive overlays (see [Archive overlays](overlays.md#archive-overlays)) modify an existing upstream source archive. No download occurs; the archive is already present on disk as a spec source. The `hash` and `hash-type` fields record the **expected hash after all overlays have been applied** to that archive.

- **`hash` and `hash-type` are required** for `"overlay"` entries — the hash cannot be computed at render time because sources are not downloaded.
- **`replace-upstream = true` is required** — the archive already exists in the upstream `sources` file, and this entry replaces its hash with the post-overlay value.
- During `prep-sources` (full run), azldev verifies that the hash it computed after repacking the archive matches the stated `hash`. A mismatch means the config is stale and must be updated.
- During `render --check-only`, the stated hash is injected directly without downloading or repacking, allowing the check to pass deterministically.

See [Recording the post-overlay hash for archive overlays](#recording-the-post-overlay-hash-for-archive-overlays) below for the full workflow.

### Recording the post-overlay hash for archive overlays

When you apply archive overlays (e.g. removing vendored files from a tarball) using `file-remove` or `file-search-replace` with an archive-scoped path, the repacked archive has a different hash than the original. Use a `source-files` entry with `origin.type = "overlay"` to record the expected post-overlay hash:

```toml
[[components.apache-commons-compress.source-files]]
filename = "commons-compress-1.27.1-src.tar.gz"
hash = "c7a2cef26959e687ad19b96b5ba8393d7514095e13bf0f29bd41e6b3c3cb2260d8ff23283ff3d5fd137b2522b843e7f0f50ab46bcf0f66df5383674f35f223ab"
hash-type = "SHA512"
origin = { type = "overlay" }
replace-upstream = true
replace-reason = "Upstream source tarball contains test fixtures flagged as malware by the AZL RPM signing pipeline. These files are not needed at runtime and are removed to allow SRPM publication."
```

**Workflow:**

1. Add the archive overlay(s) in the component's `[[overlays]]` array.
2. Run `prep-sources` once — this repacks the archive and prints the computed hash in the error message.
3. Paste the computed `hash` and `hash-type` into the `source-files` entry above.
4. Run `prep-sources` again to confirm the hash matches, then commit.

After that, `render --check-only` will pass deterministically without downloading or repacking the archive.

`replace-upstream = true` and `replace-reason` are required because the archive is already in the upstream `sources` file. The entry replaces its hash with the post-overlay value, regardless of how many overlays target that archive.

### 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:
Expand Down
82 changes: 82 additions & 0 deletions internal/app/azldev/core/sources/archiveoverlays.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,88 @@ import (
"github.com/microsoft/azure-linux-dev-tools/internal/utils/rootfs"
)

// applyArchiveOverlays groups archive overlays by target archive and processes
// them in order. Multiple overlays targeting the same archive are batched into
// a single extract/modify/repack cycle. File removals inside the archive reuse
// the same machinery as loose-file overlays ([applyNonSpecOverlay]).
//
// It returns the names of the archives that were actually repacked. In dry-run
// mode no archive is repacked, so the returned slice is empty even when archive
// overlays were present.
func applyArchiveOverlays(
dryRunnable opctx.DryRunnable,
eventListener opctx.EventListener,
sourcesDirPath string,
overlays []projectconfig.ComponentOverlay,
) ([]string, error) {
groups := groupOverlaysByArchive(overlays)

if len(groups) == 0 {
return nil, nil
}

operationCount := 0
for _, group := range groups {
operationCount += len(group.overlays)
}

event := eventListener.StartEvent("Applying archive overlays",
"archives", len(groups),
"operations", operationCount,
)
defer event.End()

var repacked []string

for _, group := range groups {
didRepack, err := processArchive(dryRunnable, sourcesDirPath, group.archive, group.overlays)
if err != nil {
return nil, fmt.Errorf("archive overlay failed for %#q:\n%w", group.archive, err)
}

if didRepack {
repacked = append(repacked, group.archive)
}
}

return repacked, nil
}

// archiveGroup holds overlays targeting the same archive, preserving order.
type archiveGroup struct {
archive string
overlays []projectconfig.ComponentOverlay
}

// groupOverlaysByArchive groups archive overlays by [projectconfig.ComponentOverlay.Archive],
// preserving insertion order within each group and across groups.
// Non-archive overlays are silently skipped.
func groupOverlaysByArchive(overlays []projectconfig.ComponentOverlay) []archiveGroup {
orderMap := make(map[string]int)

var groups []archiveGroup

for _, overlay := range overlays {
if !overlay.ModifiesArchive() {
continue
}

archiveName := overlay.Archive

idx, exists := orderMap[archiveName]
if !exists {
idx = len(groups)
orderMap[archiveName] = idx

groups = append(groups, archiveGroup{archive: archiveName})
}

groups[idx].overlays = append(groups[idx].overlays, overlay)
}

return groups
}

// processArchive extracts an archive to a temp directory, applies all overlays,
// and deterministically repacks it with the original compression, atomically
// replacing the original via a temp file + rename. It returns true when the
Expand Down
54 changes: 54 additions & 0 deletions internal/app/azldev/core/sources/archiveoverlays_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,60 @@ import (
"github.com/stretchr/testify/require"
)

func TestGroupOverlaysByArchive(t *testing.T) {
t.Run("groups overlays by archive name preserving order", func(t *testing.T) {
overlays := []projectconfig.ComponentOverlay{
{
Type: projectconfig.ComponentOverlayRemoveFile,
Archive: "pkg-1.0.tar.gz",
Filename: "unwanted.conf",
},
{
Type: projectconfig.ComponentOverlayRemoveFile,
Archive: "pkg-1.0.tar.gz",
Filename: "config.h",
},
{
Type: projectconfig.ComponentOverlayRemoveFile,
Archive: "other-2.0.tar.xz",
Filename: "docs/*.md",
},
}

groups := groupOverlaysByArchive(overlays)

require.Len(t, groups, 2)

assert.Equal(t, "pkg-1.0.tar.gz", groups[0].archive)
require.Len(t, groups[0].overlays, 2)
assert.Equal(t, "unwanted.conf", groups[0].overlays[0].Filename)
assert.Equal(t, "config.h", groups[0].overlays[1].Filename)

assert.Equal(t, "other-2.0.tar.xz", groups[1].archive)
require.Len(t, groups[1].overlays, 1)
assert.Equal(t, "docs/*.md", groups[1].overlays[0].Filename)
})

t.Run("skips overlays that are not archive-scoped", func(t *testing.T) {
overlays := []projectconfig.ComponentOverlay{
{Type: projectconfig.ComponentOverlaySetSpecTag, Tag: "Version", Value: "1.0"},
{Type: projectconfig.ComponentOverlayRemoveFile, Archive: "pkg.tar.gz", Filename: "f"},
// Plain (non-archive) file overlay: no archive field, so it must be skipped.
{Type: projectconfig.ComponentOverlayRemoveFile, Filename: "loose.txt"},
// Bare archive name with no archive field: a loose removal of the archive itself.
{Type: projectconfig.ComponentOverlayRemoveFile, Filename: "drop-me.tar.gz"},
{Type: projectconfig.ComponentOverlayAddFile, Filename: "new.txt", Source: "src"},
}

groups := groupOverlaysByArchive(overlays)

require.Len(t, groups, 1)
assert.Equal(t, "pkg.tar.gz", groups[0].archive)
require.Len(t, groups[0].overlays, 1)
assert.Equal(t, "f", groups[0].overlays[0].Filename)
})
}

// TestProcessArchive_DryRunDoesNotModifyArchive verifies that, in dry-run mode,
// processArchive skips the extract/repack cycle entirely and leaves the original
// archive on disk byte-for-byte unchanged (repacking would otherwise rewrite it).
Expand Down
Loading