Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitaly.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPatrick Steinhardt <psteinhardt@gitlab.com>2023-07-28 13:49:39 +0300
committerPatrick Steinhardt <psteinhardt@gitlab.com>2023-07-28 14:01:17 +0300
commit5ebe6f6d13f1182fdf6ac3e27b0441fc6085eace (patch)
tree84e48925a4fbe5eb3df099a6c04dc1e587dfa843
parent8cd3b5e4ea2ac188f06054e4bc6b4030ccf26353 (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.go23
-rw-r--r--internal/praefect/config/config_test.go266
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)
+ })
+ })
+ }
+}