diff --git a/cmd/deploy.go b/cmd/deploy.go index 3a1d2b6..d2d12a4 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -28,7 +28,7 @@ import ( "k8s.io/utils/ptr" ) -var ( +const ( sharedNamespace = "stackrox" ) @@ -246,7 +246,7 @@ Examples: } func runDeploy(cmd *cobra.Command, args []string) error { - log := logger.New() + log := globalLogger if !dryRun { if err := env.Initialize(log); err != nil { return err diff --git a/cmd/deploy_test.go b/cmd/deploy_test.go index 945d3a4..cc67911 100644 --- a/cmd/deploy_test.go +++ b/cmd/deploy_test.go @@ -7,10 +7,14 @@ import ( "testing" "time" + "dario.cat/mergo" "github.com/stackrox/roxie/internal/deployer" + "github.com/stackrox/roxie/internal/logger" "github.com/stackrox/roxie/internal/types" + "github.com/stackrox/roxie/internal/xdg" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" ) func TestNewDeployCmd_Flags(t *testing.T) { @@ -205,3 +209,109 @@ central: }) } } + +func TestApplyUserDefaults(t *testing.T) { + log := logger.New() + + tests := []struct { + name string + config deployer.Config + user deployer.Config + expected deployer.Config + }{ + { + name: "empty user config leaves config unchanged", + config: deployer.Config{ + Roxie: deployer.RoxieConfig{Version: "4.5.0"}, + Central: deployer.CentralConfig{ + Namespace: "custom-namespace", + }, + }, + expected: deployer.Config{ + Roxie: deployer.RoxieConfig{Version: "4.5.0"}, + Central: deployer.CentralConfig{ + Namespace: "custom-namespace", + }, + }, + }, + { + name: "fills empty fields from user defaults", + config: deployer.Config{}, + user: deployer.Config{ + Roxie: deployer.RoxieConfig{Version: "4.5.0"}, + Operator: deployer.OperatorConfig{DeployViaOlm: true}, + }, + expected: deployer.Config{ + Roxie: deployer.RoxieConfig{Version: "4.5.0"}, + Operator: deployer.OperatorConfig{DeployViaOlm: true}, + }, + }, + { + name: "user config overrides any config fields including config defaults", + config: deployer.Config{ + Roxie: deployer.RoxieConfig{ + Version: "4.9.2", + }, + }, + user: deployer.Config{ + Roxie: deployer.RoxieConfig{ + Version: "4.5.0", + }, + Operator: deployer.OperatorConfig{ + DeployViaOlm: true, + }, + Central: deployer.CentralConfig{ + Namespace: "custom-namespace", + }, + }, + expected: deployer.Config{ + Roxie: deployer.RoxieConfig{ + Version: "4.5.0", + }, + Operator: deployer.OperatorConfig{ + DeployViaOlm: true, + }, + Central: deployer.CentralConfig{ + Namespace: "custom-namespace", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmpDir) + t.Setenv("HOME", tmpDir) // For non-Unix systems. + + if !reflect.DeepEqual(tt.user, deployer.Config{}) { + configPath, err := xdg.UserConfigPath() + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755)) + data, err := yaml.Marshal(tt.user) + require.NoError(t, err) + require.NoError(t, os.WriteFile(configPath, data, 0o644)) + } + + cfg := deployer.NewConfig() + require.NoError(t, mergo.Merge(&cfg, &tt.config, mergo.WithOverride, mergo.WithoutDereference)) + require.NoError(t, tryApplyUserDefaults(log, &cfg)) + + expected := deployer.NewConfig() + require.NoError(t, mergo.Merge(&expected, &tt.expected, mergo.WithOverride, mergo.WithoutDereference)) + + assert.True(t, reflect.DeepEqual(expected, cfg), "expected %+v, got %+v", expected, cfg) + }) + } + + t.Run("returns error on invalid yaml", func(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + configPath, err := xdg.UserConfigPath() + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755)) + require.NoError(t, os.WriteFile(configPath, []byte(`invalid: [yaml`), 0o644)) + + cfg := deployer.NewConfig() + assert.Error(t, tryApplyUserDefaults(log, &cfg)) + }) +} diff --git a/cmd/env.go b/cmd/env.go index 0e59264..129356b 100644 --- a/cmd/env.go +++ b/cmd/env.go @@ -6,7 +6,6 @@ import ( "github.com/spf13/cobra" "github.com/stackrox/roxie/internal/env" - "github.com/stackrox/roxie/internal/logger" ) func newEnvCmd() *cobra.Command { @@ -22,7 +21,7 @@ func newEnvCmd() *cobra.Command { } func runEnv(cmd *cobra.Command, args []string) error { - log := logger.New() + log := globalLogger if err := env.Initialize(log); err != nil { return err } diff --git a/cmd/main.go b/cmd/main.go index 81b92f7..f3c4788 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,11 +1,16 @@ package main import ( + "fmt" "os" + "dario.cat/mergo" "github.com/fatih/color" "github.com/spf13/cobra" "github.com/stackrox/roxie/internal/deployer" + "github.com/stackrox/roxie/internal/logger" + "github.com/stackrox/roxie/internal/xdg" + "gopkg.in/yaml.v3" ) var ( @@ -15,18 +20,56 @@ var ( envrc string dryRun bool + globalLogger = logger.New() + // We need this set up before command line flags are parsed. deploySettings = deployer.NewConfig() ) func main() { - if err := rootCmd.Execute(); err != nil { - red := color.New(color.FgRed, color.Bold) + red := color.New(color.FgRed, color.Bold) + if err := run(); err != nil { red.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } } +func run() error { + if err := tryApplyUserDefaults(globalLogger, &deploySettings); err != nil { + return err + } + return rootCmd.Execute() +} + +// If a user config file exists, apply those user defaults on top the +// current config. This essentially means, that the user config can +// override values, which are already initialized in NewConfig(). +// Note: the user config should only contain reasonable fields, which +// are not already handled by roxies smart defaulting like cluster-dependent +// resource profiles. +func tryApplyUserDefaults(log *logger.Logger, config *deployer.Config) error { + path, err := xdg.UserConfigPath() + if err != nil { + return err + } + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("reading user config %q: %w", path, err) + } + var userDefaults deployer.Config + if err := yaml.Unmarshal(data, &userDefaults); err != nil { + return fmt.Errorf("parsing user config %q: %w", path, err) + } + if err := mergo.Merge(config, &userDefaults, mergo.WithOverride, mergo.WithoutDereference); err != nil { + return fmt.Errorf("merging user config %q: %w", path, err) + } + log.Dimf("Applied user config from %s", path) + return nil +} + var rootCmd = &cobra.Command{ Use: "roxie", Short: "roxie - Advanced Cluster Security Deployment Tool", diff --git a/cmd/teardown.go b/cmd/teardown.go index 2b8a834..94b7906 100644 --- a/cmd/teardown.go +++ b/cmd/teardown.go @@ -9,7 +9,6 @@ import ( "github.com/stackrox/roxie/internal/component" "github.com/stackrox/roxie/internal/deployer" "github.com/stackrox/roxie/internal/env" - "github.com/stackrox/roxie/internal/logger" "github.com/stackrox/roxie/internal/manifest" ) @@ -39,7 +38,7 @@ func newTeardownCmd(settings *deployer.Config) *cobra.Command { } func runTeardown(cmd *cobra.Command, args []string) error { - log := logger.New() + log := globalLogger if err := env.Initialize(log); err != nil { return err } diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index ee4c553..38ed448 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -239,7 +239,10 @@ func New(log *logger.Logger) (*Deployer, error) { } d.dockerAuth = dockerauth.New(log) - d.imageCache = imagecache.New(log, "", 20) + d.imageCache, err = imagecache.New(log, "", 20) + if err != nil { + return nil, err + } d.portForward = portforward.New(k8s.GetKubectl(), log) if password := os.Getenv("ROX_ADMIN_PASSWORD"); password != "" { diff --git a/internal/imagecache/imagecache.go b/internal/imagecache/imagecache.go index 0fa6ee9..8a23361 100644 --- a/internal/imagecache/imagecache.go +++ b/internal/imagecache/imagecache.go @@ -10,6 +10,7 @@ import ( "github.com/stackrox/roxie/internal/logger" "github.com/stackrox/roxie/internal/ocihelper" + "github.com/stackrox/roxie/internal/xdg" ) // ImageCache manages cache of verified pullable Docker images @@ -27,14 +28,13 @@ type CacheData struct { } // New creates a new ImageCache instance -func New(log *logger.Logger, cacheFile string, maxEntries int) *ImageCache { +func New(log *logger.Logger, cacheFile string, maxEntries int) (*ImageCache, error) { if cacheFile == "" { - home, err := os.UserHomeDir() + cacheDir, err := xdg.CacheDir() if err != nil { - home = "." + return nil, err } - // TODO(#91): how about using something XDG-compliant like ~/.cache/roxie/images? - cacheFile = filepath.Join(home, ".roxie.image_cache") + cacheFile = filepath.Join(cacheDir, "image_cache") } if maxEntries <= 0 { @@ -48,7 +48,7 @@ func New(log *logger.Logger, cacheFile string, maxEntries int) *ImageCache { } ic.cache = ic.loadCache() - return ic + return ic, nil } // loadCache loads image cache from file diff --git a/internal/imagecache/imagecache_test.go b/internal/imagecache/imagecache_test.go index c3ca3de..25aeebc 100644 --- a/internal/imagecache/imagecache_test.go +++ b/internal/imagecache/imagecache_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/stackrox/roxie/internal/logger" + "github.com/stretchr/testify/require" ) func TestImageCacheLoadSaveRoundtrip(t *testing.T) { @@ -14,7 +15,8 @@ func TestImageCacheLoadSaveRoundtrip(t *testing.T) { cachePath := filepath.Join(tmpDir, ".roxie.image_cache") log := logger.New() - c := New(log, cachePath, 20) + c, err := New(log, cachePath, 20) + require.NoError(t, err, "creating ImageCache failed") if len(c.cache) != 0 { t.Errorf("Expected empty cache, got %d entries", len(c.cache)) @@ -28,7 +30,8 @@ func TestImageCacheLoadSaveRoundtrip(t *testing.T) { } // Reopen cache and verify persistence - c2 := New(log, cachePath, 20) + c2, err := New(log, cachePath, 20) + require.NoError(t, err, "creating ImageCache failed") if !c2.IsCached("quay.io/example/app:1") { t.Error("Image should be cached after reopening") } @@ -50,7 +53,8 @@ func TestImageCacheHandlesOldFormat(t *testing.T) { } log := logger.New() - c := New(log, cachePath, 20) + c, err := New(log, cachePath, 20) + require.NoError(t, err, "creating ImageCache failed") if !c.IsCached("a") { t.Error("Should load 'a' from old format") @@ -66,7 +70,8 @@ func TestImageCacheMaxEntries(t *testing.T) { log := logger.New() maxEntries := 5 - c := New(log, cachePath, maxEntries) + c, err := New(log, cachePath, maxEntries) + require.NoError(t, err, "creating ImageCache failed") // Add more than maxEntries for i := 0; i < 10; i++ { @@ -88,7 +93,8 @@ func TestImageCacheMoveToEnd(t *testing.T) { cachePath := filepath.Join(tmpDir, ".roxie.image_cache") log := logger.New() - c := New(log, cachePath, 5) + c, err := New(log, cachePath, 5) + require.NoError(t, err, "creating ImageCache failed") c.AddToCache("image1") c.AddToCache("image2") diff --git a/internal/xdg/xdg.go b/internal/xdg/xdg.go new file mode 100644 index 0000000..b528b06 --- /dev/null +++ b/internal/xdg/xdg.go @@ -0,0 +1,36 @@ +package xdg + +import ( + "fmt" + "os" + "path/filepath" +) + +const appName = "roxie" + +func UserConfigPath() (string, error) { + dir, err := configDir() + if err != nil { + return "", fmt.Errorf("retrieving user config path: %w", err) + } + return filepath.Join(dir, "config.yaml"), nil +} + +func configDir() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, appName), nil +} + +// CacheDir returns the cache directory to be used by roxie. +// This directory might not yet exist, it is the responsibility of the caller +// to make sure this directory exists before writing to it. +func CacheDir() (string, error) { + dir, err := os.UserCacheDir() + if err != nil { + return "", err + } + return filepath.Join(dir, appName), nil +}