diff --git a/cmd/keel/main.go b/cmd/keel/main.go index ab951b95..529e5581 100644 --- a/cmd/keel/main.go +++ b/cmd/keel/main.go @@ -1,13 +1,12 @@ package main import ( + "context" "os" "os/signal" "path/filepath" "time" - "context" - kingpin "github.com/alecthomas/kingpin/v2" "github.com/prometheus/client_golang/prometheus" @@ -50,6 +49,7 @@ import ( // credentials helpers _ "github.com/keel-hq/keel/extension/credentialshelper/aws" + _ "github.com/keel-hq/keel/extension/credentialshelper/dockerhelper" _ "github.com/keel-hq/keel/extension/credentialshelper/gcr" secretsCredentialsHelper "github.com/keel-hq/keel/extension/credentialshelper/secrets" @@ -361,7 +361,6 @@ type TriggerOpts struct { // should go through all providers (or not if there is a reason) and submit events) // func setupTriggers(ctx context.Context, providers provider.Providers, approvalsManager approvals.Manager, grc *k8s.GenericResourceCache, k8sClient kubernetes.Implementer) (teardown func()) { func setupTriggers(ctx context.Context, opts *TriggerOpts) (teardown func()) { - authenticator := auth.New(&auth.Opts{ Username: os.Getenv(constants.EnvBasicAuthUser), Password: os.Getenv(constants.EnvBasicAuthPassword), diff --git a/extension/credentialshelper/dockerhelper/dockerhelper.go b/extension/credentialshelper/dockerhelper/dockerhelper.go new file mode 100644 index 00000000..709aea27 --- /dev/null +++ b/extension/credentialshelper/dockerhelper/dockerhelper.go @@ -0,0 +1,120 @@ +package dockerhelper + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "time" + + "github.com/jellydator/ttlcache/v3" + "github.com/keel-hq/keel/extension/credentialshelper" + "github.com/keel-hq/keel/types" + + log "github.com/sirupsen/logrus" +) + +const defaultCacheTTL = 30 * time.Minute + +var helperBinary = os.Getenv("DOCKER_CREDENTIALS_HELPER") + +type DockerSecret struct { + Username string `json:"Username"` + Secret string `json:"Secret"` +} + +type CredentialsHelper struct { + executor executor + cache *ttlcache.Cache[string, *types.Credentials] + path string + enabled bool +} + +type executor interface { + Run(string, string) ([]byte, error) +} + +type executorImpl struct{} + +func init() { + credentialshelper.RegisterCredentialsHelper("dockerhelper", New()) +} + +func (e *executorImpl) Run(path, input string) ([]byte, error) { + cmd := exec.Command(path, "get") + defer func() { + if cmd.Process != nil { + cmd.Process.Kill() + } + }() + stdin, err := cmd.StdinPipe() + if err != nil { + log.WithError(err).Error("credentialshelper.dockerhelper: failed to get stdin pipe") + return nil, err + } + stdin.Write([]byte(input)) + stdin.Close() + secrets, err := cmd.Output() + if err != nil { + log.WithError(err).Error("credentialshelper.dockerhelper: failed to get credentials") + return nil, err + } + return secrets, nil +} + +// New creates a new docker credentials helper. +func New() *CredentialsHelper { + ch := &CredentialsHelper{} + if helperBinary == "" { + return ch + } + // Look up the binary at DOCKER_CREDENTIALS_HELPER. + path, err := exec.LookPath(helperBinary) + if err != nil { + log.WithError(err).Error("credentialshelper.dockerhelper: failed to find DOCKER_CREDENTIALS_HELPER") + return ch + } + ch.path = path + ch.executor = &executorImpl{} + ch.cache = ttlcache.New( + ttlcache.WithTTL[string, *types.Credentials](defaultCacheTTL), + ttlcache.WithDisableTouchOnHit[string, *types.Credentials](), + ) + ch.enabled = true + return ch +} + +// IsEnabled returns a bool whether this credentials helper has been initialized. +func (h *CredentialsHelper) IsEnabled() bool { + return h.enabled +} + +// GetCredentials - finds credentials. +func (h *CredentialsHelper) GetCredentials(image *types.TrackedImage) (*types.Credentials, error) { + if !h.enabled { + return nil, fmt.Errorf("not initialized") + } + registry := image.Image.Registry() + creds := h.cache.Get(registry) + if creds != nil { + log.WithField("registry", registry).Debug("credentialshelper.dockerhelper: cache hit") + return creds.Value(), nil + } + // Run the credentials helper at h.path and pass the registry via stdin. + secrets, err := h.executor.Run(h.path, registry) + if err != nil { + return nil, err + } + dockerSecret := DockerSecret{} + if err = json.Unmarshal(secrets, &dockerSecret); err != nil { + log.WithError(err).Error("credentialshelper.dockerhelper: failed to unmarshal credentials") + return nil, err + } + crds := &types.Credentials{ + Username: dockerSecret.Username, + Password: dockerSecret.Secret, + } + log.WithField("registry", registry).Debug("credentialshelper.dockerhelper: cache miss") + h.cache.Set(registry, crds, 0) + return crds, nil +} diff --git a/extension/credentialshelper/dockerhelper/dockerhelper_test.go b/extension/credentialshelper/dockerhelper/dockerhelper_test.go new file mode 100644 index 00000000..6f35ef21 --- /dev/null +++ b/extension/credentialshelper/dockerhelper/dockerhelper_test.go @@ -0,0 +1,155 @@ +package dockerhelper + +import ( + "fmt" + "testing" + + "github.com/jellydator/ttlcache/v3" + "github.com/keel-hq/keel/types" + "github.com/keel-hq/keel/util/image" +) + +type testExecutor struct { + err error + output []byte +} + +func (e *testExecutor) Run(path, input string) ([]byte, error) { + return e.output, e.err +} + +func TestGetCredentials(t *testing.T) { + image, err := image.Parse("docker.io/keel-hq/keel:latest") + if err != nil { + t.Errorf("unexpected error: %v", err) + } + testCases := []struct { + name string + input *types.TrackedImage + executorErr error + executorOutput []byte + expected *types.Credentials + }{ + { + name: "error", + input: &types.TrackedImage{ + Image: image, + }, + executorErr: fmt.Errorf("error"), + expected: nil, + }, + { + name: "success", + input: &types.TrackedImage{ + Image: image, + }, + executorOutput: []byte(`{"Username":"testUser","Secret":"testPW"}`), + expected: &types.Credentials{ + Username: "testUser", + Password: "testPW", + }, + }, + { + name: "invalid json", + input: &types.TrackedImage{ + Image: image, + }, + executorOutput: []byte(`invalid json`), + expected: nil, + executorErr: fmt.Errorf("invalid json"), + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + helper := &CredentialsHelper{ + executor: &testExecutor{ + err: tc.executorErr, + output: tc.executorOutput, + }, + cache: ttlcache.New(ttlcache.WithTTL[string, *types.Credentials](defaultCacheTTL)), + enabled: true, + } + creds, err := helper.GetCredentials(tc.input) + if err != nil { + if tc.executorErr == nil { + t.Errorf("unexpected error: %v", err) + } + } else { + if creds.Username != tc.expected.Username { + t.Errorf("expected username %s, got %s", tc.expected.Username, creds.Username) + } + if creds.Password != tc.expected.Password { + t.Errorf("expected password %s, got %s", tc.expected.Password, creds.Password) + } + } + }) + } +} + +func TestGetCredentialsFromCache(t *testing.T) { + image, err := image.Parse("docker.io/keel-hq/keel:latest") + if err != nil { + t.Errorf("unexpected error: %v", err) + } + helper := &CredentialsHelper{ + executor: &testExecutor{ + err: nil, + output: []byte(`{"Username":"testUser","Secret":"testPW"}`), + }, + cache: ttlcache.New(ttlcache.WithTTL[string, *types.Credentials](defaultCacheTTL)), + enabled: true, + } + creds, err := helper.GetCredentials(&types.TrackedImage{ + Image: image, + }) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if creds.Password != "testPW" || creds.Username != "testUser" { + t.Errorf("unexpected credentials: %v", creds) + } + helper.executor.(*testExecutor).err = fmt.Errorf("error") + creds, err = helper.GetCredentials(&types.TrackedImage{ + Image: image, + }) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if creds.Password != "testPW" || creds.Username != "testUser" { + t.Errorf("unexpected credentials: %v", creds) + } +} + +func TestGetCredentialsExpiredCache(t *testing.T) { + image, err := image.Parse("docker.io/keel-hq/keel:latest") + if err != nil { + t.Errorf("unexpected error: %v", err) + } + helper := &CredentialsHelper{ + executor: &testExecutor{ + err: nil, + output: []byte(`{"Username":"testUser","Secret":"testPW"}`), + }, + cache: ttlcache.New(ttlcache.WithTTL[string, *types.Credentials](defaultCacheTTL)), + enabled: true, + } + creds, err := helper.GetCredentials(&types.TrackedImage{ + Image: image, + }) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if creds.Password != "testPW" || creds.Username != "testUser" { + t.Errorf("unexpected credentials: %v", creds) + } + helper.cache.DeleteAll() + creds, err = helper.GetCredentials(&types.TrackedImage{ + Image: image, + }) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if creds.Password != "testPW" || creds.Username != "testUser" { + t.Errorf("unexpected credentials: %v", creds) + } +} diff --git a/go.mod b/go.mod index b209ba1c..1a36a379 100644 --- a/go.mod +++ b/go.mod @@ -149,6 +149,7 @@ require ( github.com/huandu/xstrings v1.5.0 // indirect github.com/imdario/mergo v0.3.16 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jellydator/ttlcache/v3 v3.3.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/josharian/intern v1.0.0 // indirect diff --git a/go.sum b/go.sum index 8d4b2d3e..165ae742 100644 --- a/go.sum +++ b/go.sum @@ -294,6 +294,8 @@ github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jellydator/ttlcache/v3 v3.3.0 h1:BdoC9cE81qXfrxeb9eoJi9dWrdhSuwXMAnHTbnBm4Wc= +github.com/jellydator/ttlcache/v3 v3.3.0/go.mod h1:bj2/e0l4jRnQdrnSTaGTsh4GSXvMjQcy41i7th0GVGw= github.com/jinzhu/gorm v1.9.16 h1:+IyIjPEABKRpsu/F8OvDPy9fyQlgsg2luMV2ZIH5i5o= github.com/jinzhu/gorm v1.9.16/go.mod h1:G3LB3wezTOWM2ITLzPxEXgSkOXAntiLHS7UdBefADcs= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=