Skip to content

Commit b72e3a3

Browse files
reubenoDaniel McIlvaney
andauthored
feat: enable multiple config files on command line (#456)
Co-authored-by: reuben olinsky <reubeno@users.noreply.github.com> Co-authored-by: Daniel McIlvaney <Daniel.McIlvaney@microsoft.com>
1 parent 301b505 commit b72e3a3

19 files changed

Lines changed: 347 additions & 104 deletions

internal/app/azldev/app.go

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ type App struct {
4646
dryRun bool
4747
reportFormat ReportFormat
4848
disableDefaultConfig bool
49+
configFiles []string
4950
colorMode ColorMode
5051

5152
// Root command for the CLI.
@@ -152,15 +153,14 @@ func NewApp(fsFactory opctx.FileSystemFactory, osEnvFactory opctx.OSEnvFactory)
152153
"disable default configuration")
153154
app.cmd.PersistentFlags().StringVarP(&app.explicitProjectDir, "project", "C", "",
154155
"path to Azure Linux project")
156+
app.cmd.PersistentFlags().StringArrayVar(&app.configFiles, "config-file", nil,
157+
"additional TOML config file(s) to merge (may be repeated)")
155158
app.cmd.PersistentFlags().BoolVarP(&app.dryRun, "dry-run", "n", false, "dry run only (do not take action)")
156159
app.cmd.PersistentFlags().VarP(&app.reportFormat, "output-format", "O",
157160
"output format {csv, json, markdown, table}")
158161
app.cmd.PersistentFlags().Var(&app.colorMode, "color",
159162
"output colorization mode {always, auto, never}")
160163

161-
// Manually specifying a config file should only be used if you really know what you're doing.
162-
_ = app.cmd.PersistentFlags().MarkHidden("config-file")
163-
164164
return app
165165
}
166166

@@ -365,6 +365,7 @@ func (a *App) initializeProjectConfig(envOptions *EnvOptions, earlyTempDirPath s
365365
projectDir, config, err := a.findAndLoadConfig(
366366
envOptions.DryRunnable,
367367
earlyTempDirPath,
368+
a.configFiles,
368369
)
369370

370371
if errors.Is(err, projectconfig.ErrConfigFileNotFound) {
@@ -438,6 +439,11 @@ func (a *App) handParseConfigFlags(args []string) {
438439
}
439440
case "--no-default-config":
440441
a.disableDefaultConfig = true
442+
case "--config-file":
443+
index++
444+
if index < len(args) {
445+
a.configFiles = append(a.configFiles, args[index])
446+
}
441447
case "-n", "--dry-run":
442448
a.dryRun = true
443449
case "--color":
@@ -446,21 +452,28 @@ func (a *App) handParseConfigFlags(args []string) {
446452
_ = a.colorMode.Set(args[index])
447453
}
448454
default:
449-
switch {
450-
case strings.HasPrefix(arg, "-C"):
451-
a.explicitProjectDir = strings.TrimPrefix(arg, "-C")
452-
case strings.HasPrefix(arg, "--project="):
453-
a.explicitProjectDir = strings.TrimPrefix(arg, "--project=")
454-
case strings.HasPrefix(arg, "--color="):
455-
_ = a.colorMode.Set(strings.TrimPrefix(arg, "--color="))
456-
}
455+
a.handParsePrefixedFlags(arg)
457456
}
458457
}
459458
}
460459

460+
// handParsePrefixedFlags handles flag variants with = assignment syntax (e.g., --project=value).
461+
func (a *App) handParsePrefixedFlags(arg string) {
462+
switch {
463+
case strings.HasPrefix(arg, "-C"):
464+
a.explicitProjectDir = strings.TrimPrefix(arg, "-C")
465+
case strings.HasPrefix(arg, "--project="):
466+
a.explicitProjectDir = strings.TrimPrefix(arg, "--project=")
467+
case strings.HasPrefix(arg, "--color="):
468+
_ = a.colorMode.Set(strings.TrimPrefix(arg, "--color="))
469+
case strings.HasPrefix(arg, "--config-file="):
470+
a.configFiles = append(a.configFiles, strings.TrimPrefix(arg, "--config-file="))
471+
}
472+
}
473+
461474
// Initializes the configuration for the azldev CLI. This includes finding the project.
462475
// loading configuration, etc.
463-
func (a *App) findAndLoadConfig(dryRunnable opctx.DryRunnable, tempDirPath string) (
476+
func (a *App) findAndLoadConfig(dryRunnable opctx.DryRunnable, tempDirPath string, extraConfigFiles []string) (
464477
projectDir string, config *projectconfig.ProjectConfig, err error,
465478
) {
466479
// If no explicit project dir was specified, then fall back to the current working directory.
@@ -480,6 +493,7 @@ func (a *App) findAndLoadConfig(dryRunnable opctx.DryRunnable, tempDirPath strin
480493
referenceDir,
481494
a.disableDefaultConfig,
482495
tempDirPath,
496+
extraConfigFiles,
483497
)
484498
if err != nil {
485499
return projectDir, config, fmt.Errorf("failed to load project configuration:\n%w", err)

internal/app/azldev/cmds/image/customize.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ func NewImageCustomizeCmd() *cobra.Command {
3131
cmd.Flags().StringVarP(&options.imageTag, "image-tag", "", "",
3232
"Container tag for the MCR base Azure Linux image to be downloaded and customized."+
3333
" Cannot be specified with --image-file")
34-
cmd.Flags().StringVarP(&options.configFile, "config-file", "", "",
34+
cmd.Flags().StringVarP(&options.imageConfigFile, "image-config", "", "",
3535
"Path of the image customization config file")
3636
cmd.Flags().StringVar(&options.outputImageFormat, "output-image-format", "",
3737
"Format of output image ("+getImageCustomizerImageFormatsString()+")")
@@ -56,7 +56,7 @@ func NewImageCustomizeCmd() *cobra.Command {
5656
// Customizer, we are requiring them in azldev so that we can deduce which
5757
// folders to mount into the container.
5858
// See: https://dev.azure.com/mariner-org/polar/_workitems/edit/15282
59-
_ = cmd.MarkFlagRequired("config-file")
59+
_ = cmd.MarkFlagRequired("image-config")
6060
_ = cmd.MarkFlagRequired("output-path")
6161

6262
cmd.MarkFlagsMutuallyExclusive("image-file", "image-tag")

internal/app/azldev/cmds/image/imageutils.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ const (
3333
type imageCustomizerOptions struct {
3434
imageFile string
3535
imageTag string
36-
configFile string
36+
imageConfigFile string
3737
outputImageFormat string
3838
outputPath string
3939
rpmSources []string
@@ -58,7 +58,7 @@ func getImageCustomizerImageFormatsString() string {
5858
func buildDockerArgs(
5959
options *imageCustomizerOptions, buildDir string, logsDir string, rpmSources []rpmSourceInfo,
6060
) []string {
61-
configDir := path.Dir(options.configFile)
61+
configDir := path.Dir(options.imageConfigFile)
6262
outputPathDir := path.Dir(options.outputPath)
6363

6464
args := []string{
@@ -141,7 +141,7 @@ func buildImageCustomizerArgs(
141141

142142
args = append(args, []string{
143143
"--build-dir", buildDir,
144-
"--config-file", options.configFile,
144+
"--config-file", options.imageConfigFile,
145145
"--output-image-format", options.outputImageFormat,
146146
"--output-path", options.outputPath,
147147
"--log-level", logLevel,

internal/app/azldev/cmds/image/injectfiles.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ func NewImageInjectFilesCmd() *cobra.Command {
2828

2929
cmd.Flags().StringVarP(&options.imageFile, "image-file", "", "",
3030
"Path of the base Azure Linux image which the customization will be applied to")
31-
cmd.Flags().StringVarP(&options.configFile, "config-file", "", "",
31+
cmd.Flags().StringVarP(&options.imageConfigFile, "image-config", "", "",
3232
"Path of the image customization config file")
3333
cmd.Flags().StringVar(&options.outputImageFormat, "output-image-format", "",
3434
"Format of output image ("+getImageCustomizerImageFormatsString()+")")
@@ -47,7 +47,7 @@ func NewImageInjectFilesCmd() *cobra.Command {
4747
// folders to mount into the container.
4848
// See: https://dev.azure.com/mariner-org/polar/_workitems/edit/15282
4949
_ = cmd.MarkFlagRequired("image-file")
50-
_ = cmd.MarkFlagRequired("config-file")
50+
_ = cmd.MarkFlagRequired("image-config")
5151
_ = cmd.MarkFlagRequired("output-path")
5252

5353
return cmd

internal/projectconfig/config.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ func LoadProjectConfig(
2222
referenceDir string,
2323
disableDefaultConfig bool,
2424
tempDirPath string,
25+
extraConfigFilePaths []string,
2526
) (projectDir string, config *ProjectConfig, err error) {
2627
// Look for project root and azldev.toml file.
2728
projectDir, projectFilePath, err := FindProjectRootAndConfigFile(fs, referenceDir)
@@ -47,9 +48,13 @@ func LoadProjectConfig(
4748
configFilePaths = append(configFilePaths, defaultConfigFilePath)
4849
}
4950

50-
// Load the project config file last.
51+
// Load the project config file next.
5152
configFilePaths = append(configFilePaths, projectFilePath)
5253

54+
// Append any extra config files specified by the user (e.g., via --config-file flags).
55+
// These are loaded last, so they can override/merge with settings from the project config.
56+
configFilePaths = append(configFilePaths, extraConfigFilePaths...)
57+
5358
// Actually load and process the config file (and any linked config files it references).
5459
//
5560
// NOTE: We don't wrap the error returned back here (if one is returned) because we already have

internal/projectconfig/distro.go

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
package projectconfig
55

66
import (
7+
"fmt"
78
"runtime"
89

10+
"dario.cat/mergo"
911
"github.com/brunoga/deep"
1012
)
1113

@@ -80,15 +82,31 @@ type DistroVersionDefinition struct {
8082
MockConfigPathAarch64 string `toml:"mock-config-aarch64,omitempty" json:"mockConfigAarch64,omitempty" validate:"omitempty,filepath" jsonschema:"title=Mock config file,description=Path to the aarch64 mock config file for this version"`
8183
}
8284

85+
// MergeUpdatesFrom mutates the distro definition, updating it with overrides present in other.
86+
// Uses [mergo.WithOverride] without WithAppendSlice so that slice fields like
87+
// [DistroDefinition.PackageRepositories] are replaced, not appended. This supports the primary
88+
// use case of swapping between package sources via --config-file overrides.
89+
//
90+
// For map fields like [DistroDefinition.Versions], mergo replaces the entire value for a
91+
// matching key rather than doing a field-level merge within the value struct.
92+
func (d *DistroDefinition) MergeUpdatesFrom(other *DistroDefinition) error {
93+
err := mergo.Merge(d, other, mergo.WithOverride)
94+
if err != nil {
95+
return fmt.Errorf("failed to merge distro definition:\n%w", err)
96+
}
97+
98+
return nil
99+
}
100+
83101
// Returns a copy of the distro definition with relative file paths converted to absolute
84102
// file paths (relative to referenceDir, not the current working directory).
85-
func (d DistroDefinition) WithAbsolutePaths(referenceDir string) DistroDefinition {
103+
func (d *DistroDefinition) WithAbsolutePaths(referenceDir string) DistroDefinition {
86104
// First deep-copy ourselves.
87105
//
88106
// NOTE: We use the panicking MustCopy() because copying should only fail if the input *type*
89107
// is invalid. Since we're always using the same type, we never expect to see a runtime error
90108
// here.
91-
result := deep.MustCopy(d)
109+
result := deep.MustCopy(*d)
92110

93111
for name := range result.Versions {
94112
result.Versions[name] = result.Versions[name].WithAbsolutePaths(referenceDir)
@@ -101,13 +119,13 @@ func (d DistroDefinition) WithAbsolutePaths(referenceDir string) DistroDefinitio
101119
return result
102120
}
103121

104-
func (d DistroDefinition) WithResolvedConfigs() DistroDefinition {
122+
func (d *DistroDefinition) WithResolvedConfigs() DistroDefinition {
105123
// First deep-copy ourselves.
106124
//
107125
// NOTE: We use the panicking MustCopy() because copying should only fail if the input *type*
108126
// is invalid. Since we're always using the same type, we never expect to see a runtime error
109127
// here.
110-
result := deep.MustCopy(d)
128+
result := deep.MustCopy(*d)
111129

112130
for name := range result.Versions {
113131
result.Versions[name] = result.Versions[name].WithResolvedConfigs()

internal/projectconfig/distro_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88

99
"github.com/gim-home/azldev-preview/internal/projectconfig"
1010
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
1112
)
1213

1314
func TestDistroReferenceStringer(t *testing.T) {
@@ -38,3 +39,88 @@ func TestDistroReferenceStringer(t *testing.T) {
3839
assert.Equal(t, "(default) (default)", s.String())
3940
})
4041
}
42+
43+
func TestDistroDefinition_MergeUpdatesFrom(t *testing.T) {
44+
t.Run("override description", func(t *testing.T) {
45+
base := &projectconfig.DistroDefinition{
46+
Description: "original",
47+
DefaultVersion: "1.0",
48+
}
49+
50+
other := &projectconfig.DistroDefinition{
51+
Description: "updated",
52+
}
53+
54+
err := base.MergeUpdatesFrom(other)
55+
require.NoError(t, err)
56+
assert.Equal(t, "updated", base.Description)
57+
assert.Equal(t, "1.0", base.DefaultVersion)
58+
})
59+
60+
t.Run("add new version", func(t *testing.T) {
61+
base := &projectconfig.DistroDefinition{
62+
Description: "distro",
63+
Versions: map[string]projectconfig.DistroVersionDefinition{
64+
"1.0": {Description: "v1", ReleaseVer: "1.0"},
65+
},
66+
}
67+
68+
other := &projectconfig.DistroDefinition{
69+
Versions: map[string]projectconfig.DistroVersionDefinition{
70+
"2.0": {Description: "v2", ReleaseVer: "2.0"},
71+
},
72+
}
73+
74+
err := base.MergeUpdatesFrom(other)
75+
require.NoError(t, err)
76+
assert.Equal(t, "distro", base.Description)
77+
assert.Len(t, base.Versions, 2)
78+
assert.Equal(t, "v1", base.Versions["1.0"].Description)
79+
assert.Equal(t, "v2", base.Versions["2.0"].Description)
80+
})
81+
82+
t.Run("override existing version field", func(t *testing.T) {
83+
base := &projectconfig.DistroDefinition{
84+
Versions: map[string]projectconfig.DistroVersionDefinition{
85+
"1.0": {Description: "old desc", ReleaseVer: "1.0", DistGitBranch: "main"},
86+
},
87+
}
88+
89+
other := &projectconfig.DistroDefinition{
90+
Versions: map[string]projectconfig.DistroVersionDefinition{
91+
"1.0": {Description: "new desc"},
92+
},
93+
}
94+
95+
err := base.MergeUpdatesFrom(other)
96+
require.NoError(t, err)
97+
98+
version := base.Versions["1.0"]
99+
assert.Equal(t, "new desc", version.Description)
100+
// mergo.WithOverride replaces the entire map value for the same key,
101+
// so non-specified fields will be zeroed out. This is intentional:
102+
// override configs are expected to fully redefine any version they touch.
103+
assert.Empty(t, version.ReleaseVer)
104+
assert.Empty(t, version.DistGitBranch)
105+
})
106+
107+
t.Run("replace package repositories", func(t *testing.T) {
108+
base := &projectconfig.DistroDefinition{
109+
PackageRepositories: []projectconfig.PackageRepository{
110+
{BaseURI: "https://old-repo.example.com"},
111+
{BaseURI: "https://another-old-repo.example.com"},
112+
},
113+
}
114+
115+
other := &projectconfig.DistroDefinition{
116+
PackageRepositories: []projectconfig.PackageRepository{
117+
{BaseURI: "https://new-repo.example.com"},
118+
},
119+
}
120+
121+
err := base.MergeUpdatesFrom(other)
122+
require.NoError(t, err)
123+
require.Len(t, base.PackageRepositories, 1)
124+
assert.Equal(t, "https://new-repo.example.com", base.PackageRepositories[0].BaseURI)
125+
})
126+
}

internal/projectconfig/loader.go

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,6 @@ var (
2525
ErrDuplicateComponentGroups = errors.New("duplicate component group")
2626
// ErrDuplicateImages is returned when duplicate conflicting image definitions are found.
2727
ErrDuplicateImages = errors.New("duplicate image")
28-
// ErrDuplicateDistros is returned when duplicate conflicting distro definitions are found.
29-
ErrDuplicateDistros = errors.New("duplicate distro")
3028
)
3129

3230
// Loads and resolves the project configuration files located at the given path. Referenced include files
@@ -95,11 +93,19 @@ func mergeConfigFile(resolvedCfg *ProjectConfig, loadedCfg *ConfigFile) error {
9593

9694
// Merge in distros.
9795
for distroName, distro := range loadedCfg.Distros {
98-
if _, ok := resolvedCfg.Distros[distroName]; ok {
99-
return fmt.Errorf("%w: %s", ErrDuplicateDistros, distroName)
100-
}
96+
resolvedDistro := distro.WithResolvedConfigs()
97+
resolvedDistro = resolvedDistro.WithAbsolutePaths(loadedCfg.dir)
10198

102-
resolvedCfg.Distros[distroName] = distro.WithResolvedConfigs().WithAbsolutePaths(loadedCfg.dir)
99+
if existing, ok := resolvedCfg.Distros[distroName]; ok {
100+
err := existing.MergeUpdatesFrom(&resolvedDistro)
101+
if err != nil {
102+
return fmt.Errorf("failed to merge distro %#q:\n%w", distroName, err)
103+
}
104+
105+
resolvedCfg.Distros[distroName] = existing
106+
} else {
107+
resolvedCfg.Distros[distroName] = resolvedDistro
108+
}
103109
}
104110

105111
// Merge in component groups.

0 commit comments

Comments
 (0)