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
58 changes: 57 additions & 1 deletion docs/user/reference/config/overlays.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,9 @@ When a single logical change (a CVE backport, a feature disablement, a Fedora ch

Set `overlay-files` on the component to one or more globs (relative to the component config) and drop one overlay file per logical change into a directory of your choosing. The conventional layout uses a sibling `overlays/` directory and a `*.overlay.toml` filename suffix, but neither is required — `overlay-files` is just a glob, so any layout you can describe with `**`/`*` patterns works.

`overlay-files` can also be inherited from `default-component-config` at the project, distro, or component-group level. Inherited relative patterns are still resolved for each concrete component: from its component config file when it has one, or from the matched spec file's directory when it is discovered by a component group's `specs` pattern. This makes defaults useful for component-local discovery patterns such as `overlay-files = ["overlays/*.overlay.toml"]`. If a component sets `overlay-files`, that value replaces the inherited list; use `overlay-files = []` to disable inherited overlay files for a component, or include both patterns explicitly when you want to keep default discovery and add component-specific locations.
`overlay-files` on `[components.X]` is a plain glob resolved relative to the component's config file. `overlay-files` can also be set on the project-level `default-component-config`, where **every entry must contain the `{component}` placeholder** (see [Discovering components by pattern](#discovering-components-by-pattern-component-in-overlay-files) below). A component that doesn't declare its own `overlay-files` inherits the project pattern with `{component}` substituted for its own name; use `overlay-files = []` at component scope to disable the inherited list, or set your own list to replace it wholesale.

`overlay-files` is only settable at project scope (the top-level `default-component-config`) or at component scope (`[components.X]`). Setting it inside a distro-version or component-group `default-component-config` is rejected at load time — even an empty list (`overlay-files = []`).

```
base/comps/mypackage/
Expand Down Expand Up @@ -210,6 +212,60 @@ section = "%changelog"
lines = ["- Fix CVE-2024-1234"]
```

### Discovering components by pattern (`{component}` in `overlay-files`)

For projects that keep each component's overlay files in a predictable per-component directory (e.g. `base/comps/<name>/overlays/*.overlay.toml`), you can skip declaring a one-line `[components.<name>]` table for every component. Set `overlay-files` on the **project-level** `default-component-config` to a pattern containing `{component}`, and every match becomes an implicit component.

Project-scope `overlay-files` entries **must** contain `{component}` exactly once, as a whole path segment, and **must** have at least one more path segment after it (the placeholder names a component directory, not a file). Additional constraints enforced at load time:

* Use `/` as the path separator (backslashes are rejected, even on Windows).
* No glob metacharacters (`*`, `?`, `[`) before `{component}` — the prefix is a literal used to capture the component name.
* The rest of the entry (after `{component}/`) must be a valid `doublestar` glob (validated with `doublestar.ValidatePattern`).
* The captured segment must be a safe component name (no spaces, no path traversal, no absolute paths); matches with unsafe names are skipped with a `slog.Debug` entry.

For each entry, `{component}` is expanded to `*` for globbing, and the captured path segment becomes the discovered component's name. The matched files are attached to that component (as if it had declared `overlay-files = [<matched paths>]` explicitly). Explicit components with no `overlay-files` of their own inherit the same pattern and re-expand it with their own name substituted for `{component}`.

```toml
# components.toml (project-level)
[default-component-config]
overlay-files = ["base/comps/{component}/overlays/*.overlay.toml"]
```

With the layout below, no per-component `[components.<name>]` table is needed — `openssl` and `curl` are discovered automatically, each carrying its overlay files.

```
base/comps/
├── openssl/
│ └── overlays/
│ ├── 0001-cve-2024-1234.overlay.toml
│ └── 0002-disable-fips-tests.overlay.toml
└── curl/
└── overlays/
└── 0001-cve-2024-5678.overlay.toml
```

**Where `{component}` is allowed.** Only in **project-level** `default-component-config`. At every project-level `overlay-files` entry the placeholder is *required* — plain globs at that scope are rejected because they would apply the same files to every component in the project, which is almost never what you want. At component scope, `overlay-files` entries are plain globs relative to the declaring config file; `{component}` is rejected there. `overlay-files` cannot be set at all in distro-version or component-group `default-component-config` — those broad-scope defaults would silently displace project-level discovery.

**Inheritance with `{component}`.** A project-level `overlay-files` entry containing `{component}` serves two purposes at once:

1. **Discovery.** Every captured directory becomes an implicit component unless there is already an explicit `[components.<name>]` table with the same name.
2. **Inheritance.** An explicitly-declared component that does not override `overlay-files` inherits the project entry and expands `{component}` with its own name at glob time.

A component that sets its own `overlay-files = [...]` replaces the inherited value; the placeholder pattern no longer applies to that component. Setting `overlay-files = []` disables inheritance entirely for that component.

**Same-scope collisions are a hard error.** If two project-scope `overlay-files` entries both produce the same component name, the resolver fails with a diagnostic listing both entries. Restrict one of the entries or rename one of the directories.

**Shadowing.** An explicit `[components.<name>]` declaration supersedes pattern discovery for the same name. If that declaration also sets a non-empty `overlay-files` list (which replaces inheritance with a different set of files), `azldev` emits an `slog.Warn` naming the shadowed pattern so you can spot the genuine displacement. An explicit declaration that leaves `overlay-files` unset still inherits the project pattern, so the discovered files apply and no warning is emitted. An explicit empty list (`overlay-files = []`) is the documented off-switch for a component and is not treated as shadowing.

**Limitation: explicit component-group members need a `[components.<name>]` table.** A component group can enumerate its members two ways: via a `specs` glob (spec files on disk) or via an explicit `components = ["<name>", ...]` list. Only the *explicit list* interacts with pattern discovery: every name in that list must have a matching `[components.<name>]` table in the project (validated at load time, error `ErrUndefinedComponent`). Even a one-liner is enough:

```toml
# base/comps/openssl/openssl.comp.toml
[components.openssl]
```

Once the table exists, the component inherits the project-level `{component}` overlay pattern normally. Group members discovered via `specs` (not the explicit list) do **not** need a `.comp.toml` — the resolver synthesizes an empty config for them and inheritance still applies.

## Examples

### Adding a Build Dependency
Expand Down
217 changes: 217 additions & 0 deletions internal/app/azldev/core/components/pattern_discovery.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

package components

import (
"errors"
"fmt"
"log/slog"
"path"
"path/filepath"
"slices"
"strings"

"github.com/bmatcuk/doublestar/v4"
"github.com/microsoft/azure-linux-dev-tools/internal/app/azldev"
"github.com/microsoft/azure-linux-dev-tools/internal/projectconfig"
"github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils"
)

// patternDiscoveredComponent captures the result of expanding a single
// project-level [projectconfig.ComponentConfig.OverlayFiles] entry that
// contains the `{component}` placeholder.
type patternDiscoveredComponent struct {
// Name of the discovered component (the captured {component} segment).
Name string
// ReferenceDir is the absolute directory the pattern captured for this
// component (i.e. the prefix directory + captured name). Used as the
// component's reference directory for downstream operations.
ReferenceDir string
// OverlayFiles is the deduplicated, sorted list of absolute overlay file
// paths matched by the winning pattern for this component.
OverlayFiles []string
// SourcePattern is the exact entry string that produced this match, used
// in shadow-lint diagnostics.
SourcePattern string
}

// discoverComponentsFromPatterns walks the project-level
// default-component-config, expands every 'overlay-files' entry that contains
// the `{component}` placeholder, and returns the resulting pattern-discovered
// components. Two entries at project scope producing the same component name
// is a hard error.
//
// The caller is responsible for further precedence against explicitly-declared
// components (that override happens at the resolver layer; see
// [Resolver.patternDiscoveredComponents]).
func discoverComponentsFromPatterns(env *azldev.Env) (map[string]*patternDiscoveredComponent, error) {
result := make(map[string]*patternDiscoveredComponent)

config := env.Config()
if config == nil {
return result, nil
}

var patterns []string

for _, entry := range config.DefaultComponentConfig.OverlayFiles {
if strings.Contains(entry, projectconfig.OverlayFilesComponentPlaceholder) {
patterns = append(patterns, entry)
}
}

if len(patterns) == 0 {
return result, nil
}

for _, pattern := range patterns {
byName, err := expandSinglePattern(env, pattern)
if err != nil {
return nil, fmt.Errorf("project 'overlay-files': %w", err)
}

for name, match := range byName {
if existing, ok := result[name]; ok {
return nil, fmt.Errorf(
"%w: component %#q is discovered by both %#q and %#q; "+
"restrict one of the patterns or rename one of the directories",
ErrPatternDiscoveryCollision, name, existing.SourcePattern, match.SourcePattern,
)
}

result[name] = match
}
}

return result, nil
}

// expandSinglePattern expands a single validated pattern and groups matched
// files by captured component name.
func expandSinglePattern(env *azldev.Env, pattern string) (map[string]*patternDiscoveredComponent, error) {
prefix, suffix := projectconfig.SplitOverlayFilesPlaceholder(pattern)

// Substitute a single-segment wildcard for {component} to build the actual
// glob. Because {component} is validated as a whole path segment (see
// ConfigFile.Validate), replacing it with "*" produces a well-formed
// doublestar pattern that captures exactly one path segment.
globPattern := prefix + "*" + suffix

// doublestar.WithFailOnIOErrors() is intentionally not set, mirroring
// findComponentGroupSpecPaths: we may legitimately hit unreadable subtrees
// under the project root. Non-IO errors (bad pattern, etc.) are still
// returned.
matches, err := fileutils.Glob(env.FS(), globPattern, doublestar.WithFilesOnly())
if err != nil {
return nil, fmt.Errorf("failed to expand pattern %#q:\n%w", pattern, err)
}

byName := make(map[string]*patternDiscoveredComponent)

for _, match := range matches {
name, referenceDir, err := extractCapturedName(pattern, prefix, match)
if err != nil {
// Skip matches we can't parse (should not happen with a validated
// pattern, but guard anyway).
slog.Debug("pattern-discovery: skipping unparseable match",
"pattern", pattern, "match", match, "err", err)

continue
}

// Reject matches whose captured segment is not a safe component name
// (spaces, path traversal, absolute paths, etc). Downstream code uses
// the name as a filesystem path fragment (see fileutils.ValidateFilename)
// so we fail fast rather than let it explode later.
if err := fileutils.ValidateFilename(name); err != nil {
slog.Debug("pattern-discovery: skipping match with unsafe captured name",
"pattern", pattern, "match", match, "name", name, "err", err)

continue
}

bucket, ok := byName[name]
if !ok {
bucket = &patternDiscoveredComponent{
Name: name,
ReferenceDir: referenceDir,
SourcePattern: pattern,
}
byName[name] = bucket
}

bucket.OverlayFiles = append(bucket.OverlayFiles, match)
}

// Sort the overlay-files list per component (filename first, then full path).
for _, bucket := range byName {
slices.SortFunc(bucket.OverlayFiles, func(left, right string) int {
if result := strings.Compare(filepath.Base(left), filepath.Base(right)); result != 0 {
return result
}

return strings.Compare(left, right)
})
}

return byName, nil
}

// extractCapturedName strips prefix from match and returns the first path
// segment (the captured component name) together with the concrete directory
// that captured segment lives in. The returned ReferenceDir is the natural
// per-component anchor for downstream operations.
func extractCapturedName(pattern, prefix, match string) (name, referenceDir string, err error) {
rel := match
if prefix != "" {
if !strings.HasPrefix(match, prefix) {
return "", "", fmt.Errorf(
"match %#q does not start with expected prefix %#q for pattern %#q",
match, prefix, pattern,
)
}

rel = match[len(prefix):]
}

// The captured segment is everything up to (but not including) the first
// path separator in the residual, or the entire residual if there is no
// suffix. Patterns are validated to use '/' only.
sep := strings.IndexByte(rel, '/')
if sep < 0 {
name = rel
} else {
name = rel[:sep]
}

if name == "" {
return "", "", fmt.Errorf(
"empty captured component name for match %#q against pattern %#q",
match, pattern,
)
}

// The reference dir is the prefix + captured segment (no trailing separator).
// Use path.Clean (POSIX '/') rather than filepath.Clean so ReferenceDir stays
// consistent with the placeholder contract, which validates and operates on
// POSIX-style separators only (filepath.Clean would emit '\' on Windows).
referenceDir = path.Clean(prefix + name)

return name, referenceDir, nil
}

// logShadowedByDeclaration emits a warning when an explicit component
// declaration overrides pattern-discovered overlay files for the same name.
func logShadowedByDeclaration(shadowed *patternDiscoveredComponent) {
slog.Warn(
"project 'overlay-files' pattern match shadowed by explicit component declaration; overlay files will not be applied",

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 this might miss shadowing from project defaults by groups. Not sure if thats something worth an error though.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This would be resolved by the scope restriction, now rejecting overlay-files in distro-version and component-group default-component-config entirely.

"component", shadowed.Name,
"shadowed_pattern", shadowed.SourcePattern,
"shadowed_files", shadowed.OverlayFiles,
)
}

// ErrPatternDiscoveryCollision wraps project-scope pattern collision errors so
// tests can assert on them.
var ErrPatternDiscoveryCollision = errors.New("project 'overlay-files' pattern collision")
Loading
Loading