Skip to content

Commit 4cbb7c4

Browse files
ddstreetCopilot
andcommitted
feat(projectconfig): load configuration without lock files
Teach the project configuration loader the lock-file-free mode selected by '--without-lockfile'. In that mode component definitions merge with override semantics rather than additively, so a generated upstream-commit TOML can replace a component's configured pin, and the config file that supplied the pin is recorded separately from the component's primary TOML so synthetic history can follow it. Because a single file may now hold a partial component definition, component validation is deferred until the whole project is assembled. The project's lock directory is left unset in this mode; a project may still declare 'lock-dir' for compatibility with the default mode, where the value keeps its existing meaning. The default mode is unchanged: components merge additively, each config file is validated on its own, and the lock directory keeps its default. The load-time modes are carried in an internal loadOptions value instead of adding more positional booleans to every loader helper. Refs: microsoft#323 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 78c489f commit 4cbb7c4

10 files changed

Lines changed: 509 additions & 121 deletions

File tree

internal/app/azldev/app.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,7 @@ func (a *App) findAndLoadConfig(tempDirPath string, extraConfigFiles []string) (
604604
tempDirPath,
605605
extraConfigFiles,
606606
a.permissiveConfigParsing,
607+
a.withoutLockfile,
607608
)
608609
if err != nil {
609610
return projectDir, config, fmt.Errorf("failed to load project configuration:\n%w", err)

internal/app/azldev/cmds/component/history_internal_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,12 @@ func TestCustomizationCollectorsCoverEveryFingerprintableField(t *testing.T) {
157157
field := st.Field(i)
158158
key := st.Name() + "." + field.Name
159159

160+
// Unexported fields are never fingerprinted: hashstructure skips
161+
// them because it cannot read them by reflection.
162+
if field.PkgPath != "" {
163+
continue
164+
}
165+
160166
// Fields excluded from the fingerprint are operational
161167
// metadata (publish channels, build hints, maintenance
162168
// markers, etc.), not modifications to upstream. Skip them.

internal/projectconfig/component.go

Lines changed: 84 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,13 @@ type ComponentConfig struct {
368368

369369
// Reference to the source config file that this definition came from; not present
370370
// in serialized files.
371-
SourceConfigFile *ConfigFile `toml:"-" json:"-" table:"-" fingerprint:"-"`
371+
SourceConfigFile *ConfigFile `toml:"-" json:"-" table:"-" validate:"-" fingerprint:"-"`
372+
373+
// upstreamCommitConfigFile references the config file that supplied the
374+
// component's upstream commit pin. Populated in lock-file-free mode, where
375+
// synthetic history must follow the file that actually changed the pin even
376+
// when another partial component definition is merged later.
377+
upstreamCommitConfigFile *ConfigFile
372378

373379
// RenderedSpecDir is the output directory for this component's rendered spec files.
374380
// Derived at resolve time from the project's rendered-specs-dir setting; not present
@@ -458,6 +464,71 @@ func (c *ComponentConfig) MergeUpdatesFrom(other *ComponentConfig) error {
458464
return nil
459465
}
460466

467+
// MergeOverridesFrom mutates the component config so that values present in other
468+
// replace the existing ones, instead of being merged additively into them.
469+
//
470+
// Used in lock-file-free mode, where a generated upstream-commit config file holds
471+
// a partial component definition whose 'spec' block must override the component's
472+
// primary definition rather than merge with it. Slices in the build config are
473+
// still appended, matching [ComponentConfig.MergeUpdatesFrom].
474+
func (c *ComponentConfig) MergeOverridesFrom(other *ComponentConfig) error {
475+
otherOverlayFiles := slices.Clone(other.OverlayFiles)
476+
477+
// Merge the nested config blocks separately so that mergo does not descend
478+
// into them with slice-appending semantics.
479+
otherTopLevel := *other
480+
otherTopLevel.Spec = SpecSource{}
481+
otherTopLevel.Release = ReleaseConfig{}
482+
otherTopLevel.Build = ComponentBuildConfig{}
483+
otherTopLevel.Render = ComponentRenderConfig{}
484+
otherTopLevel.Publish = ComponentPublishConfig{}
485+
486+
err := mergo.Merge(c, &otherTopLevel, mergo.WithOverride, mergo.WithAppendSlice)
487+
if err != nil {
488+
return fmt.Errorf("failed to merge project info:\n%w", err)
489+
}
490+
491+
for destination, source := range map[any]any{
492+
&c.Spec: &other.Spec,
493+
&c.Release: &other.Release,
494+
&c.Render: &other.Render,
495+
&c.Publish: &other.Publish,
496+
} {
497+
if err := mergo.Merge(destination, source, mergo.WithOverride); err != nil {
498+
return fmt.Errorf("failed to merge component config:\n%w", err)
499+
}
500+
}
501+
502+
if err := mergo.Merge(&c.Build, &other.Build, mergo.WithOverride, mergo.WithAppendSlice); err != nil {
503+
return fmt.Errorf("failed to merge component build config:\n%w", err)
504+
}
505+
506+
if other.SourceConfigFile != nil {
507+
c.SourceConfigFile = other.SourceConfigFile
508+
}
509+
510+
if other.upstreamCommitConfigFile != nil {
511+
c.upstreamCommitConfigFile = other.upstreamCommitConfigFile
512+
}
513+
514+
if other.OverlayFiles != nil {
515+
c.OverlayFiles = otherOverlayFiles
516+
}
517+
518+
return nil
519+
}
520+
521+
// UpstreamCommitConfigFile returns the config file that supplied the component's
522+
// effective upstream commit pin, or nil when no commit is pinned. Only meaningful
523+
// in lock-file-free mode, where the pin lives in generated component config.
524+
func (c *ComponentConfig) UpstreamCommitConfigFile() *ConfigFile {
525+
if c.upstreamCommitConfigFile == nil && c.Spec.UpstreamCommit != "" {
526+
return c.SourceConfigFile
527+
}
528+
529+
return c.upstreamCommitConfigFile
530+
}
531+
461532
// EffectiveUpstreamCommit returns the commit to use for upstream operations.
462533
// Prefers the locked commit (resolved reality) over the config pin (user intent).
463534
// Falls back to Spec.UpstreamCommit for SkipLockValidation paths (update, list,
@@ -518,17 +589,18 @@ func (c *ComponentConfig) WithAbsolutePaths(referenceDir string) *ComponentConfi
518589
// the SourceConfigFile, as we *do* want to alias that pointer, sharing it across
519590
// all configs that came from that source config file.
520591
result := &ComponentConfig{
521-
Name: c.Name,
522-
SourceConfigFile: c.SourceConfigFile,
523-
RenderedSpecDir: c.RenderedSpecDir,
524-
Locked: deep.MustCopy(c.Locked),
525-
Release: c.Release,
526-
Spec: deep.MustCopy(c.Spec),
527-
Build: deep.MustCopy(c.Build),
528-
Render: c.Render,
529-
SourceFiles: deep.MustCopy(c.SourceFiles),
530-
Packages: deep.MustCopy(c.Packages),
531-
Publish: deep.MustCopy(c.Publish),
592+
Name: c.Name,
593+
SourceConfigFile: c.SourceConfigFile,
594+
upstreamCommitConfigFile: c.upstreamCommitConfigFile,
595+
RenderedSpecDir: c.RenderedSpecDir,
596+
Locked: deep.MustCopy(c.Locked),
597+
Release: c.Release,
598+
Spec: deep.MustCopy(c.Spec),
599+
Build: deep.MustCopy(c.Build),
600+
Render: c.Render,
601+
SourceFiles: deep.MustCopy(c.SourceFiles),
602+
Packages: deep.MustCopy(c.Packages),
603+
Publish: deep.MustCopy(c.Publish),
532604
// OverlayFiles is consumed after component config resolution; preserve it verbatim
533605
// here so inherited patterns can be interpreted relative to the concrete component
534606
// config file.

internal/projectconfig/config.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ import (
1717
// may make use of the provided temporary directory, with the expectation that the caller is responsible
1818
// for cleaning it up -- but not until after it is done using the loaded configuration. The loaded
1919
// configuration may implicitly depend on the contents of the temporary directory.
20+
//
21+
// When withoutLockfile is set, component definitions merge with override semantics,
22+
// are validated only once the whole project is assembled, and the project's lock
23+
// directory is left unset.
2024
func LoadProjectConfig(
2125
fs opctx.FS,
2226
osEnv opctx.OSEnv,
@@ -25,6 +29,7 @@ func LoadProjectConfig(
2529
tempDirPath string,
2630
extraConfigFilePaths []string,
2731
permissiveConfigParsing bool,
32+
withoutLockfile bool,
2833
) (projectDir string, config *ProjectConfig, err error) {
2934
// Look for project root and azldev.toml file.
3035
projectDir, projectFilePath, err := FindProjectRootAndConfigFile(fs, referenceDir)
@@ -79,7 +84,12 @@ func LoadProjectConfig(
7984
//
8085
// NOTE: We don't wrap the error returned back here (if one is returned) because we already have
8186
// a decent one coming from this function.
82-
config, err = loadAndResolveProjectConfig(fs, permissiveConfigParsing, configFilePaths...)
87+
options := loadOptions{
88+
permissiveConfigParsing: permissiveConfigParsing,
89+
withoutLockfile: withoutLockfile,
90+
}
91+
92+
config, err = loadAndResolveProjectConfig(fs, options, configFilePaths...)
8393
if err != nil {
8494
return "", nil, err
8595
}
@@ -90,5 +100,12 @@ func LoadProjectConfig(
90100
// Apply project-relative defaults for any unset path fields.
91101
config.Project.ApplyProjectDefaults(projectDir)
92102

103+
// Lock-file-free mode never reads or writes lock files, so the lock directory
104+
// is left unset. A project may still declare 'lock-dir' for compatibility with
105+
// azldev's default mode; the value is simply ignored here.
106+
if withoutLockfile {
107+
config.Project.LockDir = ""
108+
}
109+
93110
return projectDir, config, nil
94111
}

internal/projectconfig/config_test.go

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ description = "`+testProjectDesc+`"
5151
`)
5252

5353
_, config, err := projectconfig.LoadProjectConfig(
54-
ctx.FS(), ctx.OSEnv(), testProjectDir, true /*disableDefaultConfig*/, t.TempDir(), nil, false,
54+
ctx.FS(), ctx.OSEnv(), testProjectDir, true /*disableDefaultConfig*/, t.TempDir(), nil, false, false,
5555
)
5656
require.NoError(t, err)
5757
require.NotNil(t, config)
@@ -66,7 +66,7 @@ func TestLoadProjectConfig_WithDefaultConfig(t *testing.T) {
6666
require.NoError(t, fileutils.MkdirAll(ctx.FS(), tempDir))
6767

6868
_, config, err := projectconfig.LoadProjectConfig(
69-
ctx.FS(), ctx.OSEnv(), testProjectDir, false /*disableDefaultConfig*/, tempDir, nil, false,
69+
ctx.FS(), ctx.OSEnv(), testProjectDir, false /*disableDefaultConfig*/, tempDir, nil, false, false,
7070
)
7171
require.NoError(t, err)
7272
require.NotNil(t, config)
@@ -94,7 +94,7 @@ output-dir = "/from/user/out"
9494
`), fileperms.PublicFile))
9595

9696
_, config, err := projectconfig.LoadProjectConfig(
97-
ctx.FS(), ctx.OSEnv(), testProjectDir, true /*disableDefaultConfig*/, t.TempDir(), nil, false,
97+
ctx.FS(), ctx.OSEnv(), testProjectDir, true /*disableDefaultConfig*/, t.TempDir(), nil, false, false,
9898
)
9999
require.NoError(t, err)
100100
require.NotNil(t, config)
@@ -136,7 +136,7 @@ description = "`+testUserDesc+`"
136136

137137
_, config, err := projectconfig.LoadProjectConfig(
138138
ctx.FS(), ctx.OSEnv(), testProjectDir, true /*disableDefaultConfig*/, t.TempDir(),
139-
[]string{extraConfigPath}, false,
139+
[]string{extraConfigPath}, false, false,
140140
)
141141
require.NoError(t, err)
142142
require.NotNil(t, config)
@@ -166,9 +166,41 @@ description = "`+testUserDesc+`"
166166
`), fileperms.PublicFile))
167167

168168
_, config, err := projectconfig.LoadProjectConfig(
169-
ctx.FS(), ctx.OSEnv(), testProjectDir, true /*disableDefaultConfig*/, t.TempDir(), nil, false,
169+
ctx.FS(), ctx.OSEnv(), testProjectDir, true /*disableDefaultConfig*/, t.TempDir(), nil, false, false,
170170
)
171171
require.NoError(t, err)
172172
require.NotNil(t, config)
173173
assert.Equal(t, testUserDesc, config.Project.Description)
174174
}
175+
176+
// TestLoadProjectConfig_LockDirByMode verifies that the project's lock directory is
177+
// defaulted in azldev's default mode and left unset in lock-file-free mode, where an
178+
// explicitly configured 'lock-dir' is accepted but ignored.
179+
func TestLoadProjectConfig_LockDirByMode(t *testing.T) {
180+
testCases := []struct {
181+
name string
182+
withoutLockfile bool
183+
expected string
184+
}{
185+
{name: "default mode", withoutLockfile: false, expected: filepath.Join(testProjectDir, "legacy-locks")},
186+
{name: "lock-file-free mode", withoutLockfile: true, expected: ""},
187+
}
188+
189+
for _, testCase := range testCases {
190+
t.Run(testCase.name, func(t *testing.T) {
191+
ctx := newTestCtxWithXDGConfigHome()
192+
writeProjectConfig(t, ctx, `
193+
[project]
194+
lock-dir = "legacy-locks"
195+
`)
196+
197+
_, config, err := projectconfig.LoadProjectConfig(
198+
ctx.FS(), ctx.OSEnv(), testProjectDir, true /*disableDefaultConfig*/, t.TempDir(), nil,
199+
false, testCase.withoutLockfile,
200+
)
201+
require.NoError(t, err)
202+
require.NotNil(t, config)
203+
assert.Equal(t, testCase.expected, config.Project.LockDir)
204+
})
205+
}
206+
}

internal/projectconfig/configfile.go

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -108,14 +108,41 @@ func (f ConfigFile) Validate() error {
108108
return err
109109
}
110110

111-
// Per-component snapshot timestamps are not allowed. Components inherit
112-
// the snapshot from the distro/group default-component-config or the
113-
// project's default-distro. Per-component snapshots would create
114-
// non-deterministic builds that the lock file cannot reliably track.
115-
// Use an explicit 'upstream-commit' pin instead.
116-
117-
// Validate overlay configurations for each component.
118-
for componentName, component := range f.Components {
111+
if err := validateComponentConfigs(f.Components); err != nil {
112+
return err
113+
}
114+
115+
if err := validateTestSuites(f.TestSuites); err != nil {
116+
return err
117+
}
118+
119+
if err := validateTestDefinitions(f.Tests); err != nil {
120+
return err
121+
}
122+
123+
return nil
124+
}
125+
126+
// validateNonComponentFields validates every field except the component
127+
// definitions. Lock-file-free mode merges component definitions across config
128+
// files with override semantics, so a single file may legitimately be
129+
// incomplete; components are validated once the whole project is assembled.
130+
func (f ConfigFile) validateNonComponentFields() error {
131+
f.Components = nil
132+
133+
return f.Validate()
134+
}
135+
136+
// validateComponentConfigs validates the parts of a component definition that
137+
// the struct validator cannot express.
138+
//
139+
// Per-component snapshot timestamps are not allowed. Components inherit the
140+
// snapshot from the distro/group default-component-config or the project's
141+
// default-distro. Per-component snapshots would create non-deterministic builds
142+
// that the lock file cannot reliably track. Use an explicit 'upstream-commit'
143+
// pin instead.
144+
func validateComponentConfigs(components map[string]ComponentConfig) error {
145+
for componentName, component := range components {
119146
for i, overlay := range component.Overlays {
120147
err := overlay.Validate()
121148
if err != nil {
@@ -142,14 +169,6 @@ func (f ConfigFile) Validate() error {
142169
}
143170
}
144171

145-
if err := validateTestSuites(f.TestSuites); err != nil {
146-
return err
147-
}
148-
149-
if err := validateTestDefinitions(f.Tests); err != nil {
150-
return err
151-
}
152-
153172
return nil
154173
}
155174

@@ -652,3 +671,19 @@ func (f ConfigFile) Serialize(fs opctx.FS, filePath string) error {
652671

653672
return nil
654673
}
674+
675+
// validateComponentStructs runs the struct validator over each component
676+
// definition. Config files declare components with a 'dive' validation tag, so
677+
// this is only needed when component validation is deferred until the whole
678+
// project has been merged (lock-file-free mode).
679+
func validateComponentStructs(components map[string]ComponentConfig) error {
680+
validate := validator.New()
681+
682+
for componentName, component := range components {
683+
if err := validate.Struct(&component); err != nil {
684+
return fmt.Errorf("invalid component %#q:\n%w", componentName, err)
685+
}
686+
}
687+
688+
return nil
689+
}

0 commit comments

Comments
 (0)