Skip to content

Commit fc2fd2b

Browse files
committed
feat(overlay-files): support {component} placeholder for pattern-discovered components
Add '{component}' placeholder in project-level overlay-files entries. The resolver globs matching paths, extracts each captured segment as a component name, and synthesizes components not otherwise declared. Explicit component declarations shadow pattern-discovered matches (warning emitted). - projectconfig: validate placeholder syntax per scope; reject backslash separators and glob metachars before the placeholder - components: pattern_discovery walks overlay-files entries; Resolver caches discovery results per instance - schema/docs: user-facing overlays.md updated
1 parent 88295ee commit fc2fd2b

13 files changed

Lines changed: 1081 additions & 26 deletions

docs/user/reference/config/overlays.md

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,9 @@ When a single logical change (a CVE backport, a feature disablement, a Fedora ch
163163

164164
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.
165165

166-
`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.
166+
`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.
167+
168+
`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 = []`).
167169

168170
```
169171
base/comps/mypackage/
@@ -210,6 +212,53 @@ section = "%changelog"
210212
lines = ["- Fix CVE-2024-1234"]
211213
```
212214

215+
### Discovering components by pattern (`{component}` in `overlay-files`)
216+
217+
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.
218+
219+
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). 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}`.
220+
221+
```toml
222+
# components.toml (project-level)
223+
[default-component-config]
224+
overlay-files = ["base/comps/{component}/overlays/*.overlay.toml"]
225+
```
226+
227+
With the layout below, no per-component `[components.<name>]` table is needed — `openssl` and `curl` are discovered automatically, each carrying its overlay files.
228+
229+
```
230+
base/comps/
231+
├── openssl/
232+
│ └── overlays/
233+
│ ├── 0001-cve-2024-1234.overlay.toml
234+
│ └── 0002-disable-fips-tests.overlay.toml
235+
└── curl/
236+
└── overlays/
237+
└── 0001-cve-2024-5678.overlay.toml
238+
```
239+
240+
**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.
241+
242+
**Inheritance with `{component}`.** A project-level `overlay-files` entry containing `{component}` serves two purposes at once:
243+
244+
1. **Discovery.** Every captured directory becomes an implicit component unless there is already an explicit `[components.<name>]` table with the same name.
245+
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.
246+
247+
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.
248+
249+
**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.
250+
251+
**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.
252+
253+
**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:
254+
255+
```toml
256+
# base/comps/openssl/openssl.comp.toml
257+
[components.openssl]
258+
```
259+
260+
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.
261+
213262
## Examples
214263

215264
### Adding a Build Dependency
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
package components
5+
6+
import (
7+
"errors"
8+
"fmt"
9+
"log/slog"
10+
"path"
11+
"path/filepath"
12+
"slices"
13+
"strings"
14+
15+
"github.com/bmatcuk/doublestar/v4"
16+
"github.com/microsoft/azure-linux-dev-tools/internal/app/azldev"
17+
"github.com/microsoft/azure-linux-dev-tools/internal/projectconfig"
18+
"github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils"
19+
)
20+
21+
// patternDiscoveredComponent captures the result of expanding a single
22+
// project-level [projectconfig.ComponentConfig.OverlayFiles] entry that
23+
// contains the `{component}` placeholder.
24+
type patternDiscoveredComponent struct {
25+
// Name of the discovered component (the captured {component} segment).
26+
Name string
27+
// ReferenceDir is the absolute directory the pattern captured for this
28+
// component (i.e. the prefix directory + captured name). Used as the
29+
// component's reference directory for downstream operations.
30+
ReferenceDir string
31+
// OverlayFiles is the deduplicated, sorted list of absolute overlay file
32+
// paths matched by the winning pattern for this component.
33+
OverlayFiles []string
34+
// SourcePattern is the exact entry string that produced this match, used
35+
// in shadow-lint diagnostics.
36+
SourcePattern string
37+
}
38+
39+
// discoverComponentsFromPatterns walks the project-level
40+
// default-component-config, expands every 'overlay-files' entry that contains
41+
// the `{component}` placeholder, and returns the resulting pattern-discovered
42+
// components. Two entries at project scope producing the same component name
43+
// is a hard error.
44+
//
45+
// The caller is responsible for further precedence against explicitly-declared
46+
// components (that override happens at the resolver layer; see
47+
// [Resolver.patternDiscoveredComponents]).
48+
func discoverComponentsFromPatterns(env *azldev.Env) (map[string]*patternDiscoveredComponent, error) {
49+
result := make(map[string]*patternDiscoveredComponent)
50+
51+
config := env.Config()
52+
if config == nil {
53+
return result, nil
54+
}
55+
56+
var patterns []string
57+
58+
for _, entry := range config.DefaultComponentConfig.OverlayFiles {
59+
if strings.Contains(entry, projectconfig.OverlayFilesComponentPlaceholder) {
60+
patterns = append(patterns, entry)
61+
}
62+
}
63+
64+
if len(patterns) == 0 {
65+
return result, nil
66+
}
67+
68+
for _, pattern := range patterns {
69+
byName, err := expandSinglePattern(env, pattern)
70+
if err != nil {
71+
return nil, fmt.Errorf("project 'overlay-files': %w", err)
72+
}
73+
74+
for name, match := range byName {
75+
if existing, ok := result[name]; ok {
76+
return nil, fmt.Errorf(
77+
"%w: component %#q is discovered by both %#q and %#q; "+
78+
"restrict one of the patterns or rename one of the directories",
79+
ErrPatternDiscoveryCollision, name, existing.SourcePattern, match.SourcePattern,
80+
)
81+
}
82+
83+
result[name] = match
84+
}
85+
}
86+
87+
return result, nil
88+
}
89+
90+
// expandSinglePattern expands a single validated pattern and groups matched
91+
// files by captured component name.
92+
func expandSinglePattern(env *azldev.Env, pattern string) (map[string]*patternDiscoveredComponent, error) {
93+
prefix, suffix := projectconfig.SplitOverlayFilesPlaceholder(pattern)
94+
95+
// Substitute a single-segment wildcard for {component} to build the actual
96+
// glob. Because {component} is validated as a whole path segment (see
97+
// ConfigFile.Validate), replacing it with "*" produces a well-formed
98+
// doublestar pattern that captures exactly one path segment.
99+
globPattern := prefix + "*" + suffix
100+
101+
// Ignore IO errors here, mirroring findComponentGroupSpecPaths: we may
102+
// legitimately hit unreadable subtrees under the project root.
103+
matches, err := fileutils.Glob(env.FS(), globPattern, doublestar.WithFilesOnly())
104+
if err != nil {
105+
return nil, fmt.Errorf("failed to expand pattern %#q:\n%w", pattern, err)
106+
}
107+
108+
byName := make(map[string]*patternDiscoveredComponent)
109+
110+
for _, match := range matches {
111+
name, referenceDir, err := extractCapturedName(pattern, prefix, match)
112+
if err != nil {
113+
// Skip matches we can't parse (should not happen with a validated
114+
// pattern, but guard anyway).
115+
slog.Debug("pattern-discovery: skipping unparseable match",
116+
"pattern", pattern, "match", match, "err", err)
117+
118+
continue
119+
}
120+
121+
bucket, ok := byName[name]
122+
if !ok {
123+
bucket = &patternDiscoveredComponent{
124+
Name: name,
125+
ReferenceDir: referenceDir,
126+
SourcePattern: pattern,
127+
}
128+
byName[name] = bucket
129+
}
130+
131+
bucket.OverlayFiles = append(bucket.OverlayFiles, match)
132+
}
133+
134+
// Sort the overlay-files list per component (filename first, then full path).
135+
for _, bucket := range byName {
136+
slices.SortFunc(bucket.OverlayFiles, func(left, right string) int {
137+
if result := strings.Compare(filepath.Base(left), filepath.Base(right)); result != 0 {
138+
return result
139+
}
140+
141+
return strings.Compare(left, right)
142+
})
143+
}
144+
145+
return byName, nil
146+
}
147+
148+
// extractCapturedName strips prefix from match and returns the first path
149+
// segment (the captured component name) together with the concrete directory
150+
// that captured segment lives in. The returned ReferenceDir is the natural
151+
// per-component anchor for downstream operations.
152+
func extractCapturedName(pattern, prefix, match string) (name, referenceDir string, err error) {
153+
rel := match
154+
if prefix != "" {
155+
if !strings.HasPrefix(match, prefix) {
156+
return "", "", fmt.Errorf(
157+
"match %#q does not start with expected prefix %#q for pattern %#q",
158+
match, prefix, pattern,
159+
)
160+
}
161+
162+
rel = match[len(prefix):]
163+
}
164+
165+
// The captured segment is everything up to (but not including) the first
166+
// path separator in the residual, or the entire residual if there is no
167+
// suffix. Patterns are validated to use '/' only.
168+
sep := strings.IndexByte(rel, '/')
169+
if sep < 0 {
170+
name = rel
171+
} else {
172+
name = rel[:sep]
173+
}
174+
175+
if name == "" {
176+
return "", "", fmt.Errorf(
177+
"empty captured component name for match %#q against pattern %#q",
178+
match, pattern,
179+
)
180+
}
181+
182+
// The reference dir is the prefix + captured segment (no trailing separator).
183+
// Use path.Clean (POSIX '/') rather than filepath.Clean so ReferenceDir stays
184+
// consistent with the placeholder contract, which validates and operates on
185+
// POSIX-style separators only (filepath.Clean would emit '\' on Windows).
186+
referenceDir = path.Clean(prefix + name)
187+
188+
return name, referenceDir, nil
189+
}
190+
191+
// logShadowedByDeclaration emits a warning when an explicit component
192+
// declaration overrides pattern-discovered overlay files for the same name.
193+
func logShadowedByDeclaration(shadowed *patternDiscoveredComponent) {
194+
slog.Warn(
195+
"project 'overlay-files' pattern match shadowed by explicit component declaration; overlay files will not be applied",
196+
"component", shadowed.Name,
197+
"shadowed_pattern", shadowed.SourcePattern,
198+
"shadowed_files", shadowed.OverlayFiles,
199+
)
200+
}
201+
202+
// ErrPatternDiscoveryCollision wraps project-scope pattern collision errors so
203+
// tests can assert on them.
204+
var ErrPatternDiscoveryCollision = errors.New("project 'overlay-files' pattern collision")

0 commit comments

Comments
 (0)