Skip to content

Commit f2c8de7

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 cafa031 commit f2c8de7

13 files changed

Lines changed: 992 additions & 24 deletions

docs/user/reference/config/overlays.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,44 @@ section = "%changelog"
210210
lines = ["- Fix CVE-2024-1234"]
211211
```
212212

213+
### Discovering components by pattern (`{component}` in `overlay-files`)
214+
215+
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.
216+
217+
Project-scope `overlay-files` entries **must** contain `{component}` exactly once, as a whole path segment. 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}`.
218+
219+
```toml
220+
# components.toml (project-level)
221+
[default-component-config]
222+
overlay-files = ["base/comps/{component}/overlays/*.overlay.toml"]
223+
```
224+
225+
With the layout below, no per-component `[components.<name>]` table is needed — `openssl` and `curl` are discovered automatically, each carrying its overlay files.
226+
227+
```
228+
base/comps/
229+
├── openssl/
230+
│ └── overlays/
231+
│ ├── 0001-cve-2024-1234.overlay.toml
232+
│ └── 0002-disable-fips-tests.overlay.toml
233+
└── curl/
234+
└── overlays/
235+
└── 0001-cve-2024-5678.overlay.toml
236+
```
237+
238+
**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 distro, component-group, or per-component scope, `overlay-files` entries are plain globs relative to the declaring config file; `{component}` is rejected there.
239+
240+
**Inheritance with `{component}`.** A project-level `overlay-files` entry containing `{component}` serves two purposes at once:
241+
242+
1. **Discovery.** Every captured directory becomes an implicit component unless there is already an explicit `[components.<name>]` table with the same name.
243+
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.
244+
245+
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.
246+
247+
**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.
248+
249+
**Shadowing.** An explicit `[components.<name>]` declaration supersedes pattern discovery for the same name. If that declaration also sets its own `overlay-files` (which suppresses inheritance), `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.
250+
213251
## Examples
214252

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

0 commit comments

Comments
 (0)