From bd88ef207802cdb3fa5a512ede182a31fc056608 Mon Sep 17 00:00:00 2001 From: Piero Rospigliosi Date: Mon, 10 Aug 2026 10:21:13 +0200 Subject: [PATCH] feat: add configurable drift detection interval --- .../templates/deployment.yaml | 1 + .../values.schema.json | 16 +++ charts/github-platform-operator/values.yaml | 3 + cmd/main.go | 126 +++++++++++------- .../githubactionssecret_controller.go | 11 +- .../githubactionsvariable_controller.go | 12 +- .../githubenvironment_controller.go | 14 +- .../githuborganizationmember_controller.go | 12 +- .../controller/githubrepository_controller.go | 16 +-- .../githubrepository_controller_test.go | 2 +- ...hubrepositorycollaborator_archived_test.go | 2 +- ...githubrepositorycollaborator_controller.go | 18 +-- .../githubrepositoryruleset_controller.go | 12 +- .../githubrepositoryteamaccess_controller.go | 12 +- internal/controller/githubteam_controller.go | 18 ++- .../githubteammembership_controller.go | 12 +- internal/controller/reconciliation.go | 64 +++++++++ .../reconciliation_contract_test.go | 56 ++++++++ 18 files changed, 289 insertions(+), 118 deletions(-) create mode 100644 internal/controller/reconciliation.go create mode 100644 internal/controller/reconciliation_contract_test.go diff --git a/charts/github-platform-operator/templates/deployment.yaml b/charts/github-platform-operator/templates/deployment.yaml index 0d0ec12..c4d0b3f 100644 --- a/charts/github-platform-operator/templates/deployment.yaml +++ b/charts/github-platform-operator/templates/deployment.yaml @@ -42,6 +42,7 @@ spec: {{- if .Values.leaderElection.enabled }} - --leader-elect {{- end }} + - {{ printf "--drift-detection-interval=%s" .Values.reconciliation.driftDetectionInterval | quote }} {{- if .Values.metrics.enabled }} - --metrics-bind-address=:{{ .Values.metrics.port }} {{- if not .Values.metrics.secure }} diff --git a/charts/github-platform-operator/values.schema.json b/charts/github-platform-operator/values.schema.json index 15d74fc..15b8099 100644 --- a/charts/github-platform-operator/values.schema.json +++ b/charts/github-platform-operator/values.schema.json @@ -11,6 +11,7 @@ "serviceAccount", "rbac", "leaderElection", + "reconciliation", "metrics", "networkPolicy", "resources" @@ -124,6 +125,21 @@ } } }, + "reconciliation": { + "description": "Periodic reconciliation configuration for detecting drift in GitHub.", + "type": "object", + "additionalProperties": false, + "required": [ + "driftDetectionInterval" + ], + "properties": { + "driftDetectionInterval": { + "description": "How often controllers poll GitHub for external drift. Watched Kubernetes events still reconcile immediately. The manager accepts Go duration syntax and enforces a range of 1m to 24h.", + "type": "string", + "minLength": 1 + } + } + }, "metrics": { "description": "Controller metrics endpoint configuration.", "type": "object", diff --git a/charts/github-platform-operator/values.yaml b/charts/github-platform-operator/values.yaml index 0627b4d..5c5d31a 100644 --- a/charts/github-platform-operator/values.yaml +++ b/charts/github-platform-operator/values.yaml @@ -21,6 +21,9 @@ rbac: leaderElection: enabled: true +reconciliation: + driftDetectionInterval: 5m + metrics: enabled: true secure: true diff --git a/cmd/main.go b/cmd/main.go index 0128006..ca3321b 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -21,6 +21,7 @@ import ( "flag" "os" "path/filepath" + "time" // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) // to ensure that exec-entrypoint and run can make use of them. @@ -64,6 +65,7 @@ func main() { var probeAddr string var secureMetrics bool var enableHTTP2 bool + var driftDetectionInterval time.Duration var tlsOpts []func(*tls.Config) flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+ "Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.") @@ -82,6 +84,13 @@ func main() { flag.StringVar(&metricsCertKey, "metrics-cert-key", "tls.key", "The name of the metrics server key file.") flag.BoolVar(&enableHTTP2, "enable-http2", false, "If set, HTTP/2 will be enabled for the metrics and webhook servers") + flag.DurationVar( + &driftDetectionInterval, + "drift-detection-interval", + controller.DefaultDriftDetectionInterval, + "How often controllers poll GitHub to detect external configuration drift. "+ + "Watched Kubernetes events still trigger reconciliation immediately.", + ) opts := zap.Options{ Development: true, } @@ -90,6 +99,13 @@ func main() { ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) + if err := controller.ValidateDriftDetectionInterval(driftDetectionInterval); err != nil { + setupLog.Error(err, "invalid drift detection interval") + os.Exit(1) + } + + setupLog.Info("configured GitHub drift detection", "interval", driftDetectionInterval) + // if the enable-http2 flag is false (the default), http/2 should be disabled // due to its vulnerabilities. More specifically, disabling http/2 will // prevent from being vulnerable to the HTTP/2 Stream Cancellation and @@ -219,110 +235,120 @@ func main() { } if err := (&controller.GitHubOrganizationMemberReconciler{ - Client: mgr.GetClient(), - APIReader: mgr.GetAPIReader(), - Scheme: mgr.GetScheme(), - GitHubClientFactory: githubClientFactory, - GitHubTokenProvider: githubTokenProvider, + Client: mgr.GetClient(), + APIReader: mgr.GetAPIReader(), + Scheme: mgr.GetScheme(), + GitHubClientFactory: githubClientFactory, + GitHubTokenProvider: githubTokenProvider, + DriftDetectionInterval: driftDetectionInterval, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "GitHubOrganizationMember") os.Exit(1) } if err := (&controller.GitHubTeamReconciler{ - Client: mgr.GetClient(), - APIReader: mgr.GetAPIReader(), - Scheme: mgr.GetScheme(), - GitHubClientFactory: githubClientFactory, - GitHubTokenProvider: githubTokenProvider, + Client: mgr.GetClient(), + APIReader: mgr.GetAPIReader(), + Scheme: mgr.GetScheme(), + GitHubClientFactory: githubClientFactory, + GitHubTokenProvider: githubTokenProvider, + DriftDetectionInterval: driftDetectionInterval, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "GitHubTeam") os.Exit(1) } if err := (&controller.GitHubTeamMembershipReconciler{ - Client: mgr.GetClient(), - APIReader: mgr.GetAPIReader(), - Scheme: mgr.GetScheme(), - GitHubClientFactory: githubClientFactory, - GitHubTokenProvider: githubTokenProvider, + Client: mgr.GetClient(), + APIReader: mgr.GetAPIReader(), + Scheme: mgr.GetScheme(), + GitHubClientFactory: githubClientFactory, + GitHubTokenProvider: githubTokenProvider, + DriftDetectionInterval: driftDetectionInterval, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "GitHubTeamMembership") os.Exit(1) } if err := (&controller.GitHubRepositoryReconciler{ - Client: mgr.GetClient(), - APIReader: mgr.GetAPIReader(), - Scheme: mgr.GetScheme(), - GitHubClientFactory: githubClientFactory, - GitHubTokenProvider: githubTokenProvider, + Client: mgr.GetClient(), + APIReader: mgr.GetAPIReader(), + Scheme: mgr.GetScheme(), + GitHubClientFactory: githubClientFactory, + GitHubTokenProvider: githubTokenProvider, + DriftDetectionInterval: driftDetectionInterval, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "GitHubRepository") os.Exit(1) } if err := (&controller.GitHubRepositoryRulesetReconciler{ - Client: mgr.GetClient(), - APIReader: mgr.GetAPIReader(), - Scheme: mgr.GetScheme(), - GitHubClientFactory: githubClientFactory, - GitHubTokenProvider: githubTokenProvider, + Client: mgr.GetClient(), + APIReader: mgr.GetAPIReader(), + Scheme: mgr.GetScheme(), + GitHubClientFactory: githubClientFactory, + GitHubTokenProvider: githubTokenProvider, + DriftDetectionInterval: driftDetectionInterval, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "GitHubRepositoryRuleset") os.Exit(1) } if err := (&controller.GitHubRepositoryTeamAccessReconciler{ - Client: mgr.GetClient(), - APIReader: mgr.GetAPIReader(), - Scheme: mgr.GetScheme(), - GitHubClientFactory: githubClientFactory, - GitHubTokenProvider: githubTokenProvider, + Client: mgr.GetClient(), + APIReader: mgr.GetAPIReader(), + Scheme: mgr.GetScheme(), + GitHubClientFactory: githubClientFactory, + GitHubTokenProvider: githubTokenProvider, + DriftDetectionInterval: driftDetectionInterval, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "GitHubRepositoryTeamAccess") os.Exit(1) } if err := (&controller.GitHubRepositoryCollaboratorReconciler{ - Client: mgr.GetClient(), - APIReader: mgr.GetAPIReader(), - Scheme: mgr.GetScheme(), - GitHubClientFactory: githubClientFactory, - GitHubTokenProvider: githubTokenProvider, + Client: mgr.GetClient(), + APIReader: mgr.GetAPIReader(), + Scheme: mgr.GetScheme(), + GitHubClientFactory: githubClientFactory, + GitHubTokenProvider: githubTokenProvider, + DriftDetectionInterval: driftDetectionInterval, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "GitHubRepositoryCollaborator") os.Exit(1) } if err := (&controller.GitHubEnvironmentReconciler{ - Client: mgr.GetClient(), - APIReader: mgr.GetAPIReader(), - Scheme: mgr.GetScheme(), - GitHubClientFactory: githubClientFactory, - GitHubTokenProvider: githubTokenProvider, + Client: mgr.GetClient(), + APIReader: mgr.GetAPIReader(), + Scheme: mgr.GetScheme(), + GitHubClientFactory: githubClientFactory, + GitHubTokenProvider: githubTokenProvider, + DriftDetectionInterval: driftDetectionInterval, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "GitHubEnvironment") os.Exit(1) } if err := (&controller.GitHubActionsSecretReconciler{ - Client: mgr.GetClient(), - APIReader: mgr.GetAPIReader(), - Scheme: mgr.GetScheme(), - GitHubClientFactory: githubClientFactory, - GitHubTokenProvider: githubTokenProvider, + Client: mgr.GetClient(), + APIReader: mgr.GetAPIReader(), + Scheme: mgr.GetScheme(), + GitHubClientFactory: githubClientFactory, + GitHubTokenProvider: githubTokenProvider, + DriftDetectionInterval: driftDetectionInterval, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "GitHubActionsSecret") os.Exit(1) } if err := (&controller.GitHubActionsVariableReconciler{ - Client: mgr.GetClient(), - APIReader: mgr.GetAPIReader(), - Scheme: mgr.GetScheme(), - GitHubClientFactory: githubClientFactory, - GitHubTokenProvider: githubTokenProvider, + Client: mgr.GetClient(), + APIReader: mgr.GetAPIReader(), + Scheme: mgr.GetScheme(), + GitHubClientFactory: githubClientFactory, + GitHubTokenProvider: githubTokenProvider, + DriftDetectionInterval: driftDetectionInterval, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "GitHubActionsVariable") os.Exit(1) diff --git a/internal/controller/githubactionssecret_controller.go b/internal/controller/githubactionssecret_controller.go index 4931639..d79d3cc 100644 --- a/internal/controller/githubactionssecret_controller.go +++ b/internal/controller/githubactionssecret_controller.go @@ -45,10 +45,11 @@ const ( // GitHubActionsSecretReconciler reconciles GitHubActionsSecret resources. type GitHubActionsSecretReconciler struct { client.Client - APIReader client.Reader - Scheme *runtime.Scheme - GitHubClientFactory githubclient.ActionsClientFactory - GitHubTokenProvider githubclient.TokenProvider + APIReader client.Reader + Scheme *runtime.Scheme + GitHubClientFactory githubclient.ActionsClientFactory + GitHubTokenProvider githubclient.TokenProvider + DriftDetectionInterval time.Duration } // +kubebuilder:rbac:groups=github.k8sready.com,resources=githubactionssecrets,verbs=get;list;watch;create;update;patch;delete @@ -167,7 +168,7 @@ func (r *GitHubActionsSecretReconciler) Reconcile( ); err != nil { return ctrl.Result{}, fmt.Errorf("update GitHubActionsSecret status: %w", err) } - return ctrl.Result{RequeueAfter: actionsRequeueInterval}, nil + return driftDetectionResult(r.DriftDetectionInterval), nil } func (r *GitHubActionsSecretReconciler) secretNeedsSynchronization( diff --git a/internal/controller/githubactionsvariable_controller.go b/internal/controller/githubactionsvariable_controller.go index aee897c..fbe3f2b 100644 --- a/internal/controller/githubactionsvariable_controller.go +++ b/internal/controller/githubactionsvariable_controller.go @@ -20,6 +20,7 @@ import ( "context" "errors" "fmt" + "time" corev1 "k8s.io/api/core/v1" apiequality "k8s.io/apimachinery/pkg/api/equality" @@ -44,10 +45,11 @@ const ( // GitHubActionsVariableReconciler reconciles GitHubActionsVariable resources. type GitHubActionsVariableReconciler struct { client.Client - APIReader client.Reader - Scheme *runtime.Scheme - GitHubClientFactory githubclient.ActionsClientFactory - GitHubTokenProvider githubclient.TokenProvider + APIReader client.Reader + Scheme *runtime.Scheme + GitHubClientFactory githubclient.ActionsClientFactory + GitHubTokenProvider githubclient.TokenProvider + DriftDetectionInterval time.Duration } // +kubebuilder:rbac:groups=github.k8sready.com,resources=githubactionsvariables,verbs=get;list;watch;create;update;patch;delete @@ -157,7 +159,7 @@ func (r *GitHubActionsVariableReconciler) Reconcile( ); err != nil { return ctrl.Result{}, fmt.Errorf("update GitHubActionsVariable status: %w", err) } - return ctrl.Result{RequeueAfter: actionsRequeueInterval}, nil + return driftDetectionResult(r.DriftDetectionInterval), nil } func variableNeedsUpdate( diff --git a/internal/controller/githubenvironment_controller.go b/internal/controller/githubenvironment_controller.go index 31ebb61..1867b7c 100644 --- a/internal/controller/githubenvironment_controller.go +++ b/internal/controller/githubenvironment_controller.go @@ -37,16 +37,16 @@ import ( const ( githubEnvironmentFinalizer = "github.k8sready.com/environment-finalizer" - actionsRequeueInterval = 5 * time.Minute ) // GitHubEnvironmentReconciler reconciles GitHubEnvironment resources. type GitHubEnvironmentReconciler struct { client.Client - APIReader client.Reader - Scheme *runtime.Scheme - GitHubClientFactory githubclient.ActionsClientFactory - GitHubTokenProvider githubclient.TokenProvider + APIReader client.Reader + Scheme *runtime.Scheme + GitHubClientFactory githubclient.ActionsClientFactory + GitHubTokenProvider githubclient.TokenProvider + DriftDetectionInterval time.Duration } // +kubebuilder:rbac:groups=github.k8sready.com,resources=githubenvironments,verbs=get;list;watch;create;update;patch;delete @@ -134,7 +134,7 @@ func (r *GitHubEnvironmentReconciler) Reconcile( return ctrl.Result{}, fmt.Errorf("update GitHubEnvironment status: %w", err) } - return ctrl.Result{RequeueAfter: actionsRequeueInterval}, nil + return driftDetectionResult(r.DriftDetectionInterval), nil } func (r *GitHubEnvironmentReconciler) reconcileDelete( @@ -167,7 +167,7 @@ func (r *GitHubEnvironmentReconciler) reconcileDelete( ); err != nil { return ctrl.Result{}, fmt.Errorf("update GitHubEnvironment deletion status: %w", err) } - return ctrl.Result{RequeueAfter: actionsRequeueInterval}, nil + return driftDetectionResult(r.DriftDetectionInterval), nil } resolved, err := resolveRepositoryActionsTarget( diff --git a/internal/controller/githuborganizationmember_controller.go b/internal/controller/githuborganizationmember_controller.go index fb81e33..3953c6f 100644 --- a/internal/controller/githuborganizationmember_controller.go +++ b/internal/controller/githuborganizationmember_controller.go @@ -20,6 +20,7 @@ import ( "context" "errors" "fmt" + "time" apiequality "k8s.io/apimachinery/pkg/api/equality" "k8s.io/apimachinery/pkg/api/meta" @@ -39,10 +40,11 @@ const githubOrganizationMemberFinalizer = "github.k8sready.com/organization-memb // GitHubOrganizationMemberReconciler reconciles direct organization membership. type GitHubOrganizationMemberReconciler struct { client.Client - APIReader client.Reader - Scheme *runtime.Scheme - GitHubClientFactory githubclient.OrganizationClientFactory - GitHubTokenProvider githubclient.TokenProvider + APIReader client.Reader + Scheme *runtime.Scheme + GitHubClientFactory githubclient.OrganizationClientFactory + GitHubTokenProvider githubclient.TokenProvider + DriftDetectionInterval time.Duration } // +kubebuilder:rbac:groups=github.k8sready.com,resources=githuborganizationmembers,verbs=get;list;watch;create;update;patch;delete @@ -145,7 +147,7 @@ func (r *GitHubOrganizationMemberReconciler) finish( if err := r.setReadyCondition(ctx, resource, resolved, membership, status, reason, message); err != nil { return ctrl.Result{}, fmt.Errorf("update GitHubOrganizationMember status: %w", err) } - return ctrl.Result{RequeueAfter: organizationRequeueAfter}, nil + return driftDetectionResult(r.DriftDetectionInterval), nil } func (r *GitHubOrganizationMemberReconciler) reconcileDelete( diff --git a/internal/controller/githubrepository_controller.go b/internal/controller/githubrepository_controller.go index 9451013..a6d04ca 100644 --- a/internal/controller/githubrepository_controller.go +++ b/internal/controller/githubrepository_controller.go @@ -38,18 +38,16 @@ import ( githubclient "github.com/pierinho13/github-platform-operator/internal/github" ) -const ( - githubRepositoryFinalizer = "github.k8sready.com/repository-finalizer" - requeueInterval = 5 * time.Minute -) +const githubRepositoryFinalizer = "github.k8sready.com/repository-finalizer" // GitHubRepositoryReconciler reconciles a GitHubRepository object. type GitHubRepositoryReconciler struct { client.Client - APIReader client.Reader - Scheme *runtime.Scheme - GitHubClientFactory githubclient.RepositoryClientFactory - GitHubTokenProvider githubclient.TokenProvider + APIReader client.Reader + Scheme *runtime.Scheme + GitHubClientFactory githubclient.RepositoryClientFactory + GitHubTokenProvider githubclient.TokenProvider + DriftDetectionInterval time.Duration } // +kubebuilder:rbac:groups=github.k8sready.com,resources=githubrepositories,verbs=get;list;watch;create;update;patch;delete @@ -161,7 +159,7 @@ func (r *GitHubRepositoryReconciler) Reconcile( return ctrl.Result{}, fmt.Errorf("update GitHubRepository status: %w", err) } - return ctrl.Result{RequeueAfter: requeueInterval}, nil + return driftDetectionResult(r.DriftDetectionInterval), nil } func (r *GitHubRepositoryReconciler) resolveProvider( diff --git a/internal/controller/githubrepository_controller_test.go b/internal/controller/githubrepository_controller_test.go index 4048fb8..2eb4d5e 100644 --- a/internal/controller/githubrepository_controller_test.go +++ b/internal/controller/githubrepository_controller_test.go @@ -729,7 +729,7 @@ var _ = Describe("GitHubRepository Controller", func() { result, err := controllerReconciler.Reconcile(ctx, request) Expect(err).NotTo(HaveOccurred()) - Expect(result.RequeueAfter).To(Equal(requeueInterval)) + Expect(result.RequeueAfter).To(Equal(DefaultDriftDetectionInterval)) Expect(fakeFactory.calls).To(Equal(0)) Expect(fakeGitHubClient.createCalls).To(Equal(0)) diff --git a/internal/controller/githubrepositorycollaborator_archived_test.go b/internal/controller/githubrepositorycollaborator_archived_test.go index a7e60d8..4b10a92 100644 --- a/internal/controller/githubrepositorycollaborator_archived_test.go +++ b/internal/controller/githubrepositorycollaborator_archived_test.go @@ -110,7 +110,7 @@ var _ = Describe("GitHubRepositoryCollaborator archived repository handling", fu By("observing the archived repository without attempting a write") result, err := reconciler.Reconcile(ctx, request) Expect(err).NotTo(HaveOccurred()) - Expect(result.RequeueAfter).To(Equal(repositoryAccessRequeueInterval)) + Expect(result.RequeueAfter).To(Equal(DefaultDriftDetectionInterval)) Expect(fakeClient.setCollaboratorCalls).To(Equal(0)) Expect(fakeClient.updateInvitationCalls).To(Equal(0)) diff --git a/internal/controller/githubrepositorycollaborator_controller.go b/internal/controller/githubrepositorycollaborator_controller.go index 9c76a9b..7b24d1a 100644 --- a/internal/controller/githubrepositorycollaborator_controller.go +++ b/internal/controller/githubrepositorycollaborator_controller.go @@ -20,6 +20,7 @@ import ( "context" "errors" "fmt" + "time" apiequality "k8s.io/apimachinery/pkg/api/equality" "k8s.io/apimachinery/pkg/api/meta" @@ -39,10 +40,11 @@ const githubRepositoryCollaboratorFinalizer = "github.k8sready.com/collaborator- // GitHubRepositoryCollaboratorReconciler reconciles direct user repository access. type GitHubRepositoryCollaboratorReconciler struct { client.Client - APIReader client.Reader - Scheme *runtime.Scheme - GitHubClientFactory githubclient.RepositoryAccessClientFactory - GitHubTokenProvider githubclient.TokenProvider + APIReader client.Reader + Scheme *runtime.Scheme + GitHubClientFactory githubclient.RepositoryAccessClientFactory + GitHubTokenProvider githubclient.TokenProvider + DriftDetectionInterval time.Duration } // +kubebuilder:rbac:groups=github.k8sready.com,resources=githubrepositorycollaborators,verbs=get;list;watch;create;update;patch;delete @@ -221,7 +223,7 @@ func (r *GitHubRepositoryCollaboratorReconciler) finishAccess( ); err != nil { return ctrl.Result{}, err } - return ctrl.Result{RequeueAfter: repositoryAccessRequeueInterval}, nil + return driftDetectionResult(r.DriftDetectionInterval), nil } func (r *GitHubRepositoryCollaboratorReconciler) pauseForArchivedRepository( @@ -255,7 +257,7 @@ func (r *GitHubRepositoryCollaboratorReconciler) pauseForArchivedRepository( return ctrl.Result{}, err } - return ctrl.Result{RequeueAfter: repositoryAccessRequeueInterval}, nil + return driftDetectionResult(r.DriftDetectionInterval), nil } func (r *GitHubRepositoryCollaboratorReconciler) reconcileDelete( @@ -319,7 +321,7 @@ func (r *GitHubRepositoryCollaboratorReconciler) reconcileDelete( "repository", resolved.Repository.Spec.Name, "username", collaborator.Spec.Username, ) - return ctrl.Result{RequeueAfter: repositoryAccessRequeueInterval}, nil + return driftDetectionResult(r.DriftDetectionInterval), nil } err = resolved.Client.RemoveCollaboratorAccess( @@ -337,7 +339,7 @@ func (r *GitHubRepositoryCollaboratorReconciler) reconcileDelete( "repository", resolved.Repository.Spec.Name, "username", collaborator.Spec.Username, ) - return ctrl.Result{RequeueAfter: repositoryAccessRequeueInterval}, nil + return driftDetectionResult(r.DriftDetectionInterval), nil } if result, ok := githubDeferredResult(err); ok { return result, nil diff --git a/internal/controller/githubrepositoryruleset_controller.go b/internal/controller/githubrepositoryruleset_controller.go index 173678a..1032c91 100644 --- a/internal/controller/githubrepositoryruleset_controller.go +++ b/internal/controller/githubrepositoryruleset_controller.go @@ -43,16 +43,16 @@ import ( const ( githubRepositoryRulesetFinalizer = "github.k8sready.com/repository-ruleset-finalizer" repositoryRulesetSourceType = "Repository" - rulesetRequeueInterval = 5 * time.Minute ) // GitHubRepositoryRulesetReconciler reconciles repository rulesets. type GitHubRepositoryRulesetReconciler struct { client.Client - APIReader client.Reader - Scheme *runtime.Scheme - GitHubClientFactory githubclient.RepositoryRulesetClientFactory - GitHubTokenProvider githubclient.TokenProvider + APIReader client.Reader + Scheme *runtime.Scheme + GitHubClientFactory githubclient.RepositoryRulesetClientFactory + GitHubTokenProvider githubclient.TokenProvider + DriftDetectionInterval time.Duration } // +kubebuilder:rbac:groups=github.k8sready.com,resources=githubrepositoryrulesets,verbs=get;list;watch;create;update;patch;delete @@ -154,7 +154,7 @@ func (r *GitHubRepositoryRulesetReconciler) Reconcile( return ctrl.Result{}, fmt.Errorf("update GitHubRepositoryRuleset status: %w", err) } - return ctrl.Result{RequeueAfter: rulesetRequeueInterval}, nil + return driftDetectionResult(r.DriftDetectionInterval), nil } type resolvedRepositoryRuleset struct { diff --git a/internal/controller/githubrepositoryteamaccess_controller.go b/internal/controller/githubrepositoryteamaccess_controller.go index 3ce2ad0..b31627d 100644 --- a/internal/controller/githubrepositoryteamaccess_controller.go +++ b/internal/controller/githubrepositoryteamaccess_controller.go @@ -37,16 +37,16 @@ import ( const ( githubRepositoryTeamAccessFinalizer = "github.k8sready.com/team-access-finalizer" - repositoryAccessRequeueInterval = 5 * time.Minute ) // GitHubRepositoryTeamAccessReconciler reconciles GitHubRepositoryTeamAccess resources. type GitHubRepositoryTeamAccessReconciler struct { client.Client - APIReader client.Reader - Scheme *runtime.Scheme - GitHubClientFactory githubclient.RepositoryAccessClientFactory - GitHubTokenProvider githubclient.TokenProvider + APIReader client.Reader + Scheme *runtime.Scheme + GitHubClientFactory githubclient.RepositoryAccessClientFactory + GitHubTokenProvider githubclient.TokenProvider + DriftDetectionInterval time.Duration } // +kubebuilder:rbac:groups=github.k8sready.com,resources=githubrepositoryteamaccesses,verbs=get;list;watch;create;update;patch;delete @@ -243,7 +243,7 @@ func (r *GitHubRepositoryTeamAccessReconciler) succeed( ); err != nil { return ctrl.Result{}, err } - return ctrl.Result{RequeueAfter: repositoryAccessRequeueInterval}, nil + return driftDetectionResult(r.DriftDetectionInterval), nil } func (r *GitHubRepositoryTeamAccessReconciler) fail( diff --git a/internal/controller/githubteam_controller.go b/internal/controller/githubteam_controller.go index b144ecf..d4a69ac 100644 --- a/internal/controller/githubteam_controller.go +++ b/internal/controller/githubteam_controller.go @@ -35,18 +35,16 @@ import ( githubclient "github.com/pierinho13/github-platform-operator/internal/github" ) -const ( - githubTeamFinalizer = "github.k8sready.com/team-finalizer" - organizationRequeueAfter = 5 * time.Minute -) +const githubTeamFinalizer = "github.k8sready.com/team-finalizer" // GitHubTeamReconciler reconciles organization teams. type GitHubTeamReconciler struct { client.Client - APIReader client.Reader - Scheme *runtime.Scheme - GitHubClientFactory githubclient.OrganizationClientFactory - GitHubTokenProvider githubclient.TokenProvider + APIReader client.Reader + Scheme *runtime.Scheme + GitHubClientFactory githubclient.OrganizationClientFactory + GitHubTokenProvider githubclient.TokenProvider + DriftDetectionInterval time.Duration } // +kubebuilder:rbac:groups=github.k8sready.com,resources=githubteams,verbs=get;list;watch;create;update;patch;delete @@ -164,7 +162,7 @@ func (r *GitHubTeamReconciler) Reconcile( ); err != nil { return ctrl.Result{}, fmt.Errorf("update GitHubTeam status: %w", err) } - return ctrl.Result{RequeueAfter: organizationRequeueAfter}, nil + return driftDetectionResult(r.DriftDetectionInterval), nil } func desiredTeamUpdate( @@ -212,7 +210,7 @@ func (r *GitHubTeamReconciler) reconcileDelete( ); err != nil { return ctrl.Result{}, fmt.Errorf("update GitHubTeam deletion status: %w", err) } - return ctrl.Result{RequeueAfter: organizationRequeueAfter}, nil + return driftDetectionResult(r.DriftDetectionInterval), nil } resolved, err := resolveOrganization( diff --git a/internal/controller/githubteammembership_controller.go b/internal/controller/githubteammembership_controller.go index 4269a0f..7210fe7 100644 --- a/internal/controller/githubteammembership_controller.go +++ b/internal/controller/githubteammembership_controller.go @@ -20,6 +20,7 @@ import ( "context" "errors" "fmt" + "time" apiequality "k8s.io/apimachinery/pkg/api/equality" "k8s.io/apimachinery/pkg/api/meta" @@ -46,10 +47,11 @@ type resolvedTeamMembership struct { // GitHubTeamMembershipReconciler reconciles membership in managed teams. type GitHubTeamMembershipReconciler struct { client.Client - APIReader client.Reader - Scheme *runtime.Scheme - GitHubClientFactory githubclient.OrganizationClientFactory - GitHubTokenProvider githubclient.TokenProvider + APIReader client.Reader + Scheme *runtime.Scheme + GitHubClientFactory githubclient.OrganizationClientFactory + GitHubTokenProvider githubclient.TokenProvider + DriftDetectionInterval time.Duration } // +kubebuilder:rbac:groups=github.k8sready.com,resources=githubteammemberships,verbs=get;list;watch;create;update;patch;delete @@ -215,7 +217,7 @@ func (r *GitHubTeamMembershipReconciler) finish( if err := r.setReadyCondition(ctx, resource, resolved, membership, status, reason, message); err != nil { return ctrl.Result{}, fmt.Errorf("update GitHubTeamMembership status: %w", err) } - return ctrl.Result{RequeueAfter: organizationRequeueAfter}, nil + return driftDetectionResult(r.DriftDetectionInterval), nil } func (r *GitHubTeamMembershipReconciler) reconcileDelete( diff --git a/internal/controller/reconciliation.go b/internal/controller/reconciliation.go new file mode 100644 index 0000000..246bf57 --- /dev/null +++ b/internal/controller/reconciliation.go @@ -0,0 +1,64 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "fmt" + "time" + + ctrl "sigs.k8s.io/controller-runtime" +) + +const ( + // DefaultDriftDetectionInterval is the periodic interval used to detect + // changes made directly in GitHub when no Kubernetes event triggers a + // reconciliation first. + DefaultDriftDetectionInterval = 5 * time.Minute + + // MinDriftDetectionInterval prevents accidentally creating a tight polling + // loop against the GitHub API. + MinDriftDetectionInterval = time.Minute + + // MaxDriftDetectionInterval keeps drift detection bounded while still + // allowing large installations to reduce GitHub API traffic substantially. + MaxDriftDetectionInterval = 24 * time.Hour +) + +// ValidateDriftDetectionInterval validates the operator-wide periodic GitHub +// drift-detection interval. Event-driven reconciliations for watched Kubernetes resources are unaffected. +func ValidateDriftDetectionInterval(interval time.Duration) error { + if interval < MinDriftDetectionInterval || interval > MaxDriftDetectionInterval { + return fmt.Errorf( + "drift detection interval must be between %s and %s, got %s", + MinDriftDetectionInterval, + MaxDriftDetectionInterval, + interval, + ) + } + return nil +} + +func effectiveDriftDetectionInterval(interval time.Duration) time.Duration { + if interval == 0 { + return DefaultDriftDetectionInterval + } + return interval +} + +func driftDetectionResult(interval time.Duration) ctrl.Result { + return ctrl.Result{RequeueAfter: effectiveDriftDetectionInterval(interval)} +} diff --git a/internal/controller/reconciliation_contract_test.go b/internal/controller/reconciliation_contract_test.go new file mode 100644 index 0000000..7a2f31d --- /dev/null +++ b/internal/controller/reconciliation_contract_test.go @@ -0,0 +1,56 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "testing" + "time" +) + +func TestDriftDetectionIntervalContract(t *testing.T) { + t.Parallel() + + if got := effectiveDriftDetectionInterval(0); got != DefaultDriftDetectionInterval { + t.Fatalf("zero interval resolved to %s, want %s", got, DefaultDriftDetectionInterval) + } + + custom := 17 * time.Minute + if got := effectiveDriftDetectionInterval(custom); got != custom { + t.Fatalf("custom interval resolved to %s, want %s", got, custom) + } + + valid := []time.Duration{ + MinDriftDetectionInterval, + DefaultDriftDetectionInterval, + MaxDriftDetectionInterval, + } + for _, interval := range valid { + if err := ValidateDriftDetectionInterval(interval); err != nil { + t.Errorf("expected %s to be valid: %v", interval, err) + } + } + + invalid := []time.Duration{ + MinDriftDetectionInterval - time.Second, + MaxDriftDetectionInterval + time.Second, + } + for _, interval := range invalid { + if err := ValidateDriftDetectionInterval(interval); err == nil { + t.Errorf("expected %s to be rejected", interval) + } + } +}