feat: multi-level configuration and profiles (#3337)

Signed-off-by: Keith Zantow <kzantow@gmail.com>
This commit is contained in:
Keith Zantow 2024-10-23 12:15:59 -04:00 committed by GitHub
parent a00533c836
commit 759b898df5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 268 additions and 33 deletions

View File

@ -20,18 +20,18 @@ func AppClioSetupConfig(id clio.Identification, out io.Writer) *clio.SetupConfig
WithConfigInRootHelp(). // --help on the root command renders the full application config in the help text WithConfigInRootHelp(). // --help on the root command renders the full application config in the help text
WithUIConstructor( WithUIConstructor(
// select a UI based on the logging configuration and state of stdin (if stdin is a tty) // select a UI based on the logging configuration and state of stdin (if stdin is a tty)
func(cfg clio.Config) ([]clio.UI, error) { func(cfg clio.Config) (*clio.UICollection, error) {
noUI := ui.None(out, cfg.Log.Quiet) noUI := ui.None(out, cfg.Log.Quiet)
if !cfg.Log.AllowUI(os.Stdin) || cfg.Log.Quiet { if !cfg.Log.AllowUI(os.Stdin) || cfg.Log.Quiet {
return []clio.UI{noUI}, nil return clio.NewUICollection(noUI), nil
} }
return []clio.UI{ return clio.NewUICollection(
ui.New(out, cfg.Log.Quiet, ui.New(out, cfg.Log.Quiet,
ui2.New(ui2.DefaultHandlerConfig()), ui2.New(ui2.DefaultHandlerConfig()),
), ),
noUI, noUI,
}, nil ), nil
}, },
). ).
WithInitializers( WithInitializers(

View File

@ -13,6 +13,7 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
"github.com/anchore/clio" "github.com/anchore/clio"
"github.com/anchore/fangs"
"github.com/anchore/go-collections" "github.com/anchore/go-collections"
"github.com/anchore/stereoscope" "github.com/anchore/stereoscope"
"github.com/anchore/stereoscope/pkg/image" "github.com/anchore/stereoscope/pkg/image"
@ -109,7 +110,7 @@ func (o *scanOptions) PostLoad() error {
} }
func (o *scanOptions) validateLegacyOptionsNotUsed() error { func (o *scanOptions) validateLegacyOptionsNotUsed() error {
if o.Config.ConfigFile == "" { if len(fangs.Flatten(o.Config.ConfigFile)) == 0 {
return nil return nil
} }
@ -121,14 +122,15 @@ func (o *scanOptions) validateLegacyOptionsNotUsed() error {
File any `yaml:"file" json:"file" mapstructure:"file"` File any `yaml:"file" json:"file" mapstructure:"file"`
} }
by, err := os.ReadFile(o.Config.ConfigFile) for _, f := range fangs.Flatten(o.Config.ConfigFile) {
by, err := os.ReadFile(f)
if err != nil { if err != nil {
return fmt.Errorf("unable to read config file during validations %q: %w", o.Config.ConfigFile, err) return fmt.Errorf("unable to read config file during validations %q: %w", f, err)
} }
var legacy legacyConfig var legacy legacyConfig
if err := yaml.Unmarshal(by, &legacy); err != nil { if err := yaml.Unmarshal(by, &legacy); err != nil {
return fmt.Errorf("unable to parse config file during validations %q: %w", o.Config.ConfigFile, err) return fmt.Errorf("unable to parse config file during validations %q: %w", f, err)
} }
if legacy.DefaultImagePullSource != nil { if legacy.DefaultImagePullSource != nil {
@ -146,7 +148,7 @@ func (o *scanOptions) validateLegacyOptionsNotUsed() error {
if legacy.File != nil && reflect.TypeOf(legacy.File).Kind() == reflect.String { if legacy.File != nil && reflect.TypeOf(legacy.File).Kind() == reflect.String {
return fmt.Errorf("the config file option 'file' has been removed, please use 'outputs' instead") return fmt.Errorf("the config file option 'file' has been removed, please use 'outputs' instead")
} }
}
return nil return nil
} }

View File

@ -2,11 +2,11 @@ package options
import "github.com/anchore/fangs" import "github.com/anchore/fangs"
// Config holds a reference to the specific config file that was used to load application configuration // Config holds a reference to the specific config file(s) that were used to load application configuration
type Config struct { type Config struct {
ConfigFile string `yaml:"config" json:"config" mapstructure:"config"` ConfigFile string `yaml:"config" json:"config" mapstructure:"config"`
} }
func (cfg *Config) DescribeFields(descriptions fangs.FieldDescriptionSet) { func (cfg *Config) DescribeFields(descriptions fangs.FieldDescriptionSet) {
descriptions.Add(&cfg.ConfigFile, "the configuration file that was used to load application configuration") descriptions.Add(&cfg.ConfigFile, "the configuration file(s) used to load application configuration")
} }

4
go.mod
View File

@ -11,8 +11,8 @@ require (
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d
github.com/acobaugh/osrelease v0.1.0 github.com/acobaugh/osrelease v0.1.0
github.com/anchore/bubbly v0.0.0-20231115134915-def0aba654a9 github.com/anchore/bubbly v0.0.0-20231115134915-def0aba654a9
github.com/anchore/clio v0.0.0-20240522144804-d81e109008aa github.com/anchore/clio v0.0.0-20241015191535-f538a9016e10
github.com/anchore/fangs v0.0.0-20240903175602-e716ef12c23d github.com/anchore/fangs v0.0.0-20241014201141-b6e4b3469f10
github.com/anchore/go-collections v0.0.0-20240216171411-9321230ce537 github.com/anchore/go-collections v0.0.0-20240216171411-9321230ce537
github.com/anchore/go-logger v0.0.0-20230725134548-c21dafa1ec5a github.com/anchore/go-logger v0.0.0-20230725134548-c21dafa1ec5a
github.com/anchore/go-macholibre v0.0.0-20220308212642-53e6d0aaf6fb github.com/anchore/go-macholibre v0.0.0-20220308212642-53e6d0aaf6fb

8
go.sum
View File

@ -97,10 +97,10 @@ github.com/anchore/archiver/v3 v3.5.2 h1:Bjemm2NzuRhmHy3m0lRe5tNoClB9A4zYyDV58Pa
github.com/anchore/archiver/v3 v3.5.2/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssnDhppzS1L4= github.com/anchore/archiver/v3 v3.5.2/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssnDhppzS1L4=
github.com/anchore/bubbly v0.0.0-20231115134915-def0aba654a9 h1:p0ZIe0htYOX284Y4axJaGBvXHU0VCCzLN5Wf5XbKStU= github.com/anchore/bubbly v0.0.0-20231115134915-def0aba654a9 h1:p0ZIe0htYOX284Y4axJaGBvXHU0VCCzLN5Wf5XbKStU=
github.com/anchore/bubbly v0.0.0-20231115134915-def0aba654a9/go.mod h1:3ZsFB9tzW3vl4gEiUeuSOMDnwroWxIxJelOOHUp8dSw= github.com/anchore/bubbly v0.0.0-20231115134915-def0aba654a9/go.mod h1:3ZsFB9tzW3vl4gEiUeuSOMDnwroWxIxJelOOHUp8dSw=
github.com/anchore/clio v0.0.0-20240522144804-d81e109008aa h1:pwlAn4O9SBUnlgfa69YcqIynbUyobLVFYu8HxSoCffA= github.com/anchore/clio v0.0.0-20241015191535-f538a9016e10 h1:3xmanFdoQEH0REvPA+gLm3Km0/981F4z2a/7ADTlv8k=
github.com/anchore/clio v0.0.0-20240522144804-d81e109008aa/go.mod h1:nD3H5uIvjxlfmakOBgtyFQbk5Zjp3l538kxfpHPslzI= github.com/anchore/clio v0.0.0-20241015191535-f538a9016e10/go.mod h1:h6Ly2hlKjQoPtI3rA8oB5afSmB/XimhcY55xbuW4Dwo=
github.com/anchore/fangs v0.0.0-20240903175602-e716ef12c23d h1:ZD4wdCBgJJzJybjTUIEiiupLF7B9H3WLuBTjspBO2Mc= github.com/anchore/fangs v0.0.0-20241014201141-b6e4b3469f10 h1:w+HibE+e/heP6ysADh7sWxg5LhYdVqrpB1A4Hmgjyx8=
github.com/anchore/fangs v0.0.0-20240903175602-e716ef12c23d/go.mod h1:Xh4ObY3fmoMzOEVXwDtS1uK44JC7+nRD0n29/1KYFYg= github.com/anchore/fangs v0.0.0-20241014201141-b6e4b3469f10/go.mod h1:s0L1//Sxn6Rq0Dcxx+dmT/RRmD9HhsaJjJkPUJHLJLM=
github.com/anchore/go-collections v0.0.0-20240216171411-9321230ce537 h1:GjNGuwK5jWjJMyVppBjYS54eOiiSNv4Ba869k4wh72Q= github.com/anchore/go-collections v0.0.0-20240216171411-9321230ce537 h1:GjNGuwK5jWjJMyVppBjYS54eOiiSNv4Ba869k4wh72Q=
github.com/anchore/go-collections v0.0.0-20240216171411-9321230ce537/go.mod h1:1aiktV46ATCkuVg0O573ZrH56BUawTECPETbZyBcqT8= github.com/anchore/go-collections v0.0.0-20240216171411-9321230ce537/go.mod h1:1aiktV46ATCkuVg0O573ZrH56BUawTECPETbZyBcqT8=
github.com/anchore/go-logger v0.0.0-20230725134548-c21dafa1ec5a h1:nJ2G8zWKASyVClGVgG7sfM5mwoZlZ2zYpIzN2OhjWkw= github.com/anchore/go-logger v0.0.0-20230725134548-c21dafa1ec5a h1:nJ2G8zWKASyVClGVgG7sfM5mwoZlZ2zYpIzN2OhjWkw=

211
test/cli/config_test.go Normal file
View File

@ -0,0 +1,211 @@
package cli
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
func Test_configLoading(t *testing.T) {
cwd, err := os.Getwd()
require.NoError(t, err)
defer func() { require.NoError(t, os.Chdir(cwd)) }()
configsDir := filepath.Join(cwd, "test-fixtures", "configs")
path := func(path string) string {
return filepath.Join(configsDir, filepath.Join(strings.Split(path, "/")...))
}
type creds struct {
Authority string `yaml:"authority"`
}
type registry struct {
Credentials []creds `yaml:"auth"`
}
type config struct {
Registry registry `yaml:"registry"`
}
tests := []struct {
name string
home string
cwd string
args []string
expected []creds
err string
}{
{
name: "single explicit config",
home: configsDir,
cwd: cwd,
args: []string{
"-c",
path("dir1/.syft.yaml"),
},
expected: []creds{
{
Authority: "dir1-authority",
},
},
},
{
name: "multiple explicit config",
home: configsDir,
cwd: cwd,
args: []string{
"-c",
path("dir1/.syft.yaml"),
"-c",
path("dir2/.syft.yaml"),
},
expected: []creds{
{
Authority: "dir1-authority",
},
{
Authority: "dir2-authority",
},
},
},
{
name: "empty profile override",
home: configsDir,
cwd: cwd,
args: []string{
"-c",
path("dir1/.syft.yaml"),
"-c",
path("dir2/.syft.yaml"),
"--profile",
"no-auth",
},
expected: []creds{},
},
{
name: "no profiles defined",
home: configsDir,
cwd: configsDir,
args: []string{
"--profile",
"invalid",
},
err: "not found in any configuration files",
},
{
name: "invalid profile name",
home: configsDir,
cwd: cwd,
args: []string{
"-c",
path("dir1/.syft.yaml"),
"-c",
path("dir2/.syft.yaml"),
"--profile",
"alt",
},
err: "profile not found",
},
{
name: "explicit with profile override",
home: configsDir,
cwd: cwd,
args: []string{
"-c",
path("dir1/.syft.yaml"),
"-c",
path("dir2/.syft.yaml"),
"--profile",
"alt-auth",
},
expected: []creds{
{
Authority: "dir1-alt-authority", // dir1 is still first
},
{
Authority: "dir2-alt-authority",
},
},
},
{
name: "single in cwd",
home: configsDir,
cwd: path("dir2"),
args: []string{},
expected: []creds{
{
Authority: "dir2-authority",
},
},
},
{
name: "single in home",
home: path("dir2"),
cwd: configsDir,
args: []string{},
expected: []creds{
{
Authority: "dir2-authority",
},
},
},
{
name: "inherited in cwd",
home: path("dir1"),
cwd: path("dir2"),
args: []string{},
expected: []creds{
{
Authority: "dir2-authority", // dir2 is in cwd, giving higher priority
},
{
Authority: "dir1-authority", // home has "lower priority and should be after"
},
},
},
{
name: "inherited profile override",
home: path("dir1"),
cwd: path("dir2"),
args: []string{
"--profile",
"alt-auth",
},
expected: []creds{
{
Authority: "dir2-alt-authority", // dir2 is in cwd, giving higher priority
},
{
Authority: "dir1-alt-authority", // dir1 is home, lower priority
},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
require.NoError(t, os.Chdir(test.cwd))
defer func() { require.NoError(t, os.Chdir(cwd)) }()
env := map[string]string{
"HOME": test.home,
"XDG_CONFIG_HOME": test.home,
}
_, stdout, stderr := runSyft(t, env, append([]string{"config", "--load"}, test.args...)...)
if test.err != "" {
require.Contains(t, stderr, test.err)
return
} else {
require.Empty(t, stderr)
}
got := config{}
err = yaml.NewDecoder(strings.NewReader(stdout)).Decode(&got)
require.NoError(t, err)
require.Equal(t, test.expected, got.Registry.Credentials)
})
}
}

View File

@ -0,0 +1,13 @@
registry:
auth:
- authority: dir1-authority
profiles:
no-auth:
registry:
auth: []
alt-auth:
registry:
auth:
- authority: dir1-alt-authority

View File

@ -0,0 +1,9 @@
registry:
auth:
- authority: dir2-authority
profiles:
alt-auth:
registry:
auth:
- authority: dir2-alt-authority