Skip to content
Merged
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
59 changes: 58 additions & 1 deletion docs/user/reference/config/overlays.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,31 @@ successfully makes a replacement to at least one matching file.
| `file-remove` | Removes a file | `file` | Glob pattern for files to remove |
| `file-rename` | Renames a file within the same directory | `file`, `replacement` | Name of file to rename |

> **Tip:** `file-remove` and `file-search-replace` can also operate inside a source archive by setting the `archive` field — see [Archive Overlays](#archive-overlays).

### Archive Overlays

A `file-remove` or `file-search-replace` overlay can modify files **inside** a source archive
instead of loose files in the sources tree by setting the `archive` field to the archive name
and the `file` field to a glob matched against the extracted archive contents. The archive is extracted,
the matching files are modified with the same machinery as loose-file overlays, and the archive is
repacked with its original compression format.

```
archive = "pkg-1.0.tar.gz"
file = "vendor/**" # files inside the archive
```

> **Note:** Archive overlays are batched per archive — all overlays targeting the same 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.

> **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.

| `file-remove` (archive-scoped) | Removes file(s) matching a glob pattern from inside an archive | `archive`, `file` |
| `file-search-replace` (archive-scoped) | Regex-based search and replace on file(s) inside an archive | `archive`, `file`, `regex` |
## Field Reference

| Field | TOML Key | Description | Used By |
Expand All @@ -60,7 +85,8 @@ successfully makes a replacement to at least one matching file.
| 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` |
| File | `file` | The name of the non-spec file to modify or add | `file-prepend-lines`, `file-search-replace`, `file-add`, `file-remove`, `file-rename`, `patch-add` (optional), `patch-remove` |
| File | `file` | The name of the non-spec file to modify or add, or a glob pattern. When combined with the `archive` field, the glob is matched against files inside that source archive. | `file-prepend-lines`, `file-search-replace`, `file-add`, `file-remove`, `file-rename`, `patch-add` (optional), `patch-remove` |
| Archive | `archive` | The source archive to extract, modify, and repack (e.g. `pkg-1.0.tar.gz`). When set, `file` is a glob matched relative to the archive's extraction root. | `file-remove`, `file-search-replace` (optional) |
| Source | `source` | Path to source file for `file-add` and `patch-add`; relative paths are relative to the config file that defines the overlay (the overlay file if loaded via [`overlay-files`](#per-file-overlay-format), otherwise the component config) | `file-add`, `patch-add` |
| Metadata | `metadata` | Documentation table describing intent and provenance — see [Overlay Metadata](#overlay-metadata). Not allowed inside an overlay file loaded via `overlay-files` (the file-level `[metadata]` block applies to every overlay in the file). | All (optional) |

Expand Down Expand Up @@ -486,6 +512,37 @@ description = "Remove CVE patches that are now upstream"
> `PatchN` tags. Macro-based tag numbering (e.g., `Patch%{n}`) is not expanded and may
> conflict with auto-assigned numbers.

### Removing a File from an Archive

Set `archive` to the archive name and `file` to a glob to delete files matching the pattern from
inside the source archive. The archive is extracted, matching files are removed, and the archive is
repacked.

```toml
[[components.mypackage.overlays]]
type = "file-remove"
archive = "mypackage-1.0.tar.gz"
file = "vendor/**"
description = "Remove all bundled vendor files"
```

> **Tip:** Without the `archive` field, the same `file-remove` overlay removes a loose file
> from the sources tree instead.

### Search and Replace Inside an Archive

Set `archive` to the archive name to rewrite content inside an archive:

```toml
[[components.mypackage.overlays]]
type = "file-search-replace"
archive = "mypackage-1.0.tar.xz"
file = "configure.ac"
regex = "AC_CHECK_LIB\\(old_lib"
replacement = "AC_CHECK_LIB(new_lib"
description = "Update library reference in configure script"
```

### Removing a Section

The `spec-remove-section` overlay removes an entire section from the spec, including its
Expand Down
186 changes: 186 additions & 0 deletions internal/app/azldev/core/sources/archiveoverlays.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

package sources

import (
"fmt"
"log/slog"
"os"
"path/filepath"

"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/utils/archive"
"github.com/microsoft/azure-linux-dev-tools/internal/utils/rootfs"
)

// 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
// archive was repacked. In dry-run mode it returns (false, nil) without touching
// the archive on disk.
func processArchive(
dryRunnable opctx.DryRunnable,
sourcesDirPath string,
archiveName string,
overlays []projectconfig.ComponentOverlay,
) (repacked bool, err error) {
archivePath := filepath.Join(sourcesDirPath, archiveName)

if dryRunnable.DryRun() {
slog.Info("Dry run; would apply archive overlays",
"archive", archiveName, "operations", len(overlays))

return false, nil
}

// The [archive] package operates exclusively through OS primitives ([os.Root],
// os.*), so extraction must use a genuine on-disk path regardless of the injected
// FS implementation (which may be in-memory or otherwise non-OS-backed). Keep
// that directory outside the sources tree: a process crash could otherwise leave
// it behind for subsequent loose-file overlay globs to match.
workDir, err := os.MkdirTemp("", "azldev-archive-overlay-")
if err != nil {
return false, fmt.Errorf("creating temp directory:\n%w", err)
}

defer func() {
if removeErr := os.RemoveAll(workDir); removeErr != nil {
slog.Warn("Failed to clean up archive work directory", "error", removeErr)
}
}()

// Extract the archive; compression is inferred from the filename extension.
// Fail on entry types we can't repack (hardlinks, devices, ...) so they
// aren't silently dropped from the repacked archive.
if err := archive.ExtractAuto(archivePath, workDir, archive.WithErrorOnUnsupportedEntry()); err != nil {
return false, fmt.Errorf("extracting archive:\n%w", err)
}

// Determine the root of the extracted content. Most source archives unpack to
// a single top-level directory (e.g., "pkg-1.0/"), which is used as the root.
extractRoot, err := resolveExtractRoot(workDir)
if err != nil {
return false, fmt.Errorf("resolving extract root:\n%w", err)
}

// Confine an OS-backed FS to the extract root so file overlays reuse the same
// machinery as loose-file overlays.
extractFS, err := rootfs.New(extractRoot)
if err != nil {
return false, fmt.Errorf("confining FS to extract root:\n%w", err)
}

defer func() {
if closeErr := extractFS.Close(); closeErr != nil {
slog.Warn("Failed to close extract-root FS", "error", closeErr)
}
}()

// Apply each overlay in order. Archive overlays (file-remove / file-search-replace,
// see [projectconfig.ComponentOverlay.ModifiesArchive]) operate solely on the
// destination tree, so extractFS is passed as both source and destination FS.
for _, overlay := range overlays {
if err := applyNonSpecOverlay(dryRunnable, extractFS, extractFS, overlay); err != nil {
return false, fmt.Errorf("applying %#q operation:\n%w", overlay.Type, err)
}
}

// Deterministically repack the archive, reusing the original compression, and
// atomically replace the original (see [repackArchiveAtomic]).
if err := repackArchiveAtomic(archivePath, archiveName, workDir); err != nil {
return false, err
}

slog.Info("Archive overlay applied", "archive", archiveName)

return true, nil
}

// repackArchiveAtomic deterministically repacks workDir into an archive that
// replaces archivePath, reusing the compression inferred from archiveName.
//
// To avoid corrupting the fetched source archive on a mid-write failure (disk
// full, permission error, etc.), it repacks to a temp file in the same directory
// and atomically renames it over the original only on success. Repacking directly
// over archivePath would truncate it first, leaving the workspace unrecoverable
// without refetching if the repack then failed.
func repackArchiveAtomic(archivePath, archiveName, workDir string) (err error) {
archiveInfo, err := os.Stat(archivePath)
if err != nil {
return fmt.Errorf("stating original archive %#q:\n%w", archiveName, err)
}

originalPerm := archiveInfo.Mode().Perm()

// Fall back to the extension only if the archive is empty (nothing to sniff).
extComp, err := archive.DetectCompression(archiveName)
if err != nil {
return fmt.Errorf("detecting compression for %#q:\n%w", archiveName, err)
}

comp, err := archive.SniffCompressionFromFile(archivePath, extComp)
if err != nil {
return fmt.Errorf("sniffing compression for %#q:\n%w", archiveName, err)
}

tmpFile, err := os.CreateTemp(filepath.Dir(archivePath), "."+filepath.Base(archiveName)+".repack-*")
Comment thread
Tonisal-byte marked this conversation as resolved.
if err != nil {
return fmt.Errorf("creating temp archive:\n%w", err)
}

tmpPath := tmpFile.Name()

// Close the handle immediately; CreateDeterministicArchive truncates and
// reopens this uniquely-created path.
if closeErr := tmpFile.Close(); closeErr != nil {
_ = os.Remove(tmpPath)

return fmt.Errorf("closing temp archive %#q:\n%w", tmpPath, closeErr)
}

// Clean up the temp file unless it was successfully renamed over the original.
repackedOK := false

defer func() {
if !repackedOK {
if removeErr := os.Remove(tmpPath); removeErr != nil && !os.IsNotExist(removeErr) {
slog.Warn("Failed to clean up temp archive", "path", tmpPath, "error", removeErr)
}
}
}()

if err := archive.CreateDeterministicArchive(tmpPath, workDir, comp); err != nil {
return fmt.Errorf("repacking archive:\n%w", err)
}

if err := os.Chmod(tmpPath, originalPerm); err != nil {
return fmt.Errorf("restoring permissions on repacked archive %#q:\n%w", archiveName, err)
}

if err := os.Rename(tmpPath, archivePath); err != nil {
return fmt.Errorf("replacing archive %#q with repacked archive:\n%w", archivePath, err)
}

repackedOK = true

return nil
}

// resolveExtractRoot returns the effective root of an extracted archive. If workDir
// contains exactly one entry and that entry is a directory (the common case for
// source archives like "pkg-1.0/"), that subdirectory is returned; otherwise workDir
// itself is returned.
func resolveExtractRoot(workDir string) (string, error) {
entries, err := os.ReadDir(workDir)
if err != nil {
return "", fmt.Errorf("reading extracted directory:\n%w", err)
}

if len(entries) == 1 && entries[0].IsDir() {
return filepath.Join(workDir, entries[0].Name()), nil
}

return workDir, nil
}
Loading
Loading