From 5fbcd2106193f7731e1e268c3253b2d8e3488fc3 Mon Sep 17 00:00:00 2001 From: jannfis Date: Fri, 7 Jun 2024 19:47:29 +0000 Subject: [PATCH 1/2] fix: Make Git credentials work again Signed-off-by: jannfis --- cmd/ask_pass.go | 59 +++++++++++++++++++++++++++++++++ cmd/main.go | 17 +++++++++- cmd/run.go | 20 ++++++++++++ ext/git/creds.go | 9 ++++- ext/git/writer.go | 41 ++++++++++++++++++++--- pkg/argocd/gitcreds.go | 67 +++++++++++++++++++++++++++++++++++++- pkg/argocd/mocks/ArgoCD.go | 43 +++++++++++++++++++++--- pkg/argocd/update.go | 7 ++++ pkg/argocd/update_test.go | 3 +- 9 files changed, 252 insertions(+), 14 deletions(-) create mode 100644 cmd/ask_pass.go diff --git a/cmd/ask_pass.go b/cmd/ask_pass.go new file mode 100644 index 00000000..ca2e0061 --- /dev/null +++ b/cmd/ask_pass.go @@ -0,0 +1,59 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/argoproj/argo-cd/v2/util/git" + + "github.com/spf13/cobra" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + "github.com/argoproj/argo-cd/v2/reposerver/askpass" + "github.com/argoproj/argo-cd/v2/util/errors" + grpc_util "github.com/argoproj/argo-cd/v2/util/grpc" + "github.com/argoproj/argo-cd/v2/util/io" +) + +const ( + // cliName is the name of the CLI + cliName = "argocd-git-ask-pass" +) + +func NewAskPassCommand() *cobra.Command { + var command = cobra.Command{ + Use: cliName, + Short: "Argo CD git credential helper", + DisableAutoGenTag: true, + Run: func(c *cobra.Command, args []string) { + ctx := c.Context() + + if len(os.Args) != 2 { + errors.CheckError(fmt.Errorf("expected 1 argument, got %d", len(os.Args)-1)) + } + nonce := os.Getenv(git.ASKPASS_NONCE_ENV) + if nonce == "" { + errors.CheckError(fmt.Errorf("%s is not set", git.ASKPASS_NONCE_ENV)) + } + conn, err := grpc_util.BlockingDial(ctx, "unix", askpass.SocketPath, nil, grpc.WithTransportCredentials(insecure.NewCredentials())) + errors.CheckError(err) + defer io.Close(conn) + client := askpass.NewAskPassServiceClient(conn) + + creds, err := client.GetCredentials(ctx, &askpass.CredentialsRequest{Nonce: nonce}) + errors.CheckError(err) + switch { + case strings.HasPrefix(os.Args[1], "Username"): + fmt.Println(creds.Username) + case strings.HasPrefix(os.Args[1], "Password"): + fmt.Println(creds.Password) + default: + errors.CheckError(fmt.Errorf("unknown credential type '%s'", os.Args[1])) + } + }, + } + + return &command +} diff --git a/cmd/main.go b/cmd/main.go index 3a4019dd..de6f0c69 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -5,6 +5,7 @@ import ( "text/template" "time" + "github.com/argoproj-labs/argocd-image-updater/ext/git" "github.com/argoproj-labs/argocd-image-updater/pkg/argocd" "github.com/argoproj-labs/argocd-image-updater/pkg/kube" @@ -45,6 +46,7 @@ type ImageUpdaterConfig struct { GitCommitMail string GitCommitMessage *template.Template DisableKubeEvents bool + GitCreds git.CredsStore } // newRootCommand implements the root command of argocd-image-updater @@ -62,7 +64,20 @@ func newRootCommand() error { } func main() { - err := newRootCommand() + var err error + + // FIXME(jannfis): + // This is a workaround for supporting the Argo CD askpass implementation. + // When the environment ARGOCD_BINARY_NAME is set to argocd-git-ask-pass, + // we divert from the main path of execution to become a git credentials + // helper. + cmdName := os.Getenv("ARGOCD_BINARY_NAME") + if cmdName == "argocd-git-ask-pass" { + cmd := NewAskPassCommand() + err = cmd.Execute() + } else { + err = newRootCommand() + } if err != nil { os.Exit(1) } diff --git a/cmd/run.go b/cmd/run.go index 305863db..bcc0e1fe 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -19,6 +19,8 @@ import ( "github.com/argoproj-labs/argocd-image-updater/pkg/registry" "github.com/argoproj-labs/argocd-image-updater/pkg/version" + "github.com/argoproj/argo-cd/v2/reposerver/askpass" + "github.com/spf13/cobra" "golang.org/x/sync/semaphore" @@ -155,6 +157,23 @@ func newRunCommand() *cobra.Command { } } + // Start up the credentials store server + cs := askpass.NewServer() + csErrCh := make(chan error) + go func() { + log.Debugf("Starting askpass server") + csErrCh <- cs.Run(askpass.SocketPath) + }() + + // Wait for cred server to be started, just in case + err = <-csErrCh + if err != nil { + log.Errorf("Error running askpass server: %v", err) + return err + } + + cfg.GitCreds = cs + // This is our main loop. We leave it only when our health probe server // returns an error. for { @@ -309,6 +328,7 @@ func runImageUpdater(cfg *ImageUpdaterConfig, warmUp bool) (argocd.ImageUpdaterR GitCommitEmail: cfg.GitCommitMail, GitCommitMessage: cfg.GitCommitMessage, DisableKubeEvents: cfg.DisableKubeEvents, + GitCreds: cfg.GitCreds, } res := argocd.UpdateApplication(upconf, syncState) result.NumApplicationsProcessed += 1 diff --git a/ext/git/creds.go b/ext/git/creds.go index 18698449..d69e13ef 100644 --- a/ext/git/creds.go +++ b/ext/git/creds.go @@ -77,8 +77,15 @@ type Creds interface { } func getGitAskPassEnv(id string) []string { + // TODO(jannfis): This change should go upstream into Argo CD. Calling the + // full path to currently executing binary instead of relying on a binary + // named "argocd" in the PATH has only benefits. + cmd, err := os.Executable() + if err != nil { + return []string{} + } return []string{ - fmt.Sprintf("GIT_ASKPASS=%s", "argocd"), + fmt.Sprintf("GIT_ASKPASS=%s", cmd), fmt.Sprintf("%s=%s", ASKPASS_NONCE_ENV, id), "GIT_TERMINAL_PROMPT=0", "ARGOCD_BINARY_NAME=argocd-git-ask-pass", diff --git a/ext/git/writer.go b/ext/git/writer.go index 20a678f0..7b84ff92 100644 --- a/ext/git/writer.go +++ b/ext/git/writer.go @@ -2,6 +2,7 @@ package git import ( "fmt" + "os/exec" "strings" "github.com/argoproj-labs/argocd-image-updater/pkg/log" @@ -93,14 +94,20 @@ func (m *nativeGitClient) Add(path string) error { // SymRefToBranch retrieves the branch name a symbolic ref points to func (m *nativeGitClient) SymRefToBranch(symRef string) (string, error) { - output, err := m.runCmd("symbolic-ref", symRef) + output, err := m.runCredentialedCmdWithOutput("remote", "show", "origin") if err != nil { - return "", fmt.Errorf("could not resolve symbolic ref '%s': %v", symRef, err) + return "", fmt.Errorf("error running git: %v", err) } - if a := strings.SplitN(output, "refs/heads/", 2); len(a) == 2 { - return a[1], nil + for _, l := range strings.Split(output, "\n") { + l = strings.TrimSpace(l) + if strings.HasPrefix(l, "HEAD branch:") { + b := strings.SplitN(l, ":", 2) + if len(b) == 2 { + return strings.TrimSpace(b[1]), nil + } + } } - return "", fmt.Errorf("no symbolic ref named '%s' could be found", symRef) + return "", fmt.Errorf("no default branch found in remote") } // Config configures username and email address for the repository @@ -116,3 +123,27 @@ func (m *nativeGitClient) Config(username string, email string) error { return nil } + +// runCredentialedCmdWithOutput is a convenience function to run a git command +// with username/password credentials while supplying command output to the +// caller. +// nolint:unparam +func (m *nativeGitClient) runCredentialedCmdWithOutput(args ...string) (string, error) { + closer, environ, err := m.creds.Environ() + if err != nil { + return "", err + } + defer func() { _ = closer.Close() }() + + // If a basic auth header is explicitly set, tell Git to send it to the + // server to force use of basic auth instead of negotiating the auth scheme + for _, e := range environ { + if strings.HasPrefix(e, fmt.Sprintf("%s=", forceBasicAuthHeaderEnv)) { + args = append([]string{"--config-env", fmt.Sprintf("http.extraHeader=%s", forceBasicAuthHeaderEnv)}, args...) + } + } + + cmd := exec.Command("git", args...) + cmd.Env = append(cmd.Env, environ...) + return m.runCmdOutput(cmd, runOpts{}) +} diff --git a/pkg/argocd/gitcreds.go b/pkg/argocd/gitcreds.go index a9bc1302..90dc0568 100644 --- a/pkg/argocd/gitcreds.go +++ b/pkg/argocd/gitcreds.go @@ -3,15 +3,18 @@ package argocd import ( "context" "fmt" + "net/url" "strconv" "strings" "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" + "github.com/argoproj/argo-cd/v2/util/cert" "github.com/argoproj/argo-cd/v2/util/db" "github.com/argoproj/argo-cd/v2/util/settings" "github.com/argoproj-labs/argocd-image-updater/ext/git" "github.com/argoproj-labs/argocd-image-updater/pkg/kube" + "github.com/argoproj-labs/argocd-image-updater/pkg/log" ) // getGitCredsSource returns git credentials source that loads credentials from the secret or from Argo CD settings @@ -43,7 +46,69 @@ func getCredsFromArgoCD(wbc *WriteBackConfig, kubeClient *kube.KubernetesClient) if !repo.HasCredentials() { return nil, fmt.Errorf("credentials for '%s' are not configured in Argo CD settings", wbc.GitRepo) } - return repo.GetGitCreds(git.NoopCredsStore{}), nil + creds := GetGitCreds(repo, wbc.GitCreds) + return creds, nil +} + +// GetGitCreds returns the credentials from a repository configuration used to authenticate at a Git repository +// This is a slightly modified version of upstream's Repository.GetGitCreds method. We need it so it does not return the upstream type. +// TODO(jannfis): Can be removed once we have the change to the git client's getGitAskPassEnv upstream. +func GetGitCreds(repo *v1alpha1.Repository, store git.CredsStore) git.Creds { + if repo == nil { + return git.NopCreds{} + } + if repo.Password != "" { + return git.NewHTTPSCreds(repo.Username, repo.Password, repo.TLSClientCertData, repo.TLSClientCertKey, repo.IsInsecure(), repo.Proxy, store, repo.ForceHttpBasicAuth) + } + if repo.SSHPrivateKey != "" { + return git.NewSSHCreds(repo.SSHPrivateKey, getCAPath(repo.Repo), repo.IsInsecure(), store, repo.Proxy) + } + if repo.GithubAppPrivateKey != "" && repo.GithubAppId != 0 && repo.GithubAppInstallationId != 0 { + return git.NewGitHubAppCreds(repo.GithubAppId, repo.GithubAppInstallationId, repo.GithubAppPrivateKey, repo.GitHubAppEnterpriseBaseURL, repo.Repo, repo.TLSClientCertData, repo.TLSClientCertKey, repo.IsInsecure(), repo.Proxy, store) + } + if repo.GCPServiceAccountKey != "" { + return git.NewGoogleCloudCreds(repo.GCPServiceAccountKey, store) + } + return git.NopCreds{} +} + +// Taken from upstream Argo CD. +// TODO(jannfis): Can be removed once we have the change to the git client's getGitAskPassEnv upstream. +func getCAPath(repoURL string) string { + // For git ssh protocol url without ssh://, url.Parse() will fail to parse. + // However, no warn log is output since ssh scheme url is a possible format. + if ok, _ := git.IsSSHURL(repoURL); ok { + return "" + } + + hostname := "" + // url.Parse() will happily parse most things thrown at it. When the URL + // is either https or oci, we use the parsed hostname to retrieve the cert, + // otherwise we'll use the parsed path (OCI repos are often specified as + // hostname, without protocol). + parsedURL, err := url.Parse(repoURL) + if err != nil { + log.Warnf("Could not parse repo URL '%s': %v", repoURL, err) + return "" + } + if parsedURL.Scheme == "https" || parsedURL.Scheme == "oci" { + hostname = parsedURL.Host + } else if parsedURL.Scheme == "" { + hostname = parsedURL.Path + } + + if hostname == "" { + log.Warnf("Could not get hostname for repository '%s'", repoURL) + return "" + } + + caPath, err := cert.GetCertBundlePathForRepository(hostname) + if err != nil { + log.Warnf("Could not get cert bundle path for repository '%s': %v", repoURL, err) + return "" + } + + return caPath } // getCredsFromSecret loads repository credentials from secret diff --git a/pkg/argocd/mocks/ArgoCD.go b/pkg/argocd/mocks/ArgoCD.go index 4d37cfc0..f4b81301 100644 --- a/pkg/argocd/mocks/ArgoCD.go +++ b/pkg/argocd/mocks/ArgoCD.go @@ -1,4 +1,4 @@ -// Code generated by mockery v1.1.2. DO NOT EDIT. +// Code generated by mockery v2.43.0. DO NOT EDIT. package mocks @@ -21,7 +21,15 @@ type ArgoCD struct { func (_m *ArgoCD) GetApplication(ctx context.Context, appName string) (*v1alpha1.Application, error) { ret := _m.Called(ctx, appName) + if len(ret) == 0 { + panic("no return value specified for GetApplication") + } + var r0 *v1alpha1.Application + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (*v1alpha1.Application, error)); ok { + return rf(ctx, appName) + } if rf, ok := ret.Get(0).(func(context.Context, string) *v1alpha1.Application); ok { r0 = rf(ctx, appName) } else { @@ -30,7 +38,6 @@ func (_m *ArgoCD) GetApplication(ctx context.Context, appName string) (*v1alpha1 } } - var r1 error if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { r1 = rf(ctx, appName) } else { @@ -44,7 +51,15 @@ func (_m *ArgoCD) GetApplication(ctx context.Context, appName string) (*v1alpha1 func (_m *ArgoCD) ListApplications() ([]v1alpha1.Application, error) { ret := _m.Called() + if len(ret) == 0 { + panic("no return value specified for ListApplications") + } + var r0 []v1alpha1.Application + var r1 error + if rf, ok := ret.Get(0).(func() ([]v1alpha1.Application, error)); ok { + return rf() + } if rf, ok := ret.Get(0).(func() []v1alpha1.Application); ok { r0 = rf() } else { @@ -53,7 +68,6 @@ func (_m *ArgoCD) ListApplications() ([]v1alpha1.Application, error) { } } - var r1 error if rf, ok := ret.Get(1).(func() error); ok { r1 = rf() } else { @@ -67,7 +81,15 @@ func (_m *ArgoCD) ListApplications() ([]v1alpha1.Application, error) { func (_m *ArgoCD) UpdateSpec(ctx context.Context, spec *application.ApplicationUpdateSpecRequest) (*v1alpha1.ApplicationSpec, error) { ret := _m.Called(ctx, spec) + if len(ret) == 0 { + panic("no return value specified for UpdateSpec") + } + var r0 *v1alpha1.ApplicationSpec + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *application.ApplicationUpdateSpecRequest) (*v1alpha1.ApplicationSpec, error)); ok { + return rf(ctx, spec) + } if rf, ok := ret.Get(0).(func(context.Context, *application.ApplicationUpdateSpecRequest) *v1alpha1.ApplicationSpec); ok { r0 = rf(ctx, spec) } else { @@ -76,7 +98,6 @@ func (_m *ArgoCD) UpdateSpec(ctx context.Context, spec *application.ApplicationU } } - var r1 error if rf, ok := ret.Get(1).(func(context.Context, *application.ApplicationUpdateSpecRequest) error); ok { r1 = rf(ctx, spec) } else { @@ -85,3 +106,17 @@ func (_m *ArgoCD) UpdateSpec(ctx context.Context, spec *application.ApplicationU return r0, r1 } + +// NewArgoCD creates a new instance of ArgoCD. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewArgoCD(t interface { + mock.TestingT + Cleanup(func()) +}) *ArgoCD { + mock := &ArgoCD{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/argocd/update.go b/pkg/argocd/update.go index 0f1cab28..86a97f57 100644 --- a/pkg/argocd/update.go +++ b/pkg/argocd/update.go @@ -46,6 +46,7 @@ type UpdateConfiguration struct { GitCommitMessage *template.Template DisableKubeEvents bool IgnorePlatforms bool + GitCreds git.CredsStore } type GitCredsSource func(app *v1alpha1.Application) (git.Creds, error) @@ -72,6 +73,7 @@ type WriteBackConfig struct { KustomizeBase string Target string GitRepo string + GitCreds git.CredsStore } // The following are helper structs to only marshal the fields we require @@ -312,6 +314,11 @@ func UpdateApplication(updateConf *UpdateConfiguration, state *SyncIterationStat if err != nil { return result } + if updateConf.GitCreds == nil { + wbc.GitCreds = git.NoopCredsStore{} + } else { + wbc.GitCreds = updateConf.GitCreds + } if wbc.Method == WriteBackGit { if updateConf.GitCommitUser != "" { diff --git a/pkg/argocd/update_test.go b/pkg/argocd/update_test.go index fd427b40..6429a70b 100644 --- a/pkg/argocd/update_test.go +++ b/pkg/argocd/update_test.go @@ -22,7 +22,6 @@ import ( "github.com/argoproj-labs/argocd-image-updater/test/fixture" "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" - argogit "github.com/argoproj/argo-cd/v2/util/git" "github.com/distribution/distribution/v3/manifest/schema1" //nolint:staticcheck "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -2222,7 +2221,7 @@ func Test_GetGitCreds(t *testing.T) { require.NoError(t, err) require.NotNil(t, creds) // Must have HTTPS creds - _, ok := creds.(argogit.HTTPSCreds) + _, ok := creds.(git.HTTPSCreds) require.True(t, ok) }) From d23fedfddee7ebd612f096db1242e4a9aa1fa123 Mon Sep 17 00:00:00 2001 From: jannfis Date: Fri, 7 Jun 2024 19:56:34 +0000 Subject: [PATCH 2/2] Update Signed-off-by: jannfis --- cmd/ask_pass.go | 3 +++ go.mod | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/cmd/ask_pass.go b/cmd/ask_pass.go index ca2e0061..2a5f9d4c 100644 --- a/cmd/ask_pass.go +++ b/cmd/ask_pass.go @@ -1,5 +1,8 @@ package main +// Taken from https://github.com/argoproj/argo-cd/blob/ae19965ff75fd6ba199914b258d751d6b7ea876c/cmd/argocd-git-ask-pass/commands/argocd_git_ask_pass.go +// All courtesy to the original authors. + import ( "fmt" "os" diff --git a/go.mod b/go.mod index e7d6b0ad..2766958c 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( golang.org/x/exp v0.0.0-20230728194245-b0cb94b80691 golang.org/x/oauth2 v0.11.0 golang.org/x/sync v0.3.0 + google.golang.org/grpc v1.59.0 gopkg.in/yaml.v2 v2.4.0 k8s.io/api v0.26.11 k8s.io/apimachinery v0.26.11 @@ -165,7 +166,6 @@ require ( golang.org/x/tools v0.13.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/genproto v0.0.0-20230822172742-b8732ec3820d // indirect - google.golang.org/grpc v1.59.0 // indirect google.golang.org/protobuf v1.33.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect