diff --git a/applicationset/generators/pull_request.go b/applicationset/generators/pull_request.go index 0eb74e4f85..019c0903d8 100644 --- a/applicationset/generators/pull_request.go +++ b/applicationset/generators/pull_request.go @@ -243,9 +243,9 @@ func (g *PullRequestGenerator) github(ctx context.Context, cfg *argoprojiov1alph } if g.enableGitHubAPIMetrics { - return pullrequest.NewGithubAppService(*auth, cfg.API, cfg.Owner, cfg.Repo, cfg.Labels, httpClient) + return pullrequest.NewGithubAppService(ctx, *auth, cfg.API, cfg.Owner, cfg.Repo, cfg.Labels, httpClient) } - return pullrequest.NewGithubAppService(*auth, cfg.API, cfg.Owner, cfg.Repo, cfg.Labels) + return pullrequest.NewGithubAppService(ctx, *auth, cfg.API, cfg.Owner, cfg.Repo, cfg.Labels) } // always default to token, even if not set (public access) diff --git a/applicationset/generators/scm_provider.go b/applicationset/generators/scm_provider.go index b2f3e20cd3..9e7158ef8e 100644 --- a/applicationset/generators/scm_provider.go +++ b/applicationset/generators/scm_provider.go @@ -296,9 +296,9 @@ func (g *SCMProviderGenerator) githubProvider(ctx context.Context, github *argop } if g.enableGitHubAPIMetrics { - return scm_provider.NewGithubAppProviderFor(*auth, github.Organization, github.API, github.AllBranches, httpClient) + return scm_provider.NewGithubAppProviderFor(ctx, *auth, github.Organization, github.API, github.AllBranches, httpClient) } - return scm_provider.NewGithubAppProviderFor(*auth, github.Organization, github.API, github.AllBranches) + return scm_provider.NewGithubAppProviderFor(ctx, *auth, github.Organization, github.API, github.AllBranches) } token, err := utils.GetSecretRef(ctx, g.client, github.TokenRef, applicationSetInfo.Namespace, g.tokenRefStrictMode) diff --git a/applicationset/services/internal/github_app/client.go b/applicationset/services/internal/github_app/client.go index 5ae5cf969b..98f621157e 100644 --- a/applicationset/services/internal/github_app/client.go +++ b/applicationset/services/internal/github_app/client.go @@ -1,6 +1,8 @@ package github_app import ( + "context" + "errors" "fmt" "net/http" @@ -8,40 +10,65 @@ import ( "github.com/google/go-github/v69/github" "github.com/argoproj/argo-cd/v3/applicationset/services/github_app_auth" - appsetutils "github.com/argoproj/argo-cd/v3/applicationset/utils" + "github.com/argoproj/argo-cd/v3/util/git" ) -func getOptionalHTTPClientAndTransport(optionalHTTPClient ...*http.Client) (*http.Client, http.RoundTripper) { - httpClient := appsetutils.GetOptionalHTTPClient(optionalHTTPClient...) - if len(optionalHTTPClient) > 0 && optionalHTTPClient[0] != nil && optionalHTTPClient[0].Transport != nil { - // will either use the provided custom httpClient and it's transport - return httpClient, optionalHTTPClient[0].Transport +// getInstallationClient creates a new GitHub client with the specified installation ID. +// It also returns a ghinstallation.Transport, which can be used for git requests. +func getInstallationClient(g github_app_auth.Authentication, url string, httpClient ...*http.Client) (*github.Client, error) { + if g.InstallationId <= 0 { + return nil, errors.New("installation ID is required for github") } - // or the default httpClient and transport - return httpClient, http.DefaultTransport -} -// Client builds a github client for the given app authentication. -func Client(g github_app_auth.Authentication, url string, optionalHTTPClient ...*http.Client) (*github.Client, error) { - httpClient, transport := getOptionalHTTPClientAndTransport(optionalHTTPClient...) + // Use provided HTTP client's transport or default + var transport http.RoundTripper + if len(httpClient) > 0 && httpClient[0] != nil && httpClient[0].Transport != nil { + transport = httpClient[0].Transport + } else { + transport = http.DefaultTransport + } - rt, err := ghinstallation.New(transport, g.Id, g.InstallationId, []byte(g.PrivateKey)) + itr, err := ghinstallation.New(transport, g.Id, g.InstallationId, []byte(g.PrivateKey)) if err != nil { - return nil, fmt.Errorf("failed to create github app install: %w", err) + return nil, fmt.Errorf("failed to create GitHub installation transport: %w", err) } + if url == "" { url = g.EnterpriseBaseURL } + var client *github.Client - httpClient.Transport = rt if url == "" { - client = github.NewClient(httpClient) - } else { - rt.BaseURL = url - client, err = github.NewClient(httpClient).WithEnterpriseURLs(url, url) - if err != nil { - return nil, fmt.Errorf("failed to create github enterprise client: %w", err) - } + client = github.NewClient(&http.Client{Transport: itr}) + return client, nil + } + + itr.BaseURL = url + client, err = github.NewClient(&http.Client{Transport: itr}).WithEnterpriseURLs(url, url) + if err != nil { + return nil, fmt.Errorf("failed to create GitHub enterprise client: %w", err) } return client, nil } + +// Client builds a github client for the given app authentication. +func Client(ctx context.Context, g github_app_auth.Authentication, url, org string, optionalHTTPClient ...*http.Client) (*github.Client, error) { + if url == "" { + url = g.EnterpriseBaseURL + } + + // If an installation ID is already provided, use it directly. + if g.InstallationId != 0 { + return getInstallationClient(g, url, optionalHTTPClient...) + } + + // Auto-discover installation ID using shared utility + // Pass optional HTTP client for metrics tracking + installationId, err := git.DiscoverGitHubAppInstallationID(ctx, g.Id, g.PrivateKey, url, org, optionalHTTPClient...) + if err != nil { + return nil, err + } + + g.InstallationId = installationId + return getInstallationClient(g, url, optionalHTTPClient...) +} diff --git a/applicationset/services/pull_request/github_app.go b/applicationset/services/pull_request/github_app.go index 2cc7858d92..2819e7cb33 100644 --- a/applicationset/services/pull_request/github_app.go +++ b/applicationset/services/pull_request/github_app.go @@ -1,6 +1,7 @@ package pull_request import ( + "context" "net/http" "github.com/argoproj/argo-cd/v3/applicationset/services/github_app_auth" @@ -8,9 +9,9 @@ import ( appsetutils "github.com/argoproj/argo-cd/v3/applicationset/utils" ) -func NewGithubAppService(g github_app_auth.Authentication, url, owner, repo string, labels []string, optionalHTTPClient ...*http.Client) (PullRequestService, error) { +func NewGithubAppService(ctx context.Context, g github_app_auth.Authentication, url, owner, repo string, labels []string, optionalHTTPClient ...*http.Client) (PullRequestService, error) { httpClient := appsetutils.GetOptionalHTTPClient(optionalHTTPClient...) - client, err := github_app.Client(g, url, httpClient) + client, err := github_app.Client(ctx, g, url, owner, httpClient) if err != nil { return nil, err } diff --git a/applicationset/services/scm_provider/github_app.go b/applicationset/services/scm_provider/github_app.go index f11fcb0783..3863480e9d 100644 --- a/applicationset/services/scm_provider/github_app.go +++ b/applicationset/services/scm_provider/github_app.go @@ -1,6 +1,7 @@ package scm_provider import ( + "context" "net/http" "github.com/argoproj/argo-cd/v3/applicationset/services/github_app_auth" @@ -8,9 +9,9 @@ import ( appsetutils "github.com/argoproj/argo-cd/v3/applicationset/utils" ) -func NewGithubAppProviderFor(g github_app_auth.Authentication, organization string, url string, allBranches bool, optionalHTTPClient ...*http.Client) (*GithubProvider, error) { +func NewGithubAppProviderFor(ctx context.Context, g github_app_auth.Authentication, organization string, url string, allBranches bool, optionalHTTPClient ...*http.Client) (*GithubProvider, error) { httpClient := appsetutils.GetOptionalHTTPClient(optionalHTTPClient...) - client, err := github_app.Client(g, url, httpClient) + client, err := github_app.Client(ctx, g, url, organization, httpClient) if err != nil { return nil, err } diff --git a/cmd/argocd/commands/admin/repo.go b/cmd/argocd/commands/admin/repo.go index e02ae6fcd2..0f6d6dbb49 100644 --- a/cmd/argocd/commands/admin/repo.go +++ b/cmd/argocd/commands/admin/repo.go @@ -77,6 +77,15 @@ func NewGenRepoSpecCommand() *cobra.Command { # Add a private HTTP OCI repository named 'stable' argocd admin repo generate-spec oci://helm-oci-registry.cn-zhangjiakou.cr.aliyuncs.com --type oci --name stable --username test --password test --insecure-oci-force-http + + # Add a private Git repository on GitHub.com via GitHub App. github-app-installation-id is optional, if not provided, the installation id will be fetched from the GitHub API. + argocd admin repo generate-spec https://git.example.com/repos/repo --github-app-id 1 --github-app-installation-id 2 --github-app-private-key-path test.private-key.pem + + # Add a private Git repository on GitHub Enterprise via GitHub App. github-app-installation-id is optional, if not provided, the installation id will be fetched from the GitHub API. + argocd admin repo generate-spec https://ghe.example.com/repos/repo --github-app-id 1 --github-app-installation-id 2 --github-app-private-key-path test.private-key.pem --github-app-enterprise-base-url https://ghe.example.com/api/v3 + + # Add a private Git repository on Google Cloud Sources via GCP service account credentials + argocd admin repo generate-spec https://source.developers.google.com/p/my-google-cloud-project/r/my-repo --gcp-service-account-key-path service-account-key.json ` command := &cobra.Command{ diff --git a/cmd/argocd/commands/repo.go b/cmd/argocd/commands/repo.go index e3786cbb8e..8f4bcc1388 100644 --- a/cmd/argocd/commands/repo.go +++ b/cmd/argocd/commands/repo.go @@ -94,10 +94,10 @@ func NewRepoAddCommand(clientOpts *argocdclient.ClientOptions) *cobra.Command { # Add a private HTTP OCI repository named 'stable' argocd repo add oci://helm-oci-registry.cn-zhangjiakou.cr.aliyuncs.com --type oci --name stable --username test --password test --insecure-oci-force-http - # Add a private Git repository on GitHub.com via GitHub App + # Add a private Git repository on GitHub.com via GitHub App. github-app-installation-id is optional, if not provided, the installation id will be fetched from the GitHub API. argocd repo add https://git.example.com/repos/repo --github-app-id 1 --github-app-installation-id 2 --github-app-private-key-path test.private-key.pem - # Add a private Git repository on GitHub Enterprise via GitHub App + # Add a private Git repository on GitHub Enterprise via GitHub App. github-app-installation-id is optional, if not provided, the installation id will be fetched from the GitHub API. argocd repo add https://ghe.example.com/repos/repo --github-app-id 1 --github-app-installation-id 2 --github-app-private-key-path test.private-key.pem --github-app-enterprise-base-url https://ghe.example.com/api/v3 # Add a private Git repository on Google Cloud Sources via GCP service account credentials diff --git a/cmd/argocd/commands/repocreds.go b/cmd/argocd/commands/repocreds.go index 778b5be008..ed6892f857 100644 --- a/cmd/argocd/commands/repocreds.go +++ b/cmd/argocd/commands/repocreds.go @@ -72,10 +72,10 @@ func NewRepoCredsAddCommand(clientOpts *argocdclient.ClientOptions) *cobra.Comma # Add credentials with SSH private key authentication to use for all repositories under ssh://git@git.example.com/repos argocd repocreds add ssh://git@git.example.com/repos/ --ssh-private-key-path ~/.ssh/id_rsa - # Add credentials with GitHub App authentication to use for all repositories under https://github.com/repos + # Add credentials with GitHub App authentication to use for all repositories under https://github.com/repos. github-app-installation-id is optional, if not provided, the installation id will be fetched from the GitHub API. argocd repocreds add https://github.com/repos/ --github-app-id 1 --github-app-installation-id 2 --github-app-private-key-path test.private-key.pem - # Add credentials with GitHub App authentication to use for all repositories under https://ghe.example.com/repos + # Add credentials with GitHub App authentication to use for all repositories under https://ghe.example.com/repos. github-app-installation-id is optional, if not provided, the installation id will be fetched from the GitHub API. argocd repocreds add https://ghe.example.com/repos/ --github-app-id 1 --github-app-installation-id 2 --github-app-private-key-path test.private-key.pem --github-app-enterprise-base-url https://ghe.example.com/api/v3 # Add credentials with helm oci registry so that these oci registry urls do not need to be added as repos individually. @@ -191,7 +191,7 @@ func NewRepoCredsAddCommand(clientOpts *argocdclient.ClientOptions) *cobra.Comma command.Flags().StringVar(&tlsClientCertPath, "tls-client-cert-path", "", "path to the TLS client cert (must be PEM format)") command.Flags().StringVar(&tlsClientCertKeyPath, "tls-client-cert-key-path", "", "path to the TLS client cert's key (must be PEM format)") command.Flags().Int64Var(&repo.GithubAppId, "github-app-id", 0, "id of the GitHub Application") - command.Flags().Int64Var(&repo.GithubAppInstallationId, "github-app-installation-id", 0, "installation id of the GitHub Application") + command.Flags().Int64Var(&repo.GithubAppInstallationId, "github-app-installation-id", 0, "installation id of the GitHub Application (optional, will be auto-discovered if not provided)") command.Flags().StringVar(&githubAppPrivateKeyPath, "github-app-private-key-path", "", "private key of the GitHub Application") command.Flags().StringVar(&repo.GitHubAppEnterpriseBaseURL, "github-app-enterprise-base-url", "", "base url to use when using GitHub Enterprise (e.g. https://ghe.example.com/api/v3") command.Flags().BoolVar(&upsert, "upsert", false, "Override an existing repository with the same name even if the spec differs") diff --git a/cmd/util/repo.go b/cmd/util/repo.go index fdf87d91e3..ee105758f4 100644 --- a/cmd/util/repo.go +++ b/cmd/util/repo.go @@ -45,7 +45,7 @@ func AddRepoFlags(command *cobra.Command, opts *RepoOptions) { command.Flags().BoolVar(&opts.EnableLfs, "enable-lfs", false, "enable git-lfs (Large File Support) on this repository") command.Flags().BoolVar(&opts.EnableOci, "enable-oci", false, "enable helm-oci (Helm OCI-Based Repository) (only valid for helm type repositories)") command.Flags().Int64Var(&opts.GithubAppId, "github-app-id", 0, "id of the GitHub Application") - command.Flags().Int64Var(&opts.GithubAppInstallationId, "github-app-installation-id", 0, "installation id of the GitHub Application") + command.Flags().Int64Var(&opts.GithubAppInstallationId, "github-app-installation-id", 0, "installation id of the GitHub Application (optional, will be auto-discovered if not provided)") command.Flags().StringVar(&opts.GithubAppPrivateKeyPath, "github-app-private-key-path", "", "private key of the GitHub Application") command.Flags().StringVar(&opts.GitHubAppEnterpriseBaseURL, "github-app-enterprise-base-url", "", "base url to use when using GitHub Enterprise (e.g. https://ghe.example.com/api/v3") command.Flags().StringVar(&opts.Proxy, "proxy", "", "use proxy to access repository") diff --git a/commitserver/commit/credentialtypehelper.go b/commitserver/commit/credentialtypehelper.go index 2eea0a885b..ad2bea7e0c 100644 --- a/commitserver/commit/credentialtypehelper.go +++ b/commitserver/commit/credentialtypehelper.go @@ -13,7 +13,7 @@ func getCredentialType(repo *v1alpha1.Repository) string { if repo.SSHPrivateKey != "" { return "ssh" } - if repo.GithubAppPrivateKey != "" && repo.GithubAppId != 0 && repo.GithubAppInstallationId != 0 { + if repo.GithubAppPrivateKey != "" && repo.GithubAppId != 0 { // Promoter MVP: remove github-app-installation-id check since it is no longer a required field return "github-app" } if repo.GCPServiceAccountKey != "" { diff --git a/docs/user-guide/commands/argocd_admin_repo_generate-spec.md b/docs/user-guide/commands/argocd_admin_repo_generate-spec.md index 925ba22621..53e0722d41 100644 --- a/docs/user-guide/commands/argocd_admin_repo_generate-spec.md +++ b/docs/user-guide/commands/argocd_admin_repo_generate-spec.md @@ -45,6 +45,15 @@ argocd admin repo generate-spec REPOURL [flags] # Add a private HTTP OCI repository named 'stable' argocd admin repo generate-spec oci://helm-oci-registry.cn-zhangjiakou.cr.aliyuncs.com --type oci --name stable --username test --password test --insecure-oci-force-http + # Add a private Git repository on GitHub.com via GitHub App. github-app-installation-id is optional, if not provided, the installation id will be fetched from the GitHub API. + argocd admin repo generate-spec https://git.example.com/repos/repo --github-app-id 1 --github-app-installation-id 2 --github-app-private-key-path test.private-key.pem + + # Add a private Git repository on GitHub Enterprise via GitHub App. github-app-installation-id is optional, if not provided, the installation id will be fetched from the GitHub API. + argocd admin repo generate-spec https://ghe.example.com/repos/repo --github-app-id 1 --github-app-installation-id 2 --github-app-private-key-path test.private-key.pem --github-app-enterprise-base-url https://ghe.example.com/api/v3 + + # Add a private Git repository on Google Cloud Sources via GCP service account credentials + argocd admin repo generate-spec https://source.developers.google.com/p/my-google-cloud-project/r/my-repo --gcp-service-account-key-path service-account-key.json + ``` ### Options @@ -58,7 +67,7 @@ argocd admin repo generate-spec REPOURL [flags] --gcp-service-account-key-path string service account key for the Google Cloud Platform --github-app-enterprise-base-url string base url to use when using GitHub Enterprise (e.g. https://ghe.example.com/api/v3 --github-app-id int id of the GitHub Application - --github-app-installation-id int installation id of the GitHub Application + --github-app-installation-id int installation id of the GitHub Application (optional, will be auto-discovered if not provided) --github-app-private-key-path string private key of the GitHub Application -h, --help help for generate-spec --insecure-ignore-host-key disables SSH strict host key checking (deprecated, use --insecure-skip-server-verification instead) diff --git a/docs/user-guide/commands/argocd_repo_add.md b/docs/user-guide/commands/argocd_repo_add.md index 87a51c81f3..3061a3105c 100644 --- a/docs/user-guide/commands/argocd_repo_add.md +++ b/docs/user-guide/commands/argocd_repo_add.md @@ -47,10 +47,10 @@ argocd repo add REPOURL [flags] # Add a private HTTP OCI repository named 'stable' argocd repo add oci://helm-oci-registry.cn-zhangjiakou.cr.aliyuncs.com --type oci --name stable --username test --password test --insecure-oci-force-http - # Add a private Git repository on GitHub.com via GitHub App + # Add a private Git repository on GitHub.com via GitHub App. github-app-installation-id is optional, if not provided, the installation id will be fetched from the GitHub API. argocd repo add https://git.example.com/repos/repo --github-app-id 1 --github-app-installation-id 2 --github-app-private-key-path test.private-key.pem - # Add a private Git repository on GitHub Enterprise via GitHub App + # Add a private Git repository on GitHub Enterprise via GitHub App. github-app-installation-id is optional, if not provided, the installation id will be fetched from the GitHub API. argocd repo add https://ghe.example.com/repos/repo --github-app-id 1 --github-app-installation-id 2 --github-app-private-key-path test.private-key.pem --github-app-enterprise-base-url https://ghe.example.com/api/v3 # Add a private Git repository on Google Cloud Sources via GCP service account credentials @@ -69,7 +69,7 @@ argocd repo add REPOURL [flags] --gcp-service-account-key-path string service account key for the Google Cloud Platform --github-app-enterprise-base-url string base url to use when using GitHub Enterprise (e.g. https://ghe.example.com/api/v3 --github-app-id int id of the GitHub Application - --github-app-installation-id int installation id of the GitHub Application + --github-app-installation-id int installation id of the GitHub Application (optional, will be auto-discovered if not provided) --github-app-private-key-path string private key of the GitHub Application -h, --help help for add --insecure-ignore-host-key disables SSH strict host key checking (deprecated, use --insecure-skip-server-verification instead) diff --git a/docs/user-guide/commands/argocd_repocreds_add.md b/docs/user-guide/commands/argocd_repocreds_add.md index 5fcc0fa614..0b67135d99 100644 --- a/docs/user-guide/commands/argocd_repocreds_add.md +++ b/docs/user-guide/commands/argocd_repocreds_add.md @@ -20,10 +20,10 @@ argocd repocreds add REPOURL [flags] # Add credentials with SSH private key authentication to use for all repositories under ssh://git@git.example.com/repos argocd repocreds add ssh://git@git.example.com/repos/ --ssh-private-key-path ~/.ssh/id_rsa - # Add credentials with GitHub App authentication to use for all repositories under https://github.com/repos + # Add credentials with GitHub App authentication to use for all repositories under https://github.com/repos. github-app-installation-id is optional, if not provided, the installation id will be fetched from the GitHub API. argocd repocreds add https://github.com/repos/ --github-app-id 1 --github-app-installation-id 2 --github-app-private-key-path test.private-key.pem - # Add credentials with GitHub App authentication to use for all repositories under https://ghe.example.com/repos + # Add credentials with GitHub App authentication to use for all repositories under https://ghe.example.com/repos. github-app-installation-id is optional, if not provided, the installation id will be fetched from the GitHub API. argocd repocreds add https://ghe.example.com/repos/ --github-app-id 1 --github-app-installation-id 2 --github-app-private-key-path test.private-key.pem --github-app-enterprise-base-url https://ghe.example.com/api/v3 # Add credentials with helm oci registry so that these oci registry urls do not need to be added as repos individually. @@ -43,7 +43,7 @@ argocd repocreds add REPOURL [flags] --gcp-service-account-key-path string service account key for the Google Cloud Platform --github-app-enterprise-base-url string base url to use when using GitHub Enterprise (e.g. https://ghe.example.com/api/v3 --github-app-id int id of the GitHub Application - --github-app-installation-id int installation id of the GitHub Application + --github-app-installation-id int installation id of the GitHub Application (optional, will be auto-discovered if not provided) --github-app-private-key-path string private key of the GitHub Application -h, --help help for add --password string password to the repository diff --git a/docs/user-guide/private-repositories.md b/docs/user-guide/private-repositories.md index cc11b7a977..483d8a15b5 100644 --- a/docs/user-guide/private-repositories.md +++ b/docs/user-guide/private-repositories.md @@ -122,13 +122,16 @@ argocd repo add https://github.com/argoproj/argocd-example-apps.git --github-app > [!NOTE] > To add a private Git repository on GitHub Enterprise using the CLI add `--github-app-enterprise-base-url https://ghe.example.com/api/v3` flag. +> [!NOTE] +> The `--github-app-installation-id` flag is optional. If omitted, Argo CD will automatically discover the installation ID based on the repository's organization. + Using the UI: 1. Navigate to `Settings/Repositories` ![connect repo overview](../assets/repo-add-overview.png) -2. Click `Connect Repo using GitHub App` button, choose type: `GitHub` or `GitHub Enterprise`, enter the URL, App Id, Installation Id, and the app's private key. +2. Click `Connect Repo using GitHub App` button, choose type: `GitHub` or `GitHub Enterprise`, enter the URL, App Id, Installation Id (optional), and the app's private key. > [!NOTE] > Enter the GitHub Enterprise Base URL for type `GitHub Enterprise`. diff --git a/docs/user-guide/source-hydrator.md b/docs/user-guide/source-hydrator.md index 670ec68112..a6f8a78092 100644 --- a/docs/user-guide/source-hydrator.md +++ b/docs/user-guide/source-hydrator.md @@ -62,7 +62,8 @@ stringData: url: "https://github.com//" type: "git" githubAppID: "" - githubAppInstallationID: "" + # githubAppInstallationID is optional and will be auto-discovered if omitted + githubAppInstallationID: "" # Optional githubAppPrivateKey: | --- @@ -78,7 +79,8 @@ stringData: url: "https://github.com//" type: "git" githubAppID: "" - githubAppInstallationID: "" + # githubAppInstallationID is optional and will be auto-discovered if omitted + githubAppInstallationID: "" # Optional githubAppPrivateKey: | ``` diff --git a/pkg/apis/application/v1alpha1/repository_types.go b/pkg/apis/application/v1alpha1/repository_types.go index 71a311f7ff..8366064a18 100644 --- a/pkg/apis/application/v1alpha1/repository_types.go +++ b/pkg/apis/application/v1alpha1/repository_types.go @@ -1,9 +1,11 @@ package v1alpha1 import ( + "context" "fmt" "net/url" "strings" + "time" "github.com/argoproj/argo-cd/v3/util/oci" @@ -239,8 +241,33 @@ func (repo *Repository) GetGitCreds(store git.CredsStore) git.Creds { if repo.SSHPrivateKey != "" { return git.NewSSHCreds(repo.SSHPrivateKey, getCAPath(repo.Repo), repo.IsInsecure(), repo.Proxy) } - if repo.GithubAppPrivateKey != "" && repo.GithubAppId != 0 && repo.GithubAppInstallationId != 0 { - return git.NewGitHubAppCreds(repo.GithubAppId, repo.GithubAppInstallationId, repo.GithubAppPrivateKey, repo.GitHubAppEnterpriseBaseURL, repo.TLSClientCertData, repo.TLSClientCertKey, repo.IsInsecure(), repo.Proxy, repo.NoProxy, store) + if repo.GithubAppPrivateKey != "" && repo.GithubAppId != 0 { // Promoter MVP: remove github-app-installation-id check since it is no longer a required field + installationId := repo.GithubAppInstallationId + + // Auto-discover installation ID if not provided + if installationId == 0 { + org, err := git.ExtractOrgFromRepoURL(repo.Repo) + if err != nil { + log.Warnf("Failed to extract organization from repository URL %s for GitHub App auto-discovery: %v", repo.Repo, err) + return git.NopCreds{} + } + if org != "" { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + discoveredId, err := git.DiscoverGitHubAppInstallationID(ctx, repo.GithubAppId, repo.GithubAppPrivateKey, repo.GitHubAppEnterpriseBaseURL, org) + if err != nil { + log.Warnf("Failed to auto-discover GitHub App installation ID for org %s: %v. Proceeding with installation ID 0.", org, err) + } else { + log.Infof("Auto-discovered GitHub App installation ID %d for org %s", discoveredId, org) + installationId = discoveredId + } + } else { + log.Warnf("Could not extract organization from repository URL %s for GitHub App auto-discovery", repo.Repo) + } + } + + return git.NewGitHubAppCreds(repo.GithubAppId, installationId, repo.GithubAppPrivateKey, repo.GitHubAppEnterpriseBaseURL, repo.TLSClientCertData, repo.TLSClientCertKey, repo.IsInsecure(), repo.Proxy, repo.NoProxy, store) } if repo.GCPServiceAccountKey != "" { return git.NewGoogleCloudCreds(repo.GCPServiceAccountKey, store) diff --git a/ui/src/app/settings/components/repos-list/repos-list.tsx b/ui/src/app/settings/components/repos-list/repos-list.tsx index 5a2834a493..478838e618 100644 --- a/ui/src/app/settings/components/repos-list/repos-list.tsx +++ b/ui/src/app/settings/components/repos-list/repos-list.tsx @@ -217,7 +217,6 @@ export const ReposList = ({match, location}: RouteComponentProps) => { return { url: (!githubAppValues.url && 'Repository URL is required') || (credsTemplate && !isHTTPOrHTTPSUrl(githubAppValues.url) && 'Not a valid HTTP/HTTPS URL'), githubAppId: !githubAppValues.githubAppId && 'GitHub App ID is required', - githubAppInstallationId: !githubAppValues.githubAppInstallationId && 'GitHub App installation ID is required', githubAppPrivateKey: !githubAppValues.githubAppPrivateKey && 'GitHub App private Key is required' }; case ConnectionMethod.GOOGLECLOUD: diff --git a/util/git/creds.go b/util/git/creds.go index 0a7640b060..130f6df7d2 100644 --- a/util/git/creds.go +++ b/util/git/creds.go @@ -9,13 +9,16 @@ import ( "errors" "fmt" "io" + "net" "net/http" "net/url" "os" "strconv" "strings" + "sync" "time" + giturls "github.com/chainguard-dev/git-urls" "github.com/google/go-github/v69/github" "golang.org/x/oauth2" @@ -43,6 +46,10 @@ var ( // In memory cache for storing Azure tokens azureTokenCache *gocache.Cache + + // installationIdCache caches installation IDs for organizations to avoid redundant API calls. + githubInstallationIdCache *gocache.Cache + githubInstallationIdCacheMutex sync.RWMutex // For bulk API call coordination ) const ( @@ -66,6 +73,7 @@ func init() { // oauth2.TokenSource handles fetching new Tokens once they are expired. The oauth2.TokenSource itself does not expire. googleCloudTokenSource = gocache.New(gocache.NoExpiration, 0) azureTokenCache = gocache.New(gocache.NoExpiration, 0) + githubInstallationIdCache = gocache.New(60*time.Minute, 60*time.Minute) } type NoopCredsStore struct{} @@ -576,6 +584,199 @@ func (g GitHubAppCreds) GetClientCertKey() string { return g.clientCertKey } +// GitHub App installation discovery cache and helper + +// DiscoverGitHubAppInstallationID discovers the GitHub App installation ID for a given organization. +// It queries the GitHub API to list all installations for the app and returns the installation ID +// for the matching organization. Results are cached to avoid redundant API calls. +// An optional HTTP client can be provided for custom transport (e.g., for metrics tracking). +func DiscoverGitHubAppInstallationID(ctx context.Context, appId int64, privateKey, enterpriseBaseURL, org string, httpClient ...*http.Client) (int64, error) { + domain, err := domainFromBaseURL(enterpriseBaseURL) + if err != nil { + return 0, fmt.Errorf("failed to get domain from base URL: %w", err) + } + org = strings.ToLower(org) + // Check cache first + cacheKey := fmt.Sprintf("%s:%s:%d", strings.ToLower(org), domain, appId) + if id, found := githubInstallationIdCache.Get(cacheKey); found { + return id.(int64), nil + } + + // Use provided HTTP client or default + var transport http.RoundTripper + if len(httpClient) > 0 && httpClient[0] != nil && httpClient[0].Transport != nil { + transport = httpClient[0].Transport + } else { + transport = http.DefaultTransport + } + + // Create GitHub App transport + rt, err := ghinstallation.NewAppsTransport(transport, appId, []byte(privateKey)) + if err != nil { + return 0, fmt.Errorf("failed to create GitHub app transport: %w", err) + } + + if enterpriseBaseURL != "" { + rt.BaseURL = enterpriseBaseURL + } + + // Create GitHub client + var client *github.Client + clientTransport := &http.Client{Transport: rt} + if enterpriseBaseURL == "" { + client = github.NewClient(clientTransport) + } else { + client, err = github.NewClient(clientTransport).WithEnterpriseURLs(enterpriseBaseURL, enterpriseBaseURL) + if err != nil { + return 0, fmt.Errorf("failed to create GitHub enterprise client: %w", err) + } + } + + // List all installations and cache them + var allInstallations []*github.Installation + opts := &github.ListOptions{PerPage: 100} + + // Lock for the entire loop to avoid multiple concurrent API calls on startup + githubInstallationIdCacheMutex.Lock() + defer githubInstallationIdCacheMutex.Unlock() + + // Check cache again inside the write lock in case another goroutine already fetched it + if id, found := githubInstallationIdCache.Get(cacheKey); found { + return id.(int64), nil + } + + for { + installations, resp, err := client.Apps.ListInstallations(ctx, opts) + if err != nil { + return 0, fmt.Errorf("failed to list installations: %w", err) + } + + allInstallations = append(allInstallations, installations...) + + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + + // Cache all installation IDs + for _, installation := range allInstallations { + if installation.Account != nil && installation.Account.Login != nil && installation.ID != nil { + githubInstallationIdCache.Set(cacheKey, *installation.ID, gocache.DefaultExpiration) + } + } + + // Return the installation ID for the requested org + if id, found := githubInstallationIdCache.Get(cacheKey); found { + return id.(int64), nil + } + return 0, fmt.Errorf("installation not found for org: %s", org) +} + +// domainFromBaseURL extracts the host (domain) from the given GitHub base URL. +// Supports HTTP(S), SSH URLs, and git@host:org/repo forms. +// Returns an error if a domain cannot be extracted. +func domainFromBaseURL(baseURL string) (string, error) { + if baseURL == "" { + return "github.com", nil + } + + // --- 1. SSH-style Git URL: git@github.com:org/repo.git --- + if strings.Contains(baseURL, "@") && strings.Contains(baseURL, ":") && !strings.Contains(baseURL, "://") { + parts := strings.SplitN(baseURL, "@", 2) + right := parts[len(parts)-1] // github.com:org/repo + host := strings.SplitN(right, ":", 2)[0] // github.com + if host != "" { + return host, nil + } + return "", fmt.Errorf("failed to extract host from SSH-style URL: %q", baseURL) + } + + // --- 2. Ensure scheme so url.Parse works --- + if !strings.HasPrefix(baseURL, "http://") && + !strings.HasPrefix(baseURL, "https://") && + !strings.HasPrefix(baseURL, "ssh://") { + baseURL = "https://" + baseURL + } + + // --- 3. Standard URL parse --- + parsed, err := url.Parse(baseURL) + if err != nil { + return "", fmt.Errorf("failed to parse URL %q: %w", baseURL, err) + } + if parsed.Host == "" { + return "", fmt.Errorf("URL %q parsed but host is empty", baseURL) + } + + host := parsed.Host + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + return host, nil +} + +// ExtractOrgFromRepoURL extracts the organization/owner name from a GitHub repository URL. +// Supports formats: +// - HTTPS: https://github.com/org/repo.git +// - SSH: git@github.com:org/repo.git +// - SSH with port: git@github.com:22/org/repo.git or ssh://git@github.com:22/org/repo.git +func ExtractOrgFromRepoURL(repoURL string) (string, error) { + if repoURL == "" { + return "", errors.New("repo URL is empty") + } + + // Handle edge case: ssh://git@host:org/repo (malformed but used in practice) + // This format mixes ssh:// prefix with colon notation instead of using a slash. + // Convert it to git@host:org/repo which git-urls can parse correctly. + // We distinguish this from the valid ssh://git@host:22/org/repo (with port number). + if strings.HasPrefix(repoURL, "ssh://git@") { + remainder := strings.TrimPrefix(repoURL, "ssh://") + if colonIdx := strings.Index(remainder, ":"); colonIdx != -1 { + afterColon := remainder[colonIdx+1:] + slashIdx := strings.Index(afterColon, "/") + + // Check if what follows the colon is a port number + isPort := false + if slashIdx > 0 { + if _, err := strconv.Atoi(afterColon[:slashIdx]); err == nil { + isPort = true + } + } + + // If not a port, it's the malformed format - strip ssh:// prefix + if !isPort && slashIdx != 0 { + repoURL = remainder + } + } + } + + // Use git-urls library to parse all Git URL formats + parsed, err := giturls.Parse(repoURL) + if err != nil { + return "", fmt.Errorf("failed to parse repository URL %q: %w", repoURL, err) + } + + // Clean the path: remove leading/trailing slashes and .git suffix + path := strings.Trim(parsed.Path, "/") + path = strings.TrimSuffix(path, ".git") + + if path == "" { + return "", fmt.Errorf("repository URL %q does not contain a path", repoURL) + } + + // Extract the first path component (organization/owner) + // Path format is typically "org/repo" or "org/repo/subpath" + if idx := strings.Index(path, "/"); idx > 0 { + org := path[:idx] + // Normalize to lowercase for case-insensitive comparison + return strings.ToLower(org), nil + } + + // If there's no slash, the entire path might be just the org (unusual but handle it) + // This would fail validation later, but let's return it + return "", fmt.Errorf("could not extract organization from repository URL %q: path %q does not contain org/repo format", repoURL, path) +} + var _ Creds = GoogleCloudCreds{} // GoogleCloudCreds to authenticate to Google Cloud Source repositories diff --git a/util/git/creds_test.go b/util/git/creds_test.go index b34927e4ad..f8b0b1cd30 100644 --- a/util/git/creds_test.go +++ b/util/git/creds_test.go @@ -1,8 +1,12 @@ package git import ( + "context" "encoding/base64" + "encoding/json" "fmt" + "net/http" + "net/http/httptest" "os" "path" "regexp" @@ -515,3 +519,132 @@ func TestAzureWorkloadIdentityCreds_ReuseTokenIfExistingIsNotExpired(t *testing. func resetAzureTokenCache() { azureTokenCache = gocache.New(gocache.NoExpiration, 0) } + +const fakeGitHubAppPrivateKey = `-----BEGIN RSA PRIVATE KEY----- +MIIEogIBAAKCAQEA2KHkp2fe0ReHqAt9BimWEec2ryWZIyg9jvB3BdP3mzFf0bOt +WlHm1FAETFxH4h5jYASUaaWEwRNNyGlT1GhTp+jOMC4xhOSb5/SnI2dt2EITkudQ +FKsSFUdJAndqOzkjrP2pL4fi4b7JWhuLDO36ufAP4l2m3tnAseGSSTIccWvzLFFU +s3wsHOHxOcJGCP1Z7rizxl6mTKYL/Z+GHqN17OJslDf901uPXsUeDCYL2iigGPhD +Ao6k8POsfbpqLG7poCDTK50FLnS5qEocjxt+J4ZjBEWTU/DOFXWYstzfbhm8OZPQ +pSSEiBCxpg+zjtkQfCyZxXB5RQ84CY78fXOI9QIDAQABAoIBAG8jL0FLIp62qZvm +uO9ualUo/37/lP7aaCpq50UQJ9lwjS3yNh8+IWQO4QWj2iUBXg4mi1Vf2ymKk78b +eixgkXp1D0Lcj/8ToYBwnUami04FKDGXhhf0Y8SS27vuM4vKlqjrQd7modkangYi +V0X82UKHDD8fuLpfkGIxzXDLypfMzjMuVpSntnWaf2YX3VR/0/66yEp9GejftF2k +wqhGoWM6r68pN5XuCqWd5PRluSoDy/o4BAFMhYCSfp9PjgZE8aoeWHgYzlZ3gUyn +r+HaDDNWbibhobXk/9h8lwAJ6KCZ5RZ+HFfh0HuwIxmocT9OCFgy/S0g1p+o3m9K +VNd5AMkCgYEA5fbS5UK7FBzuLoLgr1hktmbLJhpt8y8IPHNABHcUdE+O4/1xTQNf +pMUwkKjGG1MtrGjLOIoMGURKKn8lR1GMZueOTSKY0+mAWUGvSzl6vwtJwvJruT8M +otEO03o0tPnRKGxbFjqxkp2b6iqJ8MxCRZ3lSidc4mdi7PHzv9lwgvsCgYEA8Siq +7weCri9N6y+tIdORAXgRzcW54BmJyqB147c72RvbMacb6rN28KXpM3qnRXyp3Llb +yh81TW3FH10GqrjATws7BK8lP9kkAw0Z/7kNiS1NgH3pUbO+5H2kAa/6QW35nzRe +Jw2lyfYGWqYO4hYXH14ML1kjgS1hgd3XHOQ64M8CgYAKcjDYSzS2UC4dnMJaFLjW +dErsGy09a7iDDnUs/r/GHMsP3jZkWi/hCzgOiiwdl6SufUAl/FdaWnjH/2iRGco3 +7nLPXC/3CFdVNp+g2iaSQRADtAFis9N+HeL/hkCYq/RtUqa8lsP0NgacF3yWnKCy +Ct8chDc67ZlXzBHXeCgdOwKBgHHGFPbWXUHeUW1+vbiyvrupsQSanznp8oclMtkv +Dk48hSokw9fzuU6Jh77gw9/Vk7HtxS9Tj+squZA1bDrJFPl1u+9WzkUUJZhG6xgp +bwhj1iejv5rrKUlVOTYOlwudXeJNa4oTNz9UEeVcaLMjZt9GmIsSC90a0uDZD26z +AlAjAoGAEoqm2DcNN7SrH6aVFzj1EVOrNsHYiXj/yefspeiEmf27PSAslP+uF820 +SDpz4h+Bov5qTKkzcxuu1QWtA4M0K8Iy6IYLwb83DZEm1OsAf4i0pODz21PY/I+O +VHzjB10oYgaInHZgMUdyb6F571UdiYSB6a/IlZ3ngj5touy3VIM= +-----END RSA PRIVATE KEY-----` + +func TestDiscoverGitHubAppInstallationID(t *testing.T) { + t.Run("returns cached installation ID", func(t *testing.T) { + // Setup: prepopulate cache + org := "test-org" + appId := int64(12345) + domain := "github.com" + expectedId := int64(98765) + + // Clean up at both start and end to ensure test isolation + cacheKey := fmt.Sprintf("%s:%s:%d", strings.ToLower(org), domain, appId) + + githubInstallationIdCache.Set(cacheKey, expectedId, gocache.NoExpiration) + + // Ensure cleanup even if test fails + t.Cleanup(func() { + githubInstallationIdCache.Delete(cacheKey) + }) + + // Execute + ctx := context.Background() + actualId, err := DiscoverGitHubAppInstallationID(ctx, appId, "fake-key", "", org) + + // Assert + require.NoError(t, err) + assert.Equal(t, expectedId, actualId) + }) + + t.Run("discovers installation ID from GitHub API", func(t *testing.T) { + // Setup: mock GitHub API server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // GitHub Enterprise expects paths like /api/v3/app/installations + // go-github's WithEnterpriseURLs adds this prefix automatically + if strings.HasSuffix(r.URL.Path, "/app/installations") { + w.WriteHeader(http.StatusOK) + //nolint:errcheck + json.NewEncoder(w).Encode([]map[string]any{ + {"id": 98765, "account": map[string]any{"login": "test-org"}}, + }) + return + } + // Return 404 for any other path + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + // Clean up cache entry for this test on completion + t.Cleanup(func() { + // Extract domain from server URL for proper cache key + domain, _ := domainFromBaseURL(server.URL) + cacheKey := fmt.Sprintf("%s:%s:%d", strings.ToLower("test-org"), domain, 12345) + githubInstallationIdCache.Delete(cacheKey) + }) + + // Execute & Assert + ctx := context.Background() + // Pass the mock server URL as the enterpriseBaseURL so the GitHub client uses it + // Note: The mock server will have a different domain (e.g., 127.0.0.1) than the first test (github.com), + // so there's no cache collision between the two subtests. + actualId, err := DiscoverGitHubAppInstallationID(ctx, 12345, fakeGitHubAppPrivateKey, server.URL, "test-org") + require.NoError(t, err) + assert.Equal(t, int64(98765), actualId) + }) +} + +func TestExtractOrgFromRepoURL(t *testing.T) { + tests := []struct { + name string + repoURL string + expected string + expectError bool + }{ + {"HTTPS URL", "https://github.com/argoproj/argo-cd", "argoproj", false}, + {"HTTPS URL with .git", "https://github.com/argoproj/argo-cd.git", "argoproj", false}, + {"HTTPS URL with port", "https://github.com:443/argoproj/argo-cd.git", "argoproj", false}, + {"SSH URL", "git@github.com:argoproj/argo-cd.git", "argoproj", false}, + {"SSH URL without .git", "git@github.com:argoproj/argo-cd", "argoproj", false}, + {"SSH URL with ssh:// prefix", "ssh://git@github.com:argoproj/argo-cd.git", "argoproj", false}, + {"SSH URL with port", "ssh://git@github.com:22/argoproj/argo-cd.git", "argoproj", false}, + {"GitHub Enterprise HTTPS", "https://github.example.com/myorg/myrepo.git", "myorg", false}, + {"GitHub Enterprise SSH", "git@github.example.com:myorg/myrepo.git", "myorg", false}, + {"Case insensitive", "https://github.com/ArgoPROJ/argo-cd", "argoproj", false}, // Test case sensitivity + {"Invalid URL", "not-a-url", "", true}, + {"Empty string", "", "", true}, + {"URL without org/repo", "https://github.com", "", true}, + {"URL with only org", "https://github.com/argoproj", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual, err := ExtractOrgFromRepoURL(tt.repoURL) + if tt.expectError { + require.Error(t, err) + assert.Empty(t, actual) + } else { + require.NoError(t, err) + assert.Equal(t, tt.expected, actual) + } + }) + } +} diff --git a/util/git/git_test.go b/util/git/git_test.go index 555695d44a..6a844b326f 100644 --- a/util/git/git_test.go +++ b/util/git/git_test.go @@ -10,12 +10,21 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/argoproj/argo-cd/v3/util/proxy" + "github.com/argoproj/argo-cd/v3/common" "github.com/argoproj/argo-cd/v3/test/fixture/log" "github.com/argoproj/argo-cd/v3/test/fixture/path" "github.com/argoproj/argo-cd/v3/test/fixture/test" ) +func TestMain(m *testing.M) { + // Ensure tests use non-cached proxy callback + proxy.UseTestingProxyCallback() + + os.Exit(m.Run()) +} + func TestIsCommitSHA(t *testing.T) { assert.True(t, IsCommitSHA("9d921f65f3c5373b682e2eb4b37afba6592e8f8b")) assert.True(t, IsCommitSHA("9D921F65F3C5373B682E2EB4B37AFBA6592E8F8B")) diff --git a/util/proxy/proxy.go b/util/proxy/proxy.go index c4349e8ad4..cd7b7eba18 100644 --- a/util/proxy/proxy.go +++ b/util/proxy/proxy.go @@ -9,6 +9,19 @@ import ( "golang.org/x/net/http/httpproxy" ) +// DefaultProxyCallback is the default proxy callback function that reads from environment variables. http.ProxyFromEnvironment +// is cached on first call, so we can't use it for tests. When writing a test that uses t.Setenv for some proxy env var, +// call UseTestingProxyCallback. +var DefaultProxyCallback = http.ProxyFromEnvironment + +// UseTestingProxyCallback sets the DefaultProxyCallback to use httpproxy.FromEnvironment. This is useful for tests that +// use t.Setenv to set proxy env variables. +func UseTestingProxyCallback() { + DefaultProxyCallback = func(r *http.Request) (*url.URL, error) { + return httpproxy.FromEnvironment().ProxyFunc()(r.URL) + } +} + // UpsertEnv removes the existing proxy env variables and adds the custom proxy variables func UpsertEnv(cmd *exec.Cmd, proxy string, noProxy string) []string { envs := []string{} @@ -42,7 +55,7 @@ func GetCallback(proxy string, noProxy string) func(*http.Request) (*url.URL, er } } // read proxy from env variable if custom proxy is missing - return http.ProxyFromEnvironment + return DefaultProxyCallback } func httpProxy(url string) string { diff --git a/util/proxy/proxy_test.go b/util/proxy/proxy_test.go index 39a50fdf0c..c1617d6ed8 100644 --- a/util/proxy/proxy_test.go +++ b/util/proxy/proxy_test.go @@ -4,6 +4,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "os" "os/exec" "testing" @@ -11,6 +12,13 @@ import ( "github.com/stretchr/testify/require" ) +func TestMain(m *testing.M) { + // Ensure tests use non-cached proxy callback + UseTestingProxyCallback() + + os.Exit(m.Run()) +} + func TestAddProxyEnvIfAbsent(t *testing.T) { t.Run("Existing proxy env variables", func(t *testing.T) { proxy := "https://proxy:5000"