Skip to content
Open
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: 36 additions & 2 deletions 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 @@ -356,7 +356,41 @@ 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.
**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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you update the AI instruction files with this info?


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

- **`hash` and `hash-type` are required** for normal use. To bootstrap a new archive overlay, omit both and run `prep-sources --allow-no-hashes` once; it computes the post-overlay hash for you.
- **`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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we might want to still re-pack, this is mostly a UX optimization, not a real performance optimization (i.e. we still render the whole package even with --check-only, we just don't update the files at the end).

The point is to ensure that whatever the user configured in toml matches what we have in the specs dir, and without re-running the overlay scripts etc. I'm not sure we can make that guarantee.


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 --allow-no-hashes` once — this repacks the archive and writes the computed hash to the output `sources` file.
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

Expand Down
6 changes: 6 additions & 0 deletions docs/user/reference/config/overlays.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ file = "vendor/**" # files inside the archive
> share a single extract/modify/repack cycle. When wired into the source-preparation pipeline, the `sources` file
> should be rehashed afterward to reflect the repacked archive; they are processed independently of spec and loose-file overlays.

> **Required drift protection:** Every archive targeted by an archive overlay must have one matching
> `source-files` entry with `origin.type = "overlay"`. The entry records the expected post-overlay
> hash; one entry can cover multiple overlays for the same archive. While bootstrapping a new
> overlay, `prep-sources --allow-no-hashes` skips this association check and permits an origin
> entry to omit its hash. See [Components](components.md#recording-the-post-overlay-hash-for-archive-overlays).

> **Extraction root:** The inner path is interpreted relative to the archive's extraction root: if the archive unpacks to a single top-level directory (the conventional `%{name}-%{version}` layout) that directory is the root; otherwise the archive root is used.

> **Supported entry types:** Only regular files, directories, and symlinks are supported inside an archive overlay's target. If the archive contains an entry that cannot be repacked safely (a hardlink, device node, FIFO, etc.), the overlay fails with an error rather than silently dropping the entry from the repacked archive.
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
Loading