diff --git a/api/v1/gitrepository_types.go b/api/v1/gitrepository_types.go index 2ed4df258..20ef37d0c 100644 --- a/api/v1/gitrepository_types.go +++ b/api/v1/gitrepository_types.go @@ -35,6 +35,10 @@ const ( // GitProviderAzure provides support for authentication to azure // repositories using Managed Identity. GitProviderAzure string = "azure" + + // GitProviderGitHub provides support for authentication to git + // repositories using GitHub App authentication + GitProviderGitHub string = "github" ) const ( @@ -88,9 +92,9 @@ type GitRepositorySpec struct { // +optional SecretRef *meta.LocalObjectReference `json:"secretRef,omitempty"` - // Provider used for authentication, can be 'azure', 'generic'. + // Provider used for authentication, can be 'azure', 'github', 'generic'. // When not specified, defaults to 'generic'. - // +kubebuilder:validation:Enum=generic;azure + // +kubebuilder:validation:Enum=generic;azure;github // +optional Provider string `json:"provider,omitempty"` diff --git a/config/crd/bases/source.toolkit.fluxcd.io_gitrepositories.yaml b/config/crd/bases/source.toolkit.fluxcd.io_gitrepositories.yaml index 9d01fbd54..0e37a7b49 100644 --- a/config/crd/bases/source.toolkit.fluxcd.io_gitrepositories.yaml +++ b/config/crd/bases/source.toolkit.fluxcd.io_gitrepositories.yaml @@ -105,11 +105,12 @@ spec: type: string provider: description: |- - Provider used for authentication, can be 'azure', 'generic'. + Provider used for authentication, can be 'azure', 'github', 'generic'. When not specified, defaults to 'generic'. enum: - generic - azure + - github type: string proxySecretRef: description: |- diff --git a/docs/api/v1/source.md b/docs/api/v1/source.md index 521dddc14..121a056cd 100644 --- a/docs/api/v1/source.md +++ b/docs/api/v1/source.md @@ -390,7 +390,7 @@ string (Optional) -

Provider used for authentication, can be ‘azure’, ‘generic’. +

Provider used for authentication, can be ‘azure’, ‘github’, ‘generic’. When not specified, defaults to ‘generic’.

@@ -1730,7 +1730,7 @@ string (Optional) -

Provider used for authentication, can be ‘azure’, ‘generic’. +

Provider used for authentication, can be ‘azure’, ‘github’, ‘generic’. When not specified, defaults to ‘generic’.

diff --git a/docs/spec/v1/gitrepositories.md b/docs/spec/v1/gitrepositories.md index e78aee74a..bf1602c3a 100644 --- a/docs/spec/v1/gitrepositories.md +++ b/docs/spec/v1/gitrepositories.md @@ -221,6 +221,7 @@ Supported options are: - `generic` - `azure` +- `github` When provider is not specified, it defaults to `generic` indicating that mechanisms using `spec.secretRef` are used for authentication. @@ -296,6 +297,64 @@ must follow this format: ``` https://dev.azure.com/{your-organization}/{your-project}/_git/{your-repository} ``` +#### GitHub + +The `github` provider can be used to authenticate to Git repositories using +[GitHub Apps](https://docs.github.com/en/apps/overview). + +##### Pre-requisites + +- [Register](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app) + the GitHub App with the necessary permissions and [generate a private + key](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/managing-private-keys-for-github-apps) + for the app. + +- [Install](https://docs.github.com/en/apps/using-github-apps/installing-your-own-github-app) + the app in the organization/account configuring access to the necessary + repositories. + +##### Configure GitHub App secret + +The GitHub App information is specified in `.spec.secretRef` in the format +specified below: + +- Get the App ID from the app settings page at + `https://github.com/settings/apps/`. +- Get the App Installation ID from the app installations page at +`https://github.com/settings/installations`. Click the installed app, the URL +will contain the installation ID +`https://github.com/settings/installations/`. For +organizations, the first part of the URL may be different, but it follows the +same pattern. +- The private key that was generated in the pre-requisites. +- (Optional) GitHub Enterprise Server users can set the base URL to + `http(s)://HOSTNAME/api/v3`. + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: github-sa +type: Opaque +stringData: + githubAppID: "" + githubAppInstallationID: "" + githubAppPrivateKey: | + -----BEGIN RSA PRIVATE KEY----- + ... + -----END RSA PRIVATE KEY----- + githubAppBaseURL: "" #optional, required only for GitHub Enterprise Server users +``` + +Alternatively, the Flux CLI can be used to automatically create the secret with +the github app authentication information. + +```sh +flux create secret githubapp ghapp-secret \ + --app-id=1 \ + --app-installation-id=3 \ + --app-private-key=~/private-key.pem +``` ### Interval diff --git a/internal/controller/gitrepository_controller.go b/internal/controller/gitrepository_controller.go index 9ecaf2866..155dfc24e 100644 --- a/internal/controller/gitrepository_controller.go +++ b/internal/controller/gitrepository_controller.go @@ -28,6 +28,7 @@ import ( securejoin "github.com/cyphar/filepath-securejoin" "github.com/fluxcd/pkg/auth/azure" + "github.com/fluxcd/pkg/auth/github" "github.com/fluxcd/pkg/runtime/logger" "github.com/go-git/go-git/v5/plumbing/transport" corev1 "k8s.io/api/core/v1" @@ -506,13 +507,8 @@ func (r *GitRepositoryReconciler) reconcileSource(ctx context.Context, sp *patch authOpts, err := r.getAuthOpts(ctx, obj, *u) if err != nil { - e := serror.NewGeneric( - fmt.Errorf("failed to configure authentication options: %w", err), - sourcev1.AuthenticationFailedReason, - ) - conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, "%s", e) // Return error as the world as observed may change - return sreconcile.ResultEmpty, e + return sreconcile.ResultEmpty, err } // Fetch the included artifact metadata. @@ -639,26 +635,63 @@ func (r *GitRepositoryReconciler) getAuthOpts(ctx context.Context, obj *sourcev1 var err error authData, err = r.getSecretData(ctx, obj.Spec.SecretRef.Name, obj.GetNamespace()) if err != nil { - return nil, fmt.Errorf("failed to get secret '%s/%s': %w", obj.GetNamespace(), obj.Spec.SecretRef.Name, err) + e := serror.NewGeneric( + fmt.Errorf("failed to get secret '%s/%s': %w", obj.GetNamespace(), obj.Spec.SecretRef.Name, err), + sourcev1.AuthenticationFailedReason, + ) + conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, "%s", e) + return nil, e } } // Configure authentication strategy to access the source authOpts, err := git.NewAuthOptions(u, authData) if err != nil { - return nil, err + e := serror.NewGeneric( + fmt.Errorf("failed to configure authentication options: %w", err), + sourcev1.AuthenticationFailedReason, + ) + conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, "%s", e) + return nil, e } // Configure provider authentication if specified in spec - if obj.GetProvider() == sourcev1.GitProviderAzure { + switch obj.GetProvider() { + case sourcev1.GitProviderAzure: authOpts.ProviderOpts = &git.ProviderOptions{ - Name: obj.GetProvider(), + Name: sourcev1.GitProviderAzure, AzureOpts: []azure.OptFunc{ azure.WithAzureDevOpsScope(), }, } - } + case sourcev1.GitProviderGitHub: + // if provider is github, but secret ref is not specified + if obj.Spec.SecretRef == nil { + e := serror.NewStalling( + fmt.Errorf("secretRef with github app data must be specified when provider is set to github"), + sourcev1.AuthenticationFailedReason, + ) + conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, "%s", e) + return nil, e + } + authOpts.ProviderOpts = &git.ProviderOptions{ + Name: sourcev1.GitProviderGitHub, + GitHubOpts: []github.OptFunc{ + github.WithAppData(authData), + }, + } + default: + // analyze secret, if it has github app data, perhaps provider should have been github. + if appID := authData[github.AppIDKey]; len(appID) != 0 { + e := serror.NewStalling( + fmt.Errorf("secretRef '%s/%s' has github app data but provider is not set to github", obj.GetNamespace(), obj.Spec.SecretRef.Name), + sourcev1.AuthenticationFailedReason, + ) + conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, "%s", e) + return nil, e + } + } return authOpts, nil } diff --git a/internal/controller/gitrepository_controller_test.go b/internal/controller/gitrepository_controller_test.go index a81235553..616a9b346 100644 --- a/internal/controller/gitrepository_controller_test.go +++ b/internal/controller/gitrepository_controller_test.go @@ -48,6 +48,7 @@ import ( kstatus "github.com/fluxcd/cli-utils/pkg/kstatus/status" "github.com/fluxcd/pkg/apis/meta" + "github.com/fluxcd/pkg/auth/github" "github.com/fluxcd/pkg/git" "github.com/fluxcd/pkg/gittestserver" "github.com/fluxcd/pkg/runtime/conditions" @@ -686,23 +687,89 @@ func TestGitRepositoryReconciler_reconcileSource_authStrategy(t *testing.T) { func TestGitRepositoryReconciler_getAuthOpts_provider(t *testing.T) { tests := []struct { name string + url string + secret *corev1.Secret beforeFunc func(obj *sourcev1.GitRepository) wantProviderOptsName string + wantErr error }{ { name: "azure provider", + url: "https://dev.azure.com/foo/bar/_git/baz", beforeFunc: func(obj *sourcev1.GitRepository) { obj.Spec.Provider = sourcev1.GitProviderAzure }, wantProviderOptsName: sourcev1.GitProviderAzure, }, + { + name: "github provider with no secret ref", + url: "https://github.com/org/repo.git", + beforeFunc: func(obj *sourcev1.GitRepository) { + obj.Spec.Provider = sourcev1.GitProviderGitHub + }, + wantProviderOptsName: sourcev1.GitProviderGitHub, + wantErr: errors.New("secretRef with github app data must be specified when provider is set to github"), + }, + { + name: "github provider with secret ref that does not exist", + url: "https://github.com/org/repo.git", + beforeFunc: func(obj *sourcev1.GitRepository) { + obj.Spec.Provider = sourcev1.GitProviderGitHub + obj.Spec.SecretRef = &meta.LocalObjectReference{ + Name: "githubAppSecret", + } + }, + wantErr: errors.New("failed to get secret '/githubAppSecret': secrets \"githubAppSecret\" not found"), + }, + { + name: "github provider with github app data in secret", + url: "https://example.com/org/repo", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "githubAppSecret", + }, + Data: map[string][]byte{ + github.AppIDKey: []byte("123"), + github.AppInstallationIDKey: []byte("456"), + github.AppPrivateKey: []byte("abc"), + }, + }, + beforeFunc: func(obj *sourcev1.GitRepository) { + obj.Spec.Provider = sourcev1.GitProviderGitHub + obj.Spec.SecretRef = &meta.LocalObjectReference{ + Name: "githubAppSecret", + } + }, + wantProviderOptsName: sourcev1.GitProviderGitHub, + }, + { + name: "generic provider with github app data in secret", + url: "https://example.com/org/repo", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "githubAppSecret", + }, + Data: map[string][]byte{ + github.AppIDKey: []byte("123"), + }, + }, + beforeFunc: func(obj *sourcev1.GitRepository) { + obj.Spec.Provider = sourcev1.GitProviderGeneric + obj.Spec.SecretRef = &meta.LocalObjectReference{ + Name: "githubAppSecret", + } + }, + wantErr: errors.New("secretRef '/githubAppSecret' has github app data but provider is not set to github"), + }, { name: "generic provider", + url: "https://example.com/org/repo", beforeFunc: func(obj *sourcev1.GitRepository) { obj.Spec.Provider = sourcev1.GitProviderGeneric }, }, { + url: "https://example.com/org/repo", name: "no provider", }, } @@ -710,22 +777,42 @@ func TestGitRepositoryReconciler_getAuthOpts_provider(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { g := NewWithT(t) + clientBuilder := fakeclient.NewClientBuilder(). + WithScheme(testEnv.GetScheme()). + WithStatusSubresource(&sourcev1.GitRepository{}) + + if tt.secret != nil { + clientBuilder.WithObjects(tt.secret) + } + obj := &sourcev1.GitRepository{} - r := &GitRepositoryReconciler{} - url, _ := url.Parse("https://dev.azure.com/foo/bar/_git/baz") + r := &GitRepositoryReconciler{ + EventRecorder: record.NewFakeRecorder(32), + Client: clientBuilder.Build(), + features: features.FeatureGates(), + patchOptions: getPatchOptions(gitRepositoryReadyCondition.Owned, "sc"), + } + + url, err := url.Parse(tt.url) + g.Expect(err).ToNot(HaveOccurred()) if tt.beforeFunc != nil { tt.beforeFunc(obj) } opts, err := r.getAuthOpts(context.TODO(), obj, *url) - g.Expect(err).ToNot(HaveOccurred()) - g.Expect(opts).ToNot(BeNil()) - if tt.wantProviderOptsName != "" { - g.Expect(opts.ProviderOpts).ToNot(BeNil()) - g.Expect(opts.ProviderOpts.Name).To(Equal(tt.wantProviderOptsName)) + if tt.wantErr != nil { + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring(tt.wantErr.Error())) } else { - g.Expect(opts.ProviderOpts).To(BeNil()) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(opts).ToNot(BeNil()) + if tt.wantProviderOptsName != "" { + g.Expect(opts.ProviderOpts).ToNot(BeNil()) + g.Expect(opts.ProviderOpts.Name).To(Equal(tt.wantProviderOptsName)) + } else { + g.Expect(opts.ProviderOpts).To(BeNil()) + } } }) }