diff options
author | Patrick Steinhardt <psteinhardt@gitlab.com> | 2023-07-28 13:49:39 +0300 |
---|---|---|
committer | Patrick Steinhardt <psteinhardt@gitlab.com> | 2023-07-28 14:01:17 +0300 |
commit | 5ebe6f6d13f1182fdf6ac3e27b0441fc6085eace (patch) | |
tree | 84e48925a4fbe5eb3df099a6c04dc1e587dfa843 | |
parent | 8cd3b5e4ea2ac188f06054e4bc6b4030ccf26353 (diff) |
praefect/config: Support generating configuration via external command
In 0c21142ce2 (gitaly/config: Support generating configuration via
external command, 2023-03-17), we have implemented the ability for
Gitaly to read parts of its configuration from an external command. The
most important usecase for this functionality is to not store passwords
or other secrets on disk, but instead allow the administrator to
retrieve them dynamically e.g. via AWS Secrets.
Implement the same functionality for Praefect.
Changelog: added
-rw-r--r-- | internal/praefect/config/config.go | 23 | ||||
-rw-r--r-- | internal/praefect/config/config_test.go | 266 |
2 files changed, 289 insertions, 0 deletions
diff --git a/internal/praefect/config/config.go b/internal/praefect/config/config.go index 850efe270..c5eb27d2a 100644 --- a/internal/praefect/config/config.go +++ b/internal/praefect/config/config.go @@ -2,10 +2,12 @@ package config import ( "bytes" + "encoding/json" "errors" "fmt" "io" "os" + "os/exec" "sort" "time" @@ -199,6 +201,11 @@ func DefaultReplicationConfig() Replication { // Config is a container for everything found in the TOML config file type Config struct { + // ConfigCommand specifies the path to an executable that Praefect will run after loading the initial + // configuration. The executable is expected to write JSON-formatted configuration to its standard + // output that we will then deserialize and merge back into the initially-loaded configuration again. + // This is an easy mechanism to generate parts of the configuration at runtime, like for example secrets. + ConfigCommand string `toml:"config_command,omitempty" json:"config_command"` AllowLegacyElectors bool `toml:"i_understand_my_election_strategy_is_unsupported_and_will_be_removed_without_warning,omitempty" json:"i_understand_my_election_strategy_is_unsupported_and_will_be_removed_without_warning"` BackgroundVerification BackgroundVerification `toml:"background_verification,omitempty" json:"background_verification"` Reconciliation Reconciliation `toml:"reconciliation,omitempty" json:"reconciliation"` @@ -313,6 +320,22 @@ func FromReader(reader io.Reader) (Config, error) { return Config{}, err } + if conf.ConfigCommand != "" { + output, err := exec.Command(conf.ConfigCommand).Output() + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return Config{}, fmt.Errorf("running config command: %w, stderr: %q", err, string(exitErr.Stderr)) + } + + return Config{}, fmt.Errorf("running config command: %w", err) + } + + if err := json.Unmarshal(output, &conf); err != nil { + return Config{}, fmt.Errorf("unmarshalling generated config: %w", err) + } + } + conf.setDefaults() return *conf, nil diff --git a/internal/praefect/config/config_test.go b/internal/praefect/config/config_test.go index e5d5dc4df..42ee6af60 100644 --- a/internal/praefect/config/config_test.go +++ b/internal/praefect/config/config_test.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "testing" "time" @@ -982,3 +983,268 @@ func TestConfig_ValidateV2(t *testing.T) { }, err) }) } + +func TestConfig_ConfigCommand(t *testing.T) { + t.Parallel() + + modifyDefaultConfig := func(modify func(cfg *Config)) Config { + cfg, err := FromReader(strings.NewReader("")) + require.NoError(t, err) + modify(&cfg) + return cfg + } + + writeScript := func(t *testing.T, script string) string { + return testhelper.WriteExecutable(t, + filepath.Join(testhelper.TempDir(t), "script"), + []byte("#!/bin/sh\n"+script), + ) + } + + type setupData struct { + cfg Config + expectedErr string + expectedCfg Config + } + + for _, tc := range []struct { + desc string + setup func(t *testing.T) setupData + }{ + { + desc: "nonexistent executable", + setup: func(t *testing.T) setupData { + return setupData{ + cfg: Config{ + ConfigCommand: "/does/not/exist", + }, + expectedErr: "running config command: fork/exec /does/not/exist: no such file or directory", + } + }, + }, + { + desc: "command points to non-executable file", + setup: func(t *testing.T) setupData { + cmd := filepath.Join(testhelper.TempDir(t), "script") + require.NoError(t, os.WriteFile(cmd, nil, perm.PrivateFile)) + + return setupData{ + cfg: Config{ + ConfigCommand: cmd, + }, + expectedErr: fmt.Sprintf( + "running config command: fork/exec %s: permission denied", cmd, + ), + } + }, + }, + { + desc: "executable returns error", + setup: func(t *testing.T) setupData { + return setupData{ + cfg: Config{ + ConfigCommand: writeScript(t, "echo error >&2 && exit 1"), + }, + expectedErr: "running config command: exit status 1, stderr: \"error\\n\"", + } + }, + }, + { + desc: "invalid JSON", + setup: func(t *testing.T) setupData { + return setupData{ + cfg: Config{ + ConfigCommand: writeScript(t, "echo 'this is not json'"), + }, + expectedErr: "unmarshalling generated config: invalid character 'h' in literal true (expecting 'r')", + } + }, + }, + { + desc: "mixed stdout and stderr", + setup: func(t *testing.T) setupData { + // We want to verify that we're able to correctly parse the output + // even if the process writes to both its stdout and stderr. + cmd := writeScript(t, "echo error >&2 && echo '{}'") + + return setupData{ + cfg: Config{ + ConfigCommand: cmd, + }, + expectedCfg: modifyDefaultConfig(func(cfg *Config) { + cfg.ConfigCommand = cmd + }), + } + }, + }, + { + desc: "empty script", + setup: func(t *testing.T) setupData { + cmd := writeScript(t, "echo '{}'") + + return setupData{ + cfg: Config{ + ConfigCommand: cmd, + }, + expectedCfg: modifyDefaultConfig(func(cfg *Config) { + cfg.ConfigCommand = cmd + }), + } + }, + }, + { + desc: "unknown value", + setup: func(t *testing.T) setupData { + cmd := writeScript(t, `echo '{"key_does_not_exist":"value"}'`) + + return setupData{ + cfg: Config{ + ConfigCommand: cmd, + }, + expectedCfg: modifyDefaultConfig(func(cfg *Config) { + cfg.ConfigCommand = cmd + }), + } + }, + }, + { + desc: "generated value", + setup: func(t *testing.T) setupData { + cmd := writeScript(t, `echo '{"socket_path": "value"}'`) + + return setupData{ + cfg: Config{ + ConfigCommand: cmd, + }, + expectedCfg: modifyDefaultConfig(func(cfg *Config) { + cfg.ConfigCommand = cmd + cfg.SocketPath = "value" + }), + } + }, + }, + { + desc: "overridden value", + setup: func(t *testing.T) setupData { + cmd := writeScript(t, `echo '{"socket_path": "overridden_value"}'`) + + return setupData{ + cfg: Config{ + ConfigCommand: cmd, + SocketPath: "initial_value", + }, + expectedCfg: modifyDefaultConfig(func(cfg *Config) { + cfg.ConfigCommand = cmd + cfg.SocketPath = "overridden_value" + }), + } + }, + }, + { + desc: "mixed configuration", + setup: func(t *testing.T) setupData { + cmd := writeScript(t, `echo '{"listen_addr": "listen_addr"}'`) + + return setupData{ + cfg: Config{ + ConfigCommand: cmd, + SocketPath: "socket_path", + }, + expectedCfg: modifyDefaultConfig(func(cfg *Config) { + cfg.ConfigCommand = cmd + cfg.SocketPath = "socket_path" + cfg.ListenAddr = "listen_addr" + }), + } + }, + }, + { + desc: "override default value", + setup: func(t *testing.T) setupData { + cmd := writeScript(t, `echo '{"storage": []}'`) + + return setupData{ + cfg: Config{ + ConfigCommand: cmd, + GracefulStopTimeout: duration.Duration(time.Second), + }, + expectedCfg: modifyDefaultConfig(func(cfg *Config) { + cfg.ConfigCommand = cmd + cfg.GracefulStopTimeout = duration.Duration(time.Second) + }), + } + }, + }, + { + desc: "subsections are being merged", + setup: func(t *testing.T) setupData { + cmd := writeScript(t, `cat <<-EOF + { + "failover": { + "bootstrap_interval": "13m" + } + } + EOF + `) + + return setupData{ + cfg: Config{ + ConfigCommand: cmd, + Failover: Failover{ + MonitorInterval: duration.Duration(14 * time.Minute), + }, + }, + expectedCfg: modifyDefaultConfig(func(cfg *Config) { + cfg.ConfigCommand = cmd + cfg.Failover.BootstrapInterval = duration.Duration(13 * time.Minute) + cfg.Failover.MonitorInterval = duration.Duration(14 * time.Minute) + }), + } + }, + }, + } { + tc := tc + + t.Run(tc.desc, func(t *testing.T) { + t.Parallel() + + setup := tc.setup(t) + + t.Run("FromReader", func(t *testing.T) { + var cfgBuffer bytes.Buffer + require.NoError(t, toml.NewEncoder(&cfgBuffer).Encode(setup.cfg)) + + cfg, err := FromReader(&cfgBuffer) + + // We can't use `require.Equal()` for the error as it's basically impossible + // to reproduce the exact `exec.ExitError`. + if setup.expectedErr != "" { + require.EqualError(t, err, setup.expectedErr) + } else { + require.NoError(t, err) + } + require.Equal(t, setup.expectedCfg, cfg) + }) + + t.Run("FromFile", func(t *testing.T) { + cfgPath := filepath.Join(testhelper.TempDir(t), "praefect.toml") + + cfgFile, err := os.Create(cfgPath) + require.NoError(t, err) + require.NoError(t, toml.NewEncoder(cfgFile).Encode(setup.cfg)) + testhelper.MustClose(t, cfgFile) + + cfg, err := FromFile(cfgPath) + + // We can't use `require.Equal()` for the error as it's basically impossible + // to reproduce the exact `exec.ExitError`. + if setup.expectedErr != "" { + require.EqualError(t, err, setup.expectedErr) + } else { + require.NoError(t, err) + } + require.Equal(t, setup.expectedCfg, cfg) + }) + }) + } +} |