From daf489b7e773f8355fba9d654d94d522cf31c46e Mon Sep 17 00:00:00 2001 From: Ben Wheatley Date: Tue, 23 Feb 2021 16:13:50 +0000 Subject: [PATCH 1/4] Remove envconsul dependency from theatre-envconsul Previously we've shelled-out to the `envconsul` binary in order to retrieve our secret material from Vault. Through this we've discovered several shortcomings: - Failure to provision 'secret files' which have large/non-ASCII bodies. - Failure to provision environment variables which are referencing the same Vault key path. - Hanging when a process is wrapped with `envconsul` but there's no secret material to fetch. - Workarounds required for shellwords splitting. By removing `envconsul` and implementing the fetching from Vault ourselves we simplify the code significantly and end up with something that's more performant and easy to reason about, as well as fixing the above issues. --- Dockerfile | 9 - .../v1alpha1/envconsulinjector_webhook.go | 1 - cmd/theatre-envconsul/main.go | 338 ++++++------------ 3 files changed, 111 insertions(+), 237 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1c55fb78..466f36b9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,15 +2,6 @@ FROM golang:1.14.5 as builder WORKDIR /go/src/github.com/gocardless/theatre -# Clone our fork of envconsul and build it -RUN set -x \ - && git clone https://github.com/gocardless/envconsul.git \ - && cd envconsul \ - && git checkout 2eb7fdc4dd1a13464e9a529e324ffd9b8d12ce25 \ - && make linux/amd64 \ - && mkdir ../bin \ - && mv pkg/linux_amd64/envconsul ../bin - COPY . /go/src/github.com/gocardless/theatre RUN make VERSION=$(cat VERSION) build diff --git a/apis/vault/v1alpha1/envconsulinjector_webhook.go b/apis/vault/v1alpha1/envconsulinjector_webhook.go index 2be24b1a..de63db01 100644 --- a/apis/vault/v1alpha1/envconsulinjector_webhook.go +++ b/apis/vault/v1alpha1/envconsulinjector_webhook.go @@ -320,7 +320,6 @@ func (i podInjector) configureContainer(reference corev1.Container, containerCon c := &reference args := []string{"exec"} - args = append(args, "--install-path", i.InstallPath) args = append(args, "--vault-address", i.Address) args = append(args, "--vault-path-prefix", secretMountPathPrefix) args = append(args, "--auth-backend-mount-path", i.AuthMountPath) diff --git a/cmd/theatre-envconsul/main.go b/cmd/theatre-envconsul/main.go index 1dd2ad13..631acb8a 100644 --- a/cmd/theatre-envconsul/main.go +++ b/cmd/theatre-envconsul/main.go @@ -2,8 +2,6 @@ package main import ( "context" - "encoding/base64" - "encoding/json" "fmt" "io" "io/ioutil" @@ -14,16 +12,15 @@ import ( "path/filepath" "strings" "syscall" - - "k8s.io/client-go/rest" - "k8s.io/client-go/tools/clientcmd" - - "gopkg.in/yaml.v2" + "time" "github.com/alecthomas/kingpin" "github.com/go-logr/logr" "github.com/hashicorp/vault/api" "github.com/pkg/errors" + "gopkg.in/yaml.v2" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" "github.com/gocardless/theatre/v2/cmd" "github.com/gocardless/theatre/v2/pkg/signals" @@ -41,23 +38,21 @@ var ( install = app.Command("install", "Install binaries into path") installPath = install.Flag("path", "Path to install theatre binaries").Default(defaultInstallPath).String() - installEnvconsulBinary = install.Flag("envconsul-binary", "Path to envconsul binary").Default("/usr/local/bin/envconsul").String() installTheatreEnvconsulBinary = install.Flag("theatre-envconsul-binary", "Path to theatre-envconsul binary").Default(defaultTheatreEnvconsulPath).String() exec = app.Command("exec", "Authenticate with vault and exec envconsul") execVaultOptions = newVaultOptions(exec) execConfigFile = exec.Flag("config-file", "App config file").String() - execInstallPath = exec.Flag("install-path", "Path containing installed binaries").Default(defaultInstallPath).String() - execTheatreEnvconsulBinary = exec.Flag("theatre-envconsul-binary", "Path to theatre-envconsul binary").Default(defaultTheatreEnvconsulPath).String() execServiceAccountTokenFile = exec.Flag("service-account-token-file", "Path to Kubernetes service account token file").String() execCommand = exec.Arg("command", "Command to execute").Required().Strings() - - base64Exec = app.Command("base64-exec", "Decode base64 encoded args and exec them").Hidden() - base64ExecCommand = base64Exec.Arg("base64-command", "Command to execute").Required().Strings() - - envCmd = app.Command("env", "Output environment as JSON").Hidden() ) +type environment map[string]string +type vaultFile struct { + vaultKey string + filesystemPath string +} + func main() { command := kingpin.MustParse(app.Parse(os.Args[1:])) logger = commonOpts.Logger() @@ -78,7 +73,6 @@ func mainError(ctx context.Context, command string) (err error) { // and pull secrets. case install.FullCommand(): files := map[string]string{ - *installEnvconsulBinary: "envconsul", *installTheatreEnvconsulBinary: "theatre-envconsul", } @@ -90,11 +84,11 @@ func mainError(ctx context.Context, command string) (err error) { } // Run the authentication dance against Vault, exchanging our Kubernetes service account - // token for a Vault token that can read secrets. Then prepare a Hashicorp envconsul - // configuration file and exec into envconsul with the Vault token, leaving envconsul to - // do all the secret fetching and lease management. + // token for a Vault token that can read secrets. Then parse the available environment + // variables and config file, if supplied, to determine a list of Vault paths to query + // for secret data. Once in possession of this secret data, set the environment + // variables and provision secret data to the filesystem as required. case exec.FullCommand(): - var vaultToken string if execVaultOptions.Token == "" { serviceAccountToken, err := getKubernetesToken(*execServiceAccountTokenFile) if err != nil { @@ -102,13 +96,21 @@ func mainError(ctx context.Context, command string) (err error) { } execVaultOptions.Decorate(logger).Info("logging into vault", "event", "vault.login") - vaultToken, err = execVaultOptions.Login(serviceAccountToken) + + vaultToken, err := execVaultOptions.Login(serviceAccountToken) if err != nil { return errors.Wrap(err, "failed to login to vault") } + + execVaultOptions.Token = vaultToken } - var env = environment{} + client, err := execVaultOptions.Client() + if err != nil { + return err + } + + env := environment{} // Load all the environment variables we currently know from our process for _, element := range os.Environ() { @@ -122,6 +124,7 @@ func mainError(ctx context.Context, command string) (err error) { "event", "config.load", "file_path", *execConfigFile, ) + config, err := loadConfigFromFile(*execConfigFile) if err != nil { return err @@ -134,16 +137,33 @@ func mainError(ctx context.Context, command string) (err error) { } } - var filePaths = environment{} + var ( + // Use a set to describe the keys that we need to pull from Vault, + // ensuring that API requests aren't repeated if environment variables or + // secret files use the same Vault key. + keysToFetch = map[string]bool{} + envPlain = environment{} + envFromVault = environment{} + vaultFiles = map[string]vaultFile{} + ) - // Rewrite 'vault-file:' prefixed env vars to 'vault:' prefixed env vars. Store the - // paths to which they should be written to in filePaths. When no path is - // provided, use "" as a placeholder. - // - // For reference, the expected formats are 'vault-file:tls-key/2021010100' and - // 'vault-file:ssh-key/2021010100:/home/user/.ssh/id_rsa' for key, value := range env { - if strings.HasPrefix(value, "vault-file:") { + switch { + // For all the environment values that look like they should be vault + // references, store the envvar -> vault path mapping, and add the vault + // path to our list to pull. + case strings.HasPrefix(value, "vault:"): + vaultKey := strings.TrimPrefix(value, "vault:") + + keysToFetch[vaultKey] = true + envFromVault[key] = vaultKey + + // Support 'vault-file:' prefixed env vars. + // + // For reference, the expected formats are + // 'vault-file:tls-key/2021010100' and + // 'vault-file:ssh-key/2021010100:/home/user/.ssh/id_rsa' + case strings.HasPrefix(value, "vault-file:"): trimmed := strings.TrimSpace( strings.TrimPrefix(value, "vault-file:"), ) @@ -152,88 +172,64 @@ func mainError(ctx context.Context, command string) (err error) { } split := strings.SplitN(trimmed, ":", 2) + vaultKey := split[0] + keysToFetch[vaultKey] = true // determine if we define a path at which to place the file. For SplitN, // N=2 so we only have two cases switch len(split) { case 2: // path and key - filePaths[key] = split[1] - env[key] = fmt.Sprintf("vault:%s", split[0]) + vaultFiles[key] = vaultFile{ + filesystemPath: split[1], + vaultKey: vaultKey, + } case 1: // just key - filePaths[key] = "" - env[key] = fmt.Sprintf("vault:%s", trimmed) + vaultFiles[key] = vaultFile{ + filesystemPath: "", + vaultKey: vaultKey, + } } + // For all environment variables that don't have a known prefix, store + // them in our map of plain envvars so that we can ensure that they're + // set before exec'ing the wrapped process, even if they've been defined + // in the configuration file rather than the process environment. + default: + envPlain[key] = value } } - var secretEnv = environment{} + secretEnv := environment{} - // For all the environment values that look like they should be vault references, we - // can place them in secretEnv so we can render an envconsul configuration file for - // them. - for key, value := range env { - if strings.HasPrefix(value, "vault:") { - secretEnv[key] = strings.TrimPrefix(value, "vault:") - } - } + for key := range keysToFetch { + path := path.Join(execVaultOptions.PathPrefix, key) - envconsulConfig := execVaultOptions.EnvconsulConfig( - secretEnv, vaultToken, *execTheatreEnvconsulBinary, - []string{*execTheatreEnvconsulBinary, "env"}, - ) - configJSONContents, err := json.Marshal(envconsulConfig) - if err != nil { - return err - } + resp, err := client.Logical().Read(path) + if err != nil { + return errors.Wrap(err, "failed to retrieve secret value from Vault") + } - tempConfigFile, err := ioutil.TempFile("", "envconsul-config-*.json") - if err != nil { - return errors.Wrap(err, "failed to create temporary file for envconsul") - } + if resp == nil { + return errors.Errorf("no secret data found at Vault KV path: %s", path) + } - logger.Info( - "creating envconsul config file", - "event", "envconsul_config_file.create", - "path", tempConfigFile.Name(), - ) - if err := ioutil.WriteFile(tempConfigFile.Name(), configJSONContents, 0444); err != nil { - return errors.Wrap(err, "failed to write temporary file for envconsul") + value := resp.Data["data"].(map[string]interface{})["data"].(string) + secretEnv[key] = value } // Set all our environment variables which will proxy through to our exec'd process - for key, value := range env { + for key, value := range envPlain { os.Setenv(key, value) } - envconsulBinaryPath := path.Join(*execInstallPath, "envconsul") - envconsulArgs := []string{"-once", "-config", tempConfigFile.Name()} - - logger.Info( - "executing envconsul", - "event", "envconsul.exec", - "binary", envconsulBinaryPath, - "path", tempConfigFile.Name(), - ) - - output, err := execpkg.CommandContext(ctx, envconsulBinaryPath, envconsulArgs...).Output() - if err != nil { - if ee, ok := err.(*execpkg.ExitError); ok { - output = ee.Stderr - } - - return errors.Wrapf(err, "failed to get envconsul environment variables: %s", output) - } - - envMap := map[string]string{} - err = json.Unmarshal(output, &envMap) - if err != nil { - return errors.Wrap(err, "failed to decode envconsul environment variables") + for key, value := range envFromVault { + os.Setenv(key, secretEnv[value]) } - // For every file reference in filePaths, write the value resolved by envconsul to - // the path in filePaths. Returns the path of the written file in the env var that - // requested it. - for key, path := range filePaths { + // For every 'vault file' defined in our configuration or environment variables, write + // the value out to the specified location on the filesystem, or a random path if not + // specified. + for key, file := range vaultFiles { + path := file.filesystemPath if path == "" { // generate file path prefixed by key tempFilePath, err := ioutil.TempFile("", fmt.Sprintf("%s-*", key)) @@ -241,32 +237,29 @@ func mainError(ctx context.Context, command string) (err error) { return errors.Wrap(err, fmt.Sprintf("failed to write temporary file for key %s", key)) } - path = tempFilePath.Name() + file.filesystemPath = tempFilePath.Name() + path = file.filesystemPath } // ensure the path structure is available err := os.MkdirAll(filepath.Dir(path), 0600) if err != nil { - return fmt.Errorf("failed to ensure path structure is available: %s", err.Error()) + return errors.Wrap(err, "failed to ensure path structure is available") } logger.Info( "creating vault secret file", - "event", "envconsul_secret_file.create", + "event", "secret_file.create", "path", path, ) + // write file with value of envMap[key] - if err := ioutil.WriteFile(path, []byte(envMap[key]), 0600); err != nil { + if err := ioutil.WriteFile(path, []byte(secretEnv[file.vaultKey]), 0600); err != nil { return errors.Wrap(err, fmt.Sprintf("failed to write file with key %s to path %s", key, path)) } // update the env with the location of the file we've written - envMap[key] = path - } - - // Update the environment variables based on updated environment variables - for key, value := range envMap { - os.Setenv(key, value) + os.Setenv(key, path) } command := (*execCommand)[0] @@ -282,51 +275,11 @@ func mainError(ctx context.Context, command string) (err error) { ) args := []string{command} - for _, arg := range (*execCommand)[1:] { - args = append(args, arg) - } + args = append(args, (*execCommand)[1:]...) // Run the command directly if err := syscall.Exec(binary, args, os.Environ()); err != nil { - return errors.Wrap(err, "failed to execute envconsul") - } - - // Hidden command that allows us to exec a command using base64 encoded arguments. As - // envconsul, the Hashicorp tool, only allows us to specify a command string, we have to - // ensure we preserve the original commands shellword split. - // - // We use the exec command to generate an envconsul config with base64 encoded - // arguments, passed to this base64-exec command, that we know will be split correctly. - // This command then does the final execution, ensuring the split remains consistent. - case base64Exec.FullCommand(): - args := []string{} - for _, base64arg := range *base64ExecCommand { - arg, err := base64.StdEncoding.DecodeString(base64arg) - if err != nil { - app.Fatalf("failed to decode base64 argument: %s", arg) - } - - args = append(args, string(arg)) - } - - var err error - args[0], err = execpkg.LookPath(args[0]) - if err != nil { - app.Fatalf("could not resolve binary for command: %v", err) - } - - if err := syscall.Exec(args[0], args, os.Environ()); err != nil { - return errors.Wrap(err, "failed to execute decoded arguments") - } - case envCmd.FullCommand(): - envMap := map[string]string{} - for _, envEntry := range os.Environ() { - vals := strings.SplitN(envEntry, "=", 2) - envMap[vals[0]] = vals[1] - } - err := json.NewEncoder(os.Stdout).Encode(envMap) - if err != nil { - return fmt.Errorf("failed to encode environment: %w", err) + return errors.Wrap(err, "failed to execute wrapped program") } default: @@ -341,7 +294,8 @@ func mainError(ctx context.Context, command string) (err error) { func getKubernetesToken(tokenFileOverride string) (string, error) { if tokenFileOverride != "" { tokenBytes, err := ioutil.ReadFile(tokenFileOverride) - return string(tokenBytes), err + + return string(tokenBytes), errors.Wrap(err, "failed to read kubernetes token file") } clusterConfig, err := rest.InClusterConfig() @@ -352,11 +306,11 @@ func getKubernetesToken(tokenFileOverride string) (string, error) { ).ClientConfig() if err != nil { - return "", err + return "", errors.Wrap(err, "failed to construct kubernetes client") } } - return clusterConfig.BearerToken, err + return clusterConfig.BearerToken, nil } // copyExecutable is designed to load an executable binary from our current environment @@ -395,6 +349,7 @@ type vaultOptions struct { AuthBackendMountPoint string AuthBackendRole string PathPrefix string + Timeout time.Duration } func newVaultOptions(cmd *kingpin.CmdClause) *vaultOptions { @@ -407,6 +362,7 @@ func newVaultOptions(cmd *kingpin.CmdClause) *vaultOptions { cmd.Flag("vault-use-tls", "Use TLS when connecting to Vault").Default("true").BoolVar(&opt.UseTLS) cmd.Flag("vault-insecure-skip-verify", "Skip TLS certificate verification when connecting to Vault").Default("false").BoolVar(&opt.InsecureSkipVerify) cmd.Flag("vault-path-prefix", "Path prefix to read Vault secret from").Default("").StringVar(&opt.PathPrefix) + cmd.Flag("vault-http-timeout", "Timeout in seconds when making requests to vault").Default("2s").DurationVar(&opt.Timeout) return opt } @@ -415,6 +371,11 @@ func (o *vaultOptions) Client() (*api.Client, error) { cfg := api.DefaultConfig() cfg.Address = o.Address + // By default the Vault library uses a retryable HTTP client, but we probably + // don't want to use the default timeout of 60 seconds as this will + // significantly slow the container start time. + cfg.Timeout = o.Timeout + transport := cfg.HttpClient.Transport.(*http.Transport) if o.InsecureSkipVerify { transport.TLSClientConfig.InsecureSkipVerify = true @@ -426,14 +387,14 @@ func (o *vaultOptions) Client() (*api.Client, error) { client, err := api.NewClient(cfg) if err != nil { - return nil, err + return nil, errors.Wrap(err, "failed to create vault client") } if o.Token != "" { client.SetToken(o.Token) } - return client, err + return client, nil } func (o *vaultOptions) Decorate(logger logr.Logger) logr.Logger { @@ -462,11 +423,11 @@ func (o *vaultOptions) Login(jwt string) (string, error) { resp, err := client.RawRequest(req) if err != nil { - return "", err + return "", errors.Wrap(err, "failed to perform login POST request against Vault auth backend mount") } if err := resp.Error(); err != nil { - return "", err + return "", errors.Wrap(err, "received error response from login POST request against Vault auth backend mount") } var secret api.Secret @@ -478,14 +439,12 @@ func (o *vaultOptions) Login(jwt string) (string, error) { } // Config is the configuration file format that the exec command will use to parse the -// Vault references that it will pass onto the envconsul command. We expect application +// Vault references that define where to pull secret material from. We expect application // developers to include this file within their applications. type Config struct { Environment environment `yaml:"environment"` } -type environment map[string]string - func loadConfigFromFile(configFile string) (Config, error) { var cfg Config @@ -504,78 +463,3 @@ func loadConfigFromFile(configFile string) (Config, error) { return cfg, nil } - -// EnvconsulConfig generates a configuration file that envconsul (hashicorp) can read, and -// will use to resolve secret values into environment variables. -// -// This will only work if your vault secrets have exactly one key. The format specifier we -// pass to envconsul uses no interpolation, so multiple keys in a vault secret would be -// assigned the same environment variable. This is undefined behaviour, resulting in -// subsequent executions setting different values for the same env var. -func (o *vaultOptions) EnvconsulConfig(env environment, token string, theatreEnvconsulPath string, args []string) *EnvconsulConfig { - base64args := []string{} - for _, arg := range args { - base64args = append(base64args, base64.StdEncoding.EncodeToString([]byte(arg))) - } - - cfg := &EnvconsulConfig{ - Vault: envconsulVault{ - Address: o.Address, - Token: token, - Retry: envconsulRetry{ - Enabled: false, - }, - SSL: envconsulSSL{ - Enabled: o.UseTLS, - Verify: !o.InsecureSkipVerify, - }, - }, - Exec: envconsulExec{ - // Base64 encode the command and pass it to theatre-envconsul base64-exec. This - // ensures we preserve command splitting, instead of relying on envconsul's shell - // splitting to do the right thing. - Command: fmt.Sprintf("%s %s %s", theatreEnvconsulPath, base64Exec.FullCommand(), strings.Join(base64args, " ")), - }, - Secret: []envconsulSecret{}, - } - - for key, value := range env { - path := path.Join(o.PathPrefix, value) - cfg.Secret = append(cfg.Secret, envconsulSecret{Format: key, Path: path}) - } - - return cfg -} - -// EnvconsulConfig defines the subset of the configuration we use for envconsul: -// https://github.com/hashicorp/envconsul/blob/master/config.go -type EnvconsulConfig struct { - Vault envconsulVault `json:"vault"` - Exec envconsulExec `json:"exec"` - Secret []envconsulSecret `json:"secret"` -} - -type envconsulVault struct { - Address string `json:"address"` - Token string `json:"token"` - Retry envconsulRetry `json:"retry"` - SSL envconsulSSL `json:"ssl"` -} - -type envconsulRetry struct { - Enabled bool `json:"enabled"` -} - -type envconsulSSL struct { - Enabled bool `json:"enabled"` - Verify bool `json:"verify"` -} - -type envconsulExec struct { - Command string `json:"command"` -} - -type envconsulSecret struct { - Format string `json:"format"` - Path string `json:"path"` -} From 08844bf65533e599cf2deeccc1908a78ba1a1f1e Mon Sep 17 00:00:00 2001 From: Ben Wheatley Date: Wed, 3 Feb 2021 00:16:06 +0000 Subject: [PATCH 2/4] Update tests for theatre-secrets/envconsul This removes the expectation that the envconsul_injector tests had for a --install-path arg being present in the container spec. It also adds two new test cases for when secrets contain: non binary characters, non ascii characters and shell words --- .../envconsulinjector_webhook_test.go | 4 -- cmd/vault-manager/acceptance/acceptance.go | 58 ++++++++++++++++--- 2 files changed, 51 insertions(+), 11 deletions(-) diff --git a/apis/vault/v1alpha1/envconsulinjector_webhook_test.go b/apis/vault/v1alpha1/envconsulinjector_webhook_test.go index bf08b5d9..53e73243 100644 --- a/apis/vault/v1alpha1/envconsulinjector_webhook_test.go +++ b/apis/vault/v1alpha1/envconsulinjector_webhook_test.go @@ -118,8 +118,6 @@ var _ = Describe("PodInjector", func() { }), "Args": Equal([]string{ "exec", - "--install-path", - "/var/run/theatre-envconsul", "--vault-address", "https://vault.example.com", "--vault-path-prefix", @@ -231,8 +229,6 @@ var _ = Describe("PodInjector", func() { }), "Args": Equal([]string{ "exec", - "--install-path", - "/var/run/theatre-envconsul", "--vault-address", "https://vault.example.com", "--vault-path-prefix", diff --git a/cmd/vault-manager/acceptance/acceptance.go b/cmd/vault-manager/acceptance/acceptance.go index 0ea16f97..adef4cab 100644 --- a/cmd/vault-manager/acceptance/acceptance.go +++ b/cmd/vault-manager/acceptance/acceptance.go @@ -28,8 +28,11 @@ const ( AuthBackendMountPath = "kubernetes" AuthBackendRole = "default" // use "=" characters in the secret to test the string splitting code in - // theatre-envconsol is correct - SentinelSecretValue = "eats=the=world" + // theatre-envconsul is correct + SentinelSecretValue = "eats=the=world" + SentinelSecretFileValue = "value\x00with\x00nulls" + SentinelSecretValueNonASCII = "valueΣwithλnonσASCIIμ" + SentinelSecretValueShellword = "echo $(env)" ) type Runner struct{} @@ -174,6 +177,30 @@ func (r *Runner) Prepare(logger kitlog.Logger, config *rest.Config) error { return err } + secretPath = "secret/data/kubernetes/staging/secret-reader/shellword" + secretData = map[string]interface{}{"data": map[string]interface{}{"data": SentinelSecretValueShellword}} + + logger.Log("msg", "writing shellword sentinel secret value", "path", secretPath) + if _, err := client.Logical().Write(secretPath, secretData); err != nil { + return err + } + + secretFilePath := "secret/data/kubernetes/staging/secret-reader/file-with-binary-contents" + secretFileData := map[string]interface{}{"data": map[string]interface{}{"data": SentinelSecretFileValue}} + + logger.Log("msg", "writing sentinel secret file", "path", secretFilePath) + if _, err := client.Logical().Write(secretFilePath, secretFileData); err != nil { + return err + } + + secretFilePath = "secret/data/kubernetes/staging/secret-reader/file-non-ascii" + secretFileData = map[string]interface{}{"data": map[string]interface{}{"data": SentinelSecretValueNonASCII}} + + logger.Log("msg", "writing non ascii sentinel secret file", "path", secretFilePath) + if _, err := client.Logical().Write(secretFilePath, secretFileData); err != nil { + return err + } + return nil } @@ -204,6 +231,8 @@ spec: env: - name: VAULT_RESOLVED_KEY value: vault:jimmy + - name: VAULT_TEST_SHELLWORD + value: vault:shellword command: - /usr/local/bin/theatre-envconsul args: @@ -211,7 +240,6 @@ spec: - --vault-address=http://vault.vault.svc.cluster.local:8200 - --vault-path-prefix=secret/data/kubernetes/staging/secret-reader - --no-vault-use-tls - - --install-path=/usr/local/bin - --service-account-token-file=/var/run/secrets/kubernetes.io/vault/token - -- - env @@ -241,6 +269,8 @@ spec: env: - name: VAULT_RESOLVED_KEY value: vault:jimmy + - name: VAULT_TEST_SHELLWORD + value: vault:shellword command: - env ` @@ -269,6 +299,8 @@ spec: env: - name: VAULT_RESOLVED_KEY value: vault:jimmy + - name: VAULT_TEST_SHELLWORD + value: vault:shellword command: - env ` @@ -296,12 +328,16 @@ spec: - name: VAULT_FILE_RESOLVED_KEY value: vault-file:jimmy:/tmp/jimmy - name: VAULT_TMP_FILE_RESOLVED_KEY - value: vault-file:jimmy + value: vault-file:file-with-binary-contents + - name: VAULT_NON_ASCII_FILE + value: vault-file:file-non-ascii command: - bash - -c - - 'echo -n "file:" && cat $(echo $VAULT_FILE_RESOLVED_KEY) && echo -n " tmp:" && cat $(echo $VAULT_TMP_FILE_RESOLVED_KEY)' - ` + - 'echo -n "file:" && cat $(echo $VAULT_FILE_RESOLVED_KEY) && + echo -n " tmp:" && cat $(echo $VAULT_TMP_FILE_RESOLVED_KEY) && + echo -n " ascii:" && cat $(echo $VAULT_NON_ASCII_FILE)' +` func (r *Runner) Run(logger kitlog.Logger, config *rest.Config) { var ( @@ -353,6 +389,8 @@ func (r *Runner) Run(logger kitlog.Logger, config *rest.Config) { var buffer bytes.Buffer _, err = io.Copy(&buffer, logs) + fmt.Fprint(GinkgoWriter, buffer.String()) + Expect(err).NotTo(HaveOccurred()) expects(buffer) } @@ -361,6 +399,9 @@ func (r *Runner) Run(logger kitlog.Logger, config *rest.Config) { Expect(buffer.String()).To( ContainSubstring(fmt.Sprintf("VAULT_RESOLVED_KEY=%s", SentinelSecretValue)), ) + Expect(buffer.String()).To( + ContainSubstring(fmt.Sprintf("VAULT_TEST_SHELLWORD=%s", SentinelSecretValueShellword)), + ) return } @@ -369,7 +410,10 @@ func (r *Runner) Run(logger kitlog.Logger, config *rest.Config) { ContainSubstring(fmt.Sprintf("file:%s", SentinelSecretValue)), ) Expect(buffer.String()).To( - ContainSubstring(fmt.Sprintf("tmp:%s", SentinelSecretValue)), + ContainSubstring(fmt.Sprintf("tmp:%s", strings.Split(SentinelSecretFileValue, "\n")[0])), + ) + Expect(buffer.String()).To( + ContainSubstring(fmt.Sprintf("ascii:%s", strings.Split(SentinelSecretValueNonASCII, "\n")[0])), ) return } From d272c98c65296e89a120c021b055325814ef6567 Mon Sep 17 00:00:00 2001 From: Theo Barber-Bany Date: Thu, 25 Feb 2021 14:06:30 +0000 Subject: [PATCH 3/4] %s/envconsul/secrets/g --- .dockerignore | 2 +- .goreleaser.yml | 6 +- Makefile | 4 +- README.md | 10 +-- apis/vault/v1alpha1/README.md | 82 +++++++++++-------- ..._webhook.go => secretsinjector_webhook.go} | 60 +++++++------- ...est.go => secretsinjector_webhook_test.go} | 34 ++++---- .../v1alpha1/testdata/app_no_config_pod.yaml | 2 +- .../testdata/app_with_config_pod.yaml | 2 +- cmd/theatre-envconsul/README.md | 47 ----------- cmd/theatre-secrets/README.md | 33 ++++++++ .../main.go | 18 ++-- cmd/vault-manager/acceptance/acceptance.go | 24 +++--- cmd/vault-manager/main.go | 8 +- .../setup/resources/staging-service.yaml | 2 +- config/base/kustomization.yaml | 2 +- config/base/webhooks/vault.yaml | 4 +- 17 files changed, 170 insertions(+), 170 deletions(-) rename apis/vault/v1alpha1/{envconsulinjector_webhook.go => secretsinjector_webhook.go} (84%) rename apis/vault/v1alpha1/{envconsulinjector_webhook_test.go => secretsinjector_webhook_test.go} (87%) delete mode 100644 cmd/theatre-envconsul/README.md create mode 100644 cmd/theatre-secrets/README.md rename cmd/{theatre-envconsul => theatre-secrets}/main.go (95%) diff --git a/.dockerignore b/.dockerignore index 8a0ecd47..76df6e49 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,5 +7,5 @@ config/ # Acceptance test suites don't need recompiling into our docker image cmd/acceptance/ -cmd/theatre-envconsul/acceptance/ +cmd/theatre-secrets/acceptance/ pkg/workloads/console/acceptance/ diff --git a/.goreleaser.yml b/.goreleaser.yml index 8cfa78bb..416ce70c 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -24,9 +24,9 @@ builds: - CGO_ENABLED=0 - <<: *commonBuildConfig - id: theatre-envconsul - binary: theatre-envconsul - main: cmd/theatre-envconsul/main.go + id: theatre-secrets + binary: theatre-secrets + main: cmd/theatre-secrets/main.go - <<: *commonBuildConfig id: vault-manager diff --git a/Makefile b/Makefile index b38bb548..5bd6bc5a 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -PROG=bin/rbac-manager bin/vault-manager bin/theatre-envconsul bin/workloads-manager bin/theatre-consoles +PROG=bin/rbac-manager bin/vault-manager bin/theatre-secrets bin/workloads-manager bin/theatre-consoles PROJECT=github.com/gocardless/theatre IMAGE=eu.gcr.io/gc-containers/gocardless/theatre VERSION=$(shell git rev-parse --short HEAD)-dev @@ -37,7 +37,7 @@ vet: go vet ./cmd/rbac-manager/... go vet ./cmd/vault-manager/... go vet ./cmd/workload-manager/... - go vet ./cmd/theatre-envconsul/... + go vet ./cmd/theatre-secrets/... generate: controller-gen $(CONTROLLER_GEN) object paths="./apis/rbac/..." diff --git a/README.md b/README.md index aa1acc6d..6e3dc6e1 100644 --- a/README.md +++ b/README.md @@ -47,8 +47,8 @@ expected to create or mutate pods, deployments, etc. Utilities for interacting with Vault. Primarily used to inject secret material into pods by use of annotations. -- `envconsul-injector.vault.crd.gocardless.com` webhook for injecting the - `theatre-envconsul` tool to populate a container's environment with secrets +- `secrets-injector.vault.crd.gocardless.com` webhook for injecting the + `theatre-secrets` tool to populate a container's environment with secrets from Vault before executing. ## Command line interfaces @@ -63,11 +63,11 @@ list, attach to and authorise [consoles](#workloads). Run: `go run cmd/theatre-consoles/main.go` -### theatre-envconsul +### theatre-secrets -See the [command README](cmd/theatre-envconsul/README.md) for further details. +See the [command README](cmd/theatre-secrets/README.md) for further details. -Run: `go run cmd/theatre-envconsul/main.go` +Run: `go run cmd/theatre-secrets/main.go` ## Getting Started diff --git a/apis/vault/v1alpha1/README.md b/apis/vault/v1alpha1/README.md index a3c62e3e..92c206ae 100644 --- a/apis/vault/v1alpha1/README.md +++ b/apis/vault/v1alpha1/README.md @@ -1,8 +1,7 @@ # vault -[envconsul]: https://github.com/hashicorp/envconsul -[theatre-envconsul]: ../../../cmd/theatre-envconsul -[theatre-envconsul-acceptance]: ../../../cmd/theatre-envconsul/acceptance/acceptance.go +[theatre-secrets]: ../../../cmd/theatre-secrets +[theatre-secrets-acceptance]: ../../../cmd/theatre-secrets/acceptance/acceptance.go This package contains any CRDs and webhooks GoCardless use to interact with Vault. At present, the only thing we provide is a webhook to automatically @@ -24,7 +23,7 @@ supported by the Vault ecosystem. Instead, we've built a webhook that listens for pods with an annotations like: ``` -envconsul-injector.vault.crd.gocardless.com/configs: app:config/env.yaml +secrets-injector.vault.crd.gocardless.com/configs: app:config/env.yaml ``` When our webhook sees this annotation, it tries configuring the `app` container @@ -32,10 +31,10 @@ to pull secrets from Vault, using the configuration from the `config/env.yaml` file within the container. It resolves these secrets and sets them as environment variables, finally running the original container process. -The webhook makes use of the [`theatre-envconsul`][theatre-envconsul] command to +The webhook makes use of the [`theatre-secrets`][theatre-secrets] command to perform an authentication dance with Vault. Once we've acquired a Vault token, we translate our simple [configuration file format](#config) into a Hashicorp -[envconsul][envconsul] config file, then use envconsul to perform the fetching +[secrets][secrets] config file, then use secrets to perform the fetching and lease-management of the secret values. ## Configuring Vault @@ -49,8 +48,8 @@ authentication exchange works as follows: review request against the API server configured on this auth backend. If the request succeeds, we know the token is valid, and we permit the login -The theatre-envconsul acceptance tests verify this flow against a Vault server. -If anything is unclear, look at the [Prepare][theatre-envconsul-acceptance] +The theatre-secrets acceptance tests verify this flow against a Vault server. +If anything is unclear, look at the [Prepare][theatre-secrets-acceptance] method for how we configure the test Vault server. ## How does the webhook work @@ -58,6 +57,24 @@ method for how we configure the test Vault server. Once installed, the webhook will listen for containers with a specific annotation: +```yaml +--- +apiVersion: v1 +kind: Pod +metadata: + name: app + annotations: + "secrets-injector.vault.crd.gocardless.com/configs": "app" + "envconsul-injector.vault.crd.gocardless.com/configs": "app" +spec: + containers: + - name: app + command: + - env +``` +Currently we still support the now deprecated envconsul-injector annotation as +well. The two should not be used together. + ```yaml --- apiVersion: v1 @@ -77,14 +94,11 @@ We will modify this pod to do the following... ### 1. Install binaries -Add an init container that installs `theatre-envconsul` and the Hashicorp -[envconsul][envconsul] tool into a temporary installation path volume. We use -a default storage medium (likely a physical disk backed root filesystem) for -storage, as the sum of these injected binaries can become quite large. - -This installation volume will be mounted into any of the containers that are -targeted by the `envconsul-injector.vault.crd.gocardless.com/configs` -annotation. In our example, this means the `app` container is the only target. +Add an init container that installs the `theatre-secrets` tool into a temporary +installation path volume. We use a default storage medium. This installation +volume will be mounted into any of the containers that are targeted by the +`secrets-injector.vault.crd.gocardless.com/configs` annotation. In our example, +this means the `app` container is the only target. ```yaml --- @@ -93,26 +107,26 @@ kind: Pod metadata: name: app annotations: - "envconsul-injector.vault.crd.gocardless.com/configs": "app" + "secrets-injector.vault.crd.gocardless.com/configs": "app" spec: initContainers: - - name: theatre-envconsul-injector + - name: theatre-secrets-injector image: theatre:latest imagePullPolicy: IfNotPresent command: - - theatre-envconsul + - theatre-secrets - install - --path - /var/run/theatre volumeMounts: - mountPath: /var/run/theatre - name: theatre-envconsul-install + name: theatre-secrets-install containers: - name: app command: - env volumes: - - name: theatre-envconsul-install + - name: theatre-secrets-install emptyDir: {} ``` @@ -134,7 +148,7 @@ kind: Pod metadata: name: app annotations: - "envconsul-injector.vault.crd.gocardless.com/configs": "app" + "secrets-injector.vault.crd.gocardless.com/configs": "app" spec: initContainers: ... containers: @@ -142,12 +156,12 @@ spec: command: - env volumeMounts: - - name: theatre-envconsul-serviceaccount + - name: theatre-secrets-serviceaccount mountPath: /var/run/secrets/kubernetes.io/vault volumes: - - name: theatre-envconsul-install + - name: theatre-secrets-install emptyDir: {} - - name: theatre-envconsul-serviceaccount + - name: theatre-secrets-serviceaccount projected: sources: - serviceAccountToken: @@ -155,12 +169,12 @@ spec: expirationSeconds: 900 ``` -### 3. Prepend theatre-envconsul inject +### 3. Prepend theatre-secrets inject The container must resolve secrets before we run the original command. We use -theatre-envconsul to perform the resolution, then exec the original container +theatre-secrets to perform the resolution, then exec the original container command. The application container has access to the theatre binaries via the -init container, having installed them in the `theatre-envconsul-install` volume: +init container, having installed them in the `theatre-secrets-install` volume: ```yaml --- @@ -169,13 +183,13 @@ kind: Pod metadata: name: app annotations: - "envconsul-injector.vault.crd.gocardless.com/configs": "app" + "secrets-injector.vault.crd.gocardless.com/configs": "app" spec: initContainers: ... containers: - name: app command: - - /var/run/theatre/theatre-envconsul + - /var/run/theatre/theatre-secrets args: - exec - --vault-address=http://vault.vault.svc.cluster.local:8200 @@ -185,14 +199,14 @@ spec: - -- - env volumeMounts: - - name: theatre-envconsul-install + - name: theatre-secrets-install mountPath: /var/run/theatre - - name: theatre-envconsul-serviceaccount + - name: theatre-secrets-serviceaccount mountPath: /var/run/secrets/kubernetes.io/vault volumes: - - name: theatre-envconsul-install + - name: theatre-secrets-install emptyDir: {} - - name: theatre-envconsul-serviceaccount + - name: theatre-secrets-serviceaccount projected: sources: - serviceAccountToken: diff --git a/apis/vault/v1alpha1/envconsulinjector_webhook.go b/apis/vault/v1alpha1/secretsinjector_webhook.go similarity index 84% rename from apis/vault/v1alpha1/envconsulinjector_webhook.go rename to apis/vault/v1alpha1/secretsinjector_webhook.go index de63db01..7c688fd7 100644 --- a/apis/vault/v1alpha1/envconsulinjector_webhook.go +++ b/apis/vault/v1alpha1/secretsinjector_webhook.go @@ -19,29 +19,29 @@ import ( "sigs.k8s.io/controller-runtime/pkg/webhook/admission" ) -const EnvconsulInjectorFQDN = "envconsul-injector.vault.crd.gocardless.com" +const SecretsInjectorFQDN = "secrets-injector.vault.crd.gocardless.com" -type EnvconsulInjector struct { +type SecretsInjector struct { client client.Client logger logr.Logger decoder *admission.Decoder - opts EnvconsulInjectorOptions + opts SecretsInjectorOptions } -func NewEnvconsulInjector(c client.Client, logger logr.Logger, opts EnvconsulInjectorOptions) *EnvconsulInjector { - return &EnvconsulInjector{ +func NewSecretsInjector(c client.Client, logger logr.Logger, opts SecretsInjectorOptions) *SecretsInjector { + return &SecretsInjector{ client: c, logger: logger, opts: opts, } } -func (e *EnvconsulInjector) InjectDecoder(d *admission.Decoder) error { +func (e *SecretsInjector) InjectDecoder(d *admission.Decoder) error { e.decoder = d return nil } -type EnvconsulInjectorOptions struct { +type SecretsInjectorOptions struct { Image string // image of theatre to use when constructing pod InstallPath string // location of vault installation directory NamespaceLabel string // namespace label that enables webhook to operate on @@ -55,28 +55,28 @@ var ( podLabels = []string{"pod_namespace"} handleTotal = prometheus.NewCounterVec( prometheus.CounterOpts{ - Name: "theatre_vault_envconsul_injector_handle_total", + Name: "theatre_vault_secrets_injector_handle_total", Help: "Count of requests handled by the webhook", }, podLabels, ) mutateTotal = prometheus.NewCounterVec( prometheus.CounterOpts{ - Name: "theatre_vault_envconsul_injector_mutate_total", + Name: "theatre_vault_secrets_injector_mutate_total", Help: "Count of pods mutated by the webhook", }, podLabels, ) skipTotal = prometheus.NewCounterVec( prometheus.CounterOpts{ - Name: "theatre_vault_envconsul_injector_skip_total", + Name: "theatre_vault_secrets_injector_skip_total", Help: "Count of pods skipped by the webhook, as they lack annotations", }, podLabels, ) errorsTotal = prometheus.NewCounterVec( prometheus.CounterOpts{ - Name: "theatre_vault_envconsul_injector_errors_total", + Name: "theatre_vault_secrets_injector_errors_total", Help: "Count of not-allowed responses from webhook", }, podLabels, @@ -88,7 +88,7 @@ func init() { metrics.Registry.MustRegister(handleTotal, mutateTotal, skipTotal, errorsTotal) } -func (i *EnvconsulInjector) Handle(ctx context.Context, req admission.Request) (resp admission.Response) { +func (i *SecretsInjector) Handle(ctx context.Context, req admission.Request) (resp admission.Response) { labels := prometheus.Labels{"pod_namespace": req.Namespace} logger := i.logger.WithValues("uuid", string(req.UID)) logger.Info("starting request", "event", "request.start") @@ -117,7 +117,7 @@ func (i *EnvconsulInjector) Handle(ctx context.Context, req admission.Request) ( // path for all pod creation. We need to exit for pods that don't have the // annotation on them here so they can start uninterrupted in the event // code futher along returns an error. - if _, ok := pod.Annotations[fmt.Sprintf("%s/configs", EnvconsulInjectorFQDN)]; !ok { + if _, ok := pod.Annotations[fmt.Sprintf("%s/configs", SecretsInjectorFQDN)]; !ok { logger.Info("skipping pod with no annotation", "event", "pod.skipped", "msg", "no annotation found") skipTotal.With(labels).Inc() return admission.Allowed("no annotation found") @@ -149,7 +149,7 @@ func (i *EnvconsulInjector) Handle(ctx context.Context, req admission.Request) ( return admission.Errored(http.StatusInternalServerError, err) } - mutatedPod := podInjector{EnvconsulInjectorOptions: i.opts, vaultConfig: vaultConfig}.Inject(*pod) + mutatedPod := podInjector{SecretsInjectorOptions: i.opts, vaultConfig: vaultConfig}.Inject(*pod) if mutatedPod == nil { logger.Info("no annotation found during inject - this should never occur", "event", "pod.skipped", "msg") return admission.Allowed("no annotation found") @@ -179,15 +179,15 @@ func newVaultConfig(cfgmap *corev1.ConfigMap) (vaultConfig, error) { return cfg, mapstructure.Decode(cfgmap.Data, &cfg) } -// podInjector isolates the logic around injecting theatre-envconsul away from anything to +// podInjector isolates the logic around injecting theatre-secrets away from anything to // do with mutating webhooks. This makes it easy to unit test without getting tangled in // webhook noise. type podInjector struct { - EnvconsulInjectorOptions + SecretsInjectorOptions vaultConfig } -// Inject configures the given pod to use theatre-envconsul. If it returns nil, it's +// Inject configures the given pod to use theatre-secrets. If it returns nil, it's // because the pod isn't configured for injection. func (i podInjector) Inject(pod corev1.Pod) *corev1.Pod { containerConfigs := parseContainerConfigs(pod) @@ -203,7 +203,7 @@ func (i podInjector) Inject(pod corev1.Pod) *corev1.Pod { mutatedPod.Spec.Volumes, // Installation directory for theatre binaries, used as a scratch installation path corev1.Volume{ - Name: "theatre-envconsul-install", + Name: "theatre-secrets-install", VolumeSource: corev1.VolumeSource{ EmptyDir: &corev1.EmptyDirVolumeSource{}, }, @@ -211,7 +211,7 @@ func (i podInjector) Inject(pod corev1.Pod) *corev1.Pod { // Projected service account tokens that are automatically rotated, unlike the default // service account tokens Kubernetes normally mounts. corev1.Volume{ - Name: "theatre-envconsul-serviceaccount", + Name: "theatre-secrets-serviceaccount", VolumeSource: corev1.VolumeSource{ Projected: &corev1.ProjectedVolumeSource{ // Ensure this token is readable by whatever user the container might run in, as @@ -260,17 +260,17 @@ func (i podInjector) Inject(pod corev1.Pod) *corev1.Pod { // parseContainerConfigs extracts the pod annotation and parses that configuration // required for this container. // -// envconsul-injector.vault.crd.gocardless.com/configs: app:config.yaml,sidecar +// secrets-injector.vault.crd.gocardless.com/configs: app:config.yaml,sidecar // // Valid values for the annotation are: // // annotation ::= container_config | ',' annotation // container_config ::= container_name ( ':' config_file )? // -// If no config file is specified, we inject theatre-envconsul but don't load +// If no config file is specified, we inject theatre-secrets but don't load // configuration from files, relying solely on environment variables. func parseContainerConfigs(pod corev1.Pod) map[string]string { - configString, ok := pod.Annotations[fmt.Sprintf("%s/configs", EnvconsulInjectorFQDN)] + configString, ok := pod.Annotations[fmt.Sprintf("%s/configs", SecretsInjectorFQDN)] if !ok { return nil } @@ -290,13 +290,13 @@ func parseContainerConfigs(pod corev1.Pod) map[string]string { func (i podInjector) buildInitContainer() corev1.Container { return corev1.Container{ - Name: "theatre-envconsul-injector", + Name: "theatre-secrets-injector", Image: i.Image, ImagePullPolicy: corev1.PullIfNotPresent, - Command: []string{"theatre-envconsul", "install", "--path", i.InstallPath}, + Command: []string{"theatre-secrets", "install", "--path", i.InstallPath}, VolumeMounts: []corev1.VolumeMount{ { - Name: "theatre-envconsul-install", + Name: "theatre-secrets-install", MountPath: i.InstallPath, ReadOnly: false, }, @@ -314,8 +314,8 @@ func (i podInjector) buildInitContainer() corev1.Container { } } -// configureContainer returns a copy with the command modified to run theatre-envconsul, -// along with a volume mount that will contain the envconsul binaries. +// configureContainer returns a copy with the command modified to run theatre-secrets, +// along with a volume mount that will contain the secrets binaries. func (i podInjector) configureContainer(reference corev1.Container, containerConfigPath, secretMountPathPrefix string) corev1.Container { c := &reference @@ -335,7 +335,7 @@ func (i podInjector) configureContainer(reference corev1.Container, containerCon execCommand = append(execCommand, reference.Args...) args = append(args, execCommand...) - c.Command = []string{path.Join(i.InstallPath, "theatre-envconsul")} + c.Command = []string{path.Join(i.InstallPath, "theatre-secrets")} c.Args = args c.VolumeMounts = append( @@ -343,13 +343,13 @@ func (i podInjector) configureContainer(reference corev1.Container, containerCon // Mount the binaries from our installation, ensuring we can run the command in this // container corev1.VolumeMount{ - Name: "theatre-envconsul-install", + Name: "theatre-secrets-install", MountPath: i.InstallPath, ReadOnly: true, }, // Explicitly mount service account tokens from the projected volume corev1.VolumeMount{ - Name: "theatre-envconsul-serviceaccount", + Name: "theatre-secrets-serviceaccount", MountPath: path.Dir(i.ServiceAccountTokenFile), ReadOnly: true, }, diff --git a/apis/vault/v1alpha1/envconsulinjector_webhook_test.go b/apis/vault/v1alpha1/secretsinjector_webhook_test.go similarity index 87% rename from apis/vault/v1alpha1/envconsulinjector_webhook_test.go rename to apis/vault/v1alpha1/secretsinjector_webhook_test.go index 53e73243..f13c7154 100644 --- a/apis/vault/v1alpha1/envconsulinjector_webhook_test.go +++ b/apis/vault/v1alpha1/secretsinjector_webhook_test.go @@ -40,9 +40,9 @@ var _ = Describe("PodInjector", func() { AuthRole: "default", SecretMountPathPrefix: "secret/data/kubernetes", }, - EnvconsulInjectorOptions: EnvconsulInjectorOptions{ + SecretsInjectorOptions: SecretsInjectorOptions{ Image: "theatre:latest", - InstallPath: "/var/run/theatre-envconsul", + InstallPath: "/var/run/theatre-secrets", VaultConfigMapKey: client.ObjectKey{ Namespace: "vault-system", Name: "vault-config", @@ -74,10 +74,10 @@ var _ = Describe("PodInjector", func() { ContainElement( MatchFields( IgnoreExtras, Fields{ - "Name": Equal("theatre-envconsul-injector"), + "Name": Equal("theatre-secrets-injector"), "Image": Equal("theatre:latest"), "Command": Equal([]string{ - "theatre-envconsul", "install", "--path", "/var/run/theatre-envconsul", + "theatre-secrets", "install", "--path", "/var/run/theatre-secrets", }), }, ), @@ -89,7 +89,7 @@ var _ = Describe("PodInjector", func() { var projection *corev1.ServiceAccountTokenProjection for _, volume := range pod.Spec.Volumes { - if volume.Name != "theatre-envconsul-serviceaccount" { + if volume.Name != "theatre-secrets-serviceaccount" { continue } @@ -107,14 +107,14 @@ var _ = Describe("PodInjector", func() { ) }) - It("Modifies command to prefix theatre-envconsul", func() { + It("Modifies command to prefix theatre-secrets", func() { Expect(pod.Spec.Containers).To( ContainElement( MatchFields( IgnoreExtras, Fields{ "Name": Equal("app"), "Command": Equal([]string{ - "/var/run/theatre-envconsul/theatre-envconsul", + "/var/run/theatre-secrets/theatre-secrets", }), "Args": Equal([]string{ "exec", @@ -157,7 +157,7 @@ var _ = Describe("PodInjector", func() { ) }) - It("Adds theatre-envconsul-install volumeMount", func() { + It("Adds theatre-secrets-install volumeMount", func() { Expect(pod.Spec.Containers).To( ContainElement( MatchFields( @@ -165,8 +165,8 @@ var _ = Describe("PodInjector", func() { "Name": Equal("app"), "VolumeMounts": ContainElement( corev1.VolumeMount{ - Name: "theatre-envconsul-install", - MountPath: "/var/run/theatre-envconsul", + Name: "theatre-secrets-install", + MountPath: "/var/run/theatre-secrets", ReadOnly: true, }, ), @@ -184,7 +184,7 @@ var _ = Describe("PodInjector", func() { "Name": Equal("app"), "VolumeMounts": ContainElement( corev1.VolumeMount{ - Name: "theatre-envconsul-serviceaccount", + Name: "theatre-secrets-serviceaccount", MountPath: "/var/run/secrets/kubernetes.io/vault", ReadOnly: true, }, @@ -218,14 +218,14 @@ var _ = Describe("PodInjector", func() { fixture = mustPodFixture("./testdata/app_with_config_pod.yaml") }) - It("Modifies command to prefix theatre-envconsul with config path", func() { + It("Modifies command to prefix theatre-secrets with config path", func() { Expect(pod.Spec.Containers).To( ContainElement( MatchFields( IgnoreExtras, Fields{ "Name": Equal("app"), "Command": Equal([]string{ - "/var/run/theatre-envconsul/theatre-envconsul", + "/var/run/theatre-secrets/theatre-secrets", }), "Args": Equal([]string{ "exec", @@ -271,7 +271,7 @@ var _ = Describe("parseContainerConfigs", func() { Context("With app with no config path", func() { BeforeEach(func() { - fixture.ObjectMeta.Annotations = map[string]string{fmt.Sprintf("%s/configs", EnvconsulInjectorFQDN): "app"} + fixture.ObjectMeta.Annotations = map[string]string{fmt.Sprintf("%s/configs", SecretsInjectorFQDN): "app"} }) It("Returns app with no config path", func() { @@ -281,7 +281,7 @@ var _ = Describe("parseContainerConfigs", func() { Context("With app with config path", func() { BeforeEach(func() { - fixture.ObjectMeta.Annotations = map[string]string{fmt.Sprintf("%s/configs", EnvconsulInjectorFQDN): "app:path/to/config.yaml"} + fixture.ObjectMeta.Annotations = map[string]string{fmt.Sprintf("%s/configs", SecretsInjectorFQDN): "app:path/to/config.yaml"} }) It("Returns app with config path", func() { @@ -291,7 +291,7 @@ var _ = Describe("parseContainerConfigs", func() { Context("With app with spaces in config path", func() { BeforeEach(func() { - fixture.ObjectMeta.Annotations = map[string]string{fmt.Sprintf("%s/configs", EnvconsulInjectorFQDN): " app : path/to/config.yaml"} + fixture.ObjectMeta.Annotations = map[string]string{fmt.Sprintf("%s/configs", SecretsInjectorFQDN): " app : path/to/config.yaml"} }) It("Returns app with config path with spaces stripped", func() { @@ -301,7 +301,7 @@ var _ = Describe("parseContainerConfigs", func() { Context("With multiple apps with and without config", func() { BeforeEach(func() { - fixture.ObjectMeta.Annotations = map[string]string{fmt.Sprintf("%s/configs", EnvconsulInjectorFQDN): "app: path/to/config.yaml, app2, app3: path/to/config3.yaml"} + fixture.ObjectMeta.Annotations = map[string]string{fmt.Sprintf("%s/configs", SecretsInjectorFQDN): "app: path/to/config.yaml, app2, app3: path/to/config3.yaml"} }) It("Returns multiple apps with and without config", func() { diff --git a/apis/vault/v1alpha1/testdata/app_no_config_pod.yaml b/apis/vault/v1alpha1/testdata/app_no_config_pod.yaml index f058a3a9..f2487c06 100644 --- a/apis/vault/v1alpha1/testdata/app_no_config_pod.yaml +++ b/apis/vault/v1alpha1/testdata/app_no_config_pod.yaml @@ -5,7 +5,7 @@ metadata: name: app namespace: staging annotations: { - "envconsul-injector.vault.crd.gocardless.com/configs": "app" + "secrets-injector.vault.crd.gocardless.com/configs": "app" } spec: serviceAccountName: secret-reader diff --git a/apis/vault/v1alpha1/testdata/app_with_config_pod.yaml b/apis/vault/v1alpha1/testdata/app_with_config_pod.yaml index 0525ab28..e7a95726 100644 --- a/apis/vault/v1alpha1/testdata/app_with_config_pod.yaml +++ b/apis/vault/v1alpha1/testdata/app_with_config_pod.yaml @@ -5,7 +5,7 @@ metadata: name: app namespace: staging annotations: { - "envconsul-injector.vault.crd.gocardless.com/configs": "app:config/app.yaml" + "secrets-injector.vault.crd.gocardless.com/configs": "app:config/app.yaml" } spec: serviceAccountName: secret-reader diff --git a/cmd/theatre-envconsul/README.md b/cmd/theatre-envconsul/README.md deleted file mode 100644 index 63367580..00000000 --- a/cmd/theatre-envconsul/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# theatre-envconsul - -[envconsul]: https://github.com/hashicorp/envconsul - -This binary provides the functionality required to authenticate with and pull -secrets from Vault, along with the injection of these secrets into process -environment variables. It relies on Hashicorp's [`envconsul`][envconsul] tool -for the injection of secret material. - -## `install` - -Install `theatre-envconsul` and Hashicorp's `envconsul` into a specific path. -This is run in an init container in order to prepare a shared Kubernetes volume -with these binaries, as they will be needed by the primary pod containers in -order to fetch secrets from Vault. - -## `exec` - -This is run as pid 1 of containers that want to use secrets from Vault in their -application environments. This is a shim around Hashicorp's `envconsul`, and it: - -- Performs an authentication flow with Vault, exchanging a Kubernetes service - account token for a Vault token -- Renders a Hashicorp `envconsul` configuration file with the Vault token, - specifying the command the `envconsul` should run and how to find Vault, etc. -- Exec's into `envconsul` with the rendered configuration file to obtain secrets -- Runs the command providing the fetched secrets in the processes environment - -## `base64-exec` - -This is a hidden command, and is leveraged by `exec`. As `envconsul` only -provides a command string, not a list of command and arguments, it performs -shellword splitting inside the `envconsul` binary. - -Shell splitting is unreliable, and we want to ensure any container command is -exec'd in the same way Kubernetes normally would, had we run it outside of the -theatre-envconsul command. To do this, `exec` renders an `envconsul` file with a -command string of: - -``` -command = "/usr/local/bin/theatre-envconsul base64-exec " -``` - -This means envconsul calls back into `theatre-envconsul` once it's prepared the -application environment with Vault secrets, at which point the `base64-exec` -command will decode the args and exec the command as it was originally specified -in the Kubernetes pod. diff --git a/cmd/theatre-secrets/README.md b/cmd/theatre-secrets/README.md new file mode 100644 index 00000000..ac952761 --- /dev/null +++ b/cmd/theatre-secrets/README.md @@ -0,0 +1,33 @@ +# theatre-secrets + + +This binary provides the functionality required to authenticate with and pull +secrets from Vault, along with the injection of these secrets into process +environment variables. + +## `install` + +Install `theatre-secrets` into a specific path. This is run in an init +container in order to prepare a shared Kubernetes volume with the binary, +as it will be needed by the primary pod containers in order to fetch secrets +from Vault. + +## `exec` + +This is run as pid 1 of containers that want to use secrets from Vault in their +application environments. It: + +- Performs an authentication flow with Vault, exchanging a Kubernetes service + account token for a Vault token +- For any environment variable that is formatted `vault:/some/secret`, fetches + the secret and places its contents back into the env var +- For any environment variable that is formatted + `vault-file:/some/secret:/some/path`, fetches the secret and places its + contents at the provided path. The provided path is returned to the env var + for convenience +- For any environment variable that is formatted `vault-file:/some/secret`, + fetches the secret and places its contents at a temporary path based on the + name of the secret. The temporary path is returned to the env var for + convenience +- Runs the command providing the fetched secrets in the processes environment + diff --git a/cmd/theatre-envconsul/main.go b/cmd/theatre-secrets/main.go similarity index 95% rename from cmd/theatre-envconsul/main.go rename to cmd/theatre-secrets/main.go index 631acb8a..d9bc37fb 100644 --- a/cmd/theatre-envconsul/main.go +++ b/cmd/theatre-secrets/main.go @@ -29,18 +29,18 @@ import ( var logger logr.Logger var ( - app = kingpin.New("theatre-envconsul", "Kubernetes container vault support using envconsul").Version(cmd.VersionStanza()) + app = kingpin.New("theatre-secrets", "Kubernetes container vault support using secrets").Version(cmd.VersionStanza()) commonOpts = cmd.NewCommonOptions(app) - defaultInstallPath = "/var/theatre-vault" - defaultTheatreEnvconsulPath, _ = os.Executable() + defaultInstallPath = "/var/theatre-vault" + defaultTheatreSecretsPath, _ = os.Executable() - install = app.Command("install", "Install binaries into path") - installPath = install.Flag("path", "Path to install theatre binaries").Default(defaultInstallPath).String() - installTheatreEnvconsulBinary = install.Flag("theatre-envconsul-binary", "Path to theatre-envconsul binary").Default(defaultTheatreEnvconsulPath).String() + install = app.Command("install", "Install binaries into path") + installPath = install.Flag("path", "Path to install theatre binaries").Default(defaultInstallPath).String() + installTheatreSecretsBinary = install.Flag("theatre-secrets-binary", "Path to theatre-secrets binary").Default(defaultTheatreSecretsPath).String() - exec = app.Command("exec", "Authenticate with vault and exec envconsul") + exec = app.Command("exec", "Authenticate with vault and exec secrets") execVaultOptions = newVaultOptions(exec) execConfigFile = exec.Flag("config-file", "App config file").String() execServiceAccountTokenFile = exec.Flag("service-account-token-file", "Path to Kubernetes service account token file").String() @@ -73,7 +73,7 @@ func mainError(ctx context.Context, command string) (err error) { // and pull secrets. case install.FullCommand(): files := map[string]string{ - *installTheatreEnvconsulBinary: "theatre-envconsul", + *installTheatreSecretsBinary: "theatre-secrets", } logger.Info("copying files into install path", "file_path", *installPath) @@ -270,7 +270,7 @@ func mainError(ctx context.Context, command string) (err error) { logger.Info( "executing wrapped application", - "event", "theatre_envconsul.exec", + "event", "theatre_secrets.exec", "binary", binary, ) diff --git a/cmd/vault-manager/acceptance/acceptance.go b/cmd/vault-manager/acceptance/acceptance.go index adef4cab..54f472d5 100644 --- a/cmd/vault-manager/acceptance/acceptance.go +++ b/cmd/vault-manager/acceptance/acceptance.go @@ -28,7 +28,7 @@ const ( AuthBackendMountPath = "kubernetes" AuthBackendRole = "default" // use "=" characters in the secret to test the string splitting code in - // theatre-envconsul is correct + // theatre-secrets is correct SentinelSecretValue = "eats=the=world" SentinelSecretFileValue = "value\x00with\x00nulls" SentinelSecretValueNonASCII = "valueΣwithλnonσASCIIμ" @@ -218,7 +218,7 @@ spec: serviceAccountName: secret-reader restartPolicy: Never volumes: - - name: theatre-envconsul-serviceaccount + - name: theatre-secrets-serviceaccount projected: sources: - serviceAccountToken: @@ -234,7 +234,7 @@ spec: - name: VAULT_TEST_SHELLWORD value: vault:shellword command: - - /usr/local/bin/theatre-envconsul + - /usr/local/bin/theatre-secrets args: - exec - --vault-address=http://vault.vault.svc.cluster.local:8200 @@ -244,11 +244,11 @@ spec: - -- - env volumeMounts: - - name: theatre-envconsul-serviceaccount + - name: theatre-secrets-serviceaccount mountPath: /var/run/secrets/kubernetes.io/vault ` -// This pod tests that our mutating webhook injects theatre-envconsul. We'll verify the +// This pod tests that our mutating webhook injects theatre-secrets. We'll verify the // environment is set correctly. const annotatedPodYAML = ` --- @@ -258,7 +258,7 @@ metadata: generateName: read-a-secret- namespace: staging # provisioned by the acceptance kustomize overlay annotations: - envconsul-injector.vault.crd.gocardless.com/configs: app + secrets-injector.vault.crd.gocardless.com/configs: app spec: serviceAccountName: secret-reader restartPolicy: Never @@ -285,7 +285,7 @@ metadata: generateName: read-a-secret- namespace: staging # provisioned by the acceptance kustomize overlay annotations: - envconsul-injector.vault.crd.gocardless.com/configs: app + secrets-injector.vault.crd.gocardless.com/configs: app spec: serviceAccountName: secret-reader restartPolicy: Never @@ -313,7 +313,7 @@ metadata: generateName: read-a-secret- namespace: staging # provisioned by the acceptance kustomize overlay annotations: - envconsul-injector.vault.crd.gocardless.com/configs: app + secrets-injector.vault.crd.gocardless.com/configs: app spec: serviceAccountName: secret-reader restartPolicy: Never @@ -418,12 +418,12 @@ func (r *Runner) Run(logger kitlog.Logger, config *rest.Config) { return } - Describe("theatre-envconsul", func() { + Describe("theatre-secrets", func() { BeforeEach(func() { podFixtureYAML = rawPodYAML }) It("Resolves env variables into the pod command", func() { expectResolvesEnvVariables(expectsFunc) }) - Context("As configured by the vault envconsul-injector webhook", func() { + Context("As configured by the vault secrets-injector webhook", func() { BeforeEach(func() { podFixtureYAML = annotatedPodYAML }) It("Resolves env variables into the pod command", func() { expectResolvesEnvVariables(expectsFunc) }) @@ -436,8 +436,8 @@ func (r *Runner) Run(logger kitlog.Logger, config *rest.Config) { }) }) - Describe("theatre-envconsul files", func() { - Context("As configured by the vault envconsul-injector webhook", func() { + Describe("theatre-secrets files", func() { + Context("As configured by the vault secrets-injector webhook", func() { Context("With a non-root user", func() { BeforeEach(func() { podFixtureYAML = annotatedNonRootPodYAMLFiles }) diff --git a/cmd/vault-manager/main.go b/cmd/vault-manager/main.go index 0945c69d..3fccbcde 100644 --- a/cmd/vault-manager/main.go +++ b/cmd/vault-manager/main.go @@ -25,7 +25,7 @@ var ( webhookName = app.Flag("webhook-name", "Name of webhook").Default("theatre-vault").String() theatreImage = app.Flag("theatre-image", "Set to the same image as current binary").Required().String() installPath = app.Flag("install-path", "Location to install theatre binaries").Default("/var/run/theatre").String() - namespaceLabel = app.Flag("namespace-label", "Namespace label that enables webhook to operate on").Default("theatre-envconsul-injector").String() + namespaceLabel = app.Flag("namespace-label", "Namespace label that enables webhook to operate on").Default("theatre-secrets-injector").String() vaultConfigMapName = app.Flag("vault-configmap-name", "Vault configMap name containing vault configuration").Default("vault-config").String() vaultConfigMapNamespace = app.Flag("vault-configmap-namespace", "Namespace of vault configMap").Default("vault-system").String() @@ -60,7 +60,7 @@ func main() { app.Fatalf("failed to create manager: %v", err) } - injectorOpts := vaultv1alpha1.EnvconsulInjectorOptions{ + injectorOpts := vaultv1alpha1.SecretsInjectorOptions{ Image: *theatreImage, InstallPath: *installPath, NamespaceLabel: *namespaceLabel, @@ -74,9 +74,9 @@ func main() { } mgr.GetWebhookServer().Register("/mutate-pods", &admission.Webhook{ - Handler: vaultv1alpha1.NewEnvconsulInjector( + Handler: vaultv1alpha1.NewSecretsInjector( mgr.GetClient(), - logger.WithName("webhooks").WithName("envconsul-injector"), + logger.WithName("webhooks").WithName("secrets-injector"), injectorOpts, ), }) diff --git a/config/acceptance/setup/resources/staging-service.yaml b/config/acceptance/setup/resources/staging-service.yaml index 395d2766..355e3b3b 100644 --- a/config/acceptance/setup/resources/staging-service.yaml +++ b/config/acceptance/setup/resources/staging-service.yaml @@ -7,7 +7,7 @@ kind: Namespace metadata: name: staging labels: - theatre-envconsul-injector: enabled + theatre-secrets-injector: enabled --- apiVersion: v1 kind: ServiceAccount diff --git a/config/base/kustomization.yaml b/config/base/kustomization.yaml index ff0528e9..ecd73b01 100644 --- a/config/base/kustomization.yaml +++ b/config/base/kustomization.yaml @@ -26,7 +26,7 @@ vars: # We want our mutating webhook to ensure it only ever configures pods to use # the same image as it is running itself. If we ensure this, we don't need to # worry about maintaining compatibility between versions of the webhook and - # theatre-envconsul, as both will use the same version and be deployed + # theatre-secrets, as both will use the same version and be deployed # atomically. - name: THEATRE_IMAGE objref: diff --git a/config/base/webhooks/vault.yaml b/config/base/webhooks/vault.yaml index 60d56c72..29707fee 100644 --- a/config/base/webhooks/vault.yaml +++ b/config/base/webhooks/vault.yaml @@ -14,10 +14,10 @@ webhooks: namespace: theatre-system path: /mutate-pods port: 443 - name: envconsul-injector.vault.crd.gocardless.com + name: secrets-injector.vault.crd.gocardless.com namespaceSelector: matchExpressions: - - key: theatre-envconsul-injector + - key: theatre-secrets-injector operator: In values: - enabled From b8159dbaa199b497d5e6479c5104e95289a6c415 Mon Sep 17 00:00:00 2001 From: Theo Barber-Bany Date: Thu, 25 Feb 2021 17:56:15 +0000 Subject: [PATCH 4/4] theatre-secrets: support old theatre-envconsul annotation Retain support for the old theatre-envconsul annotation to allow for a phased migration --- .../vault/v1alpha1/secretsinjector_webhook.go | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/apis/vault/v1alpha1/secretsinjector_webhook.go b/apis/vault/v1alpha1/secretsinjector_webhook.go index 7c688fd7..c9c8383a 100644 --- a/apis/vault/v1alpha1/secretsinjector_webhook.go +++ b/apis/vault/v1alpha1/secretsinjector_webhook.go @@ -20,6 +20,9 @@ import ( ) const SecretsInjectorFQDN = "secrets-injector.vault.crd.gocardless.com" +const EnvconsulInjectorFQDN = "envconsul-injector.vault.crd.gocardless.com" + +var FQDNArray = []string{SecretsInjectorFQDN, EnvconsulInjectorFQDN} type SecretsInjector struct { client client.Client @@ -117,7 +120,7 @@ func (i *SecretsInjector) Handle(ctx context.Context, req admission.Request) (re // path for all pod creation. We need to exit for pods that don't have the // annotation on them here so they can start uninterrupted in the event // code futher along returns an error. - if _, ok := pod.Annotations[fmt.Sprintf("%s/configs", SecretsInjectorFQDN)]; !ok { + if _, ok := getFQDNConfig(pod.Annotations, FQDNArray); !ok { logger.Info("skipping pod with no annotation", "event", "pod.skipped", "msg", "no annotation found") skipTotal.With(labels).Inc() return admission.Allowed("no annotation found") @@ -270,7 +273,7 @@ func (i podInjector) Inject(pod corev1.Pod) *corev1.Pod { // If no config file is specified, we inject theatre-secrets but don't load // configuration from files, relying solely on environment variables. func parseContainerConfigs(pod corev1.Pod) map[string]string { - configString, ok := pod.Annotations[fmt.Sprintf("%s/configs", SecretsInjectorFQDN)] + configString, ok := getFQDNConfig(pod.Annotations, FQDNArray) if !ok { return nil } @@ -357,3 +360,20 @@ func (i podInjector) configureContainer(reference corev1.Container, containerCon return *c } + +// getFQDNConfig takes a set of pod annotations (map[string]string), and an +// array of FQDNs to check for. If any are found under key 'FQDN/configs' then +// return the config and a true bool +// +// This is a temporary measure to support two FQDNs whilst we migrate away from +// the envconsul name +func getFQDNConfig(podAnnotations map[string]string, FQDNArray []string) (string, bool) { + for _, name := range FQDNArray { + data, ok := podAnnotations[fmt.Sprintf("%s/configs", name)] + if !ok { + continue + } + return data, ok + } + return "", false +}