diff --git a/PROJECT b/PROJECT index 6229f925d..3d8709f0b 100644 --- a/PROJECT +++ b/PROJECT @@ -106,4 +106,13 @@ resources: kind: TimedCommitStatus path: github.com/argoproj-labs/gitops-promoter/api/v1alpha1 version: v1alpha1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: argoproj.io + group: promoter + kind: GitCommitStatus + path: github.com/argoproj-labs/gitops-promoter/api/v1alpha1 + version: v1alpha1 version: "3" diff --git a/api/v1alpha1/argocdcommitstatus_types.go b/api/v1alpha1/argocdcommitstatus_types.go index a3772f56b..5468b2d61 100644 --- a/api/v1alpha1/argocdcommitstatus_types.go +++ b/api/v1alpha1/argocdcommitstatus_types.go @@ -131,7 +131,7 @@ type ApplicationsSelected struct { // +kubebuilder:subresource:status // ArgoCDCommitStatus is the Schema for the argocdcommitstatuses API. -// +kubebuilder:printcolumn:name="Strategy",type=string,JSONPath=`.spec.promotionStrategyRef.name`,priority=1 +// +kubebuilder:printcolumn:name="PromotionStrategy",type=string,JSONPath=`.spec.promotionStrategyRef.name`,priority=1 // +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status` type ArgoCDCommitStatus struct { metav1.TypeMeta `json:",inline"` diff --git a/api/v1alpha1/constants.go b/api/v1alpha1/constants.go index 22fd8bd5f..a8856f264 100644 --- a/api/v1alpha1/constants.go +++ b/api/v1alpha1/constants.go @@ -22,9 +22,6 @@ const TimedCommitStatusLabel = "promoter.argoproj.io/timed-commit-status" // PreviousEnvironmentCommitStatusKey the commit status key name used to indicate the previous environment health const PreviousEnvironmentCommitStatusKey = "promoter-previous-environment" -// ReconcileAtAnnotation is the annotation used to indicate when the webhook triggered a reconcile -const ReconcileAtAnnotation = "promoter.argoproj.io/reconcile-at" - // CommitStatusPreviousEnvironmentStatusesAnnotation is the label used to identify commit statuses that make up the aggregated active commit status const CommitStatusPreviousEnvironmentStatusesAnnotation = "promoter.argoproj.io/previous-environment-statuses" diff --git a/api/v1alpha1/controllerconfiguration_types.go b/api/v1alpha1/controllerconfiguration_types.go index 25b55fba2..d2d7c3bf3 100644 --- a/api/v1alpha1/controllerconfiguration_types.go +++ b/api/v1alpha1/controllerconfiguration_types.go @@ -59,6 +59,11 @@ type ControllerConfigurationSpec struct { // including WorkQueue settings that control reconciliation behavior. // +required TimedCommitStatus TimedCommitStatusConfiguration `json:"timedCommitStatus"` + + // GitCommitStatus contains the configuration for the GitCommitStatus controller, + // including WorkQueue settings that control reconciliation behavior. + // +required + GitCommitStatus GitCommitStatusConfiguration `json:"gitCommitStatus"` } // PromotionStrategyConfiguration defines the configuration for the PromotionStrategy controller. @@ -140,6 +145,17 @@ type TimedCommitStatusConfiguration struct { WorkQueue WorkQueue `json:"workQueue"` } +// GitCommitStatusConfiguration defines the configuration for the GitCommitStatus controller. +// +// This configuration controls how the GitCommitStatus controller processes reconciliation +// requests, including requeue intervals, concurrency limits, and rate limiting behavior. +type GitCommitStatusConfiguration struct { + // WorkQueue contains the work queue configuration for the GitCommitStatus controller. + // This includes requeue duration, maximum concurrent reconciles, and rate limiter settings. + // +required + WorkQueue WorkQueue `json:"workQueue"` +} + // WorkQueue defines the work queue configuration for a controller. // // This configuration directly correlates to parameters used with Kubernetes client-go work queues. diff --git a/api/v1alpha1/gitcommitstatus_types.go b/api/v1alpha1/gitcommitstatus_types.go new file mode 100644 index 000000000..8dfa6ed38 --- /dev/null +++ b/api/v1alpha1/gitcommitstatus_types.go @@ -0,0 +1,229 @@ +/* +Copyright 2024. + +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 v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! +// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. + +// GitCommitStatusSpec defines the desired state of GitCommitStatus +type GitCommitStatusSpec struct { + // PromotionStrategyRef is a reference to the promotion strategy that this commit status applies to. + // The controller will validate commits from ALL environments in the referenced PromotionStrategy + // where this GitCommitStatus.Spec.Key matches an entry in either: + // - PromotionStrategy.Spec.ProposedCommitStatuses (applies to all environments), OR + // - Environment.ProposedCommitStatuses (applies to specific environment) + // +required + PromotionStrategyRef ObjectReference `json:"promotionStrategyRef"` + + // Key is the unique identifier for this validation rule. + // It is used as the commit status key and in status messages. + // This key is matched against PromotionStrategy's proposedCommitStatuses or activeCommitStatuses + // to determine which environments this validation applies to. + // +required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=63 + // +kubebuilder:validation:Pattern=^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + Key string `json:"key"` + + // Description is a human-readable description of this validation that will be shown in the SCM provider + // (GitHub, GitLab, etc.) as the commit status description. + // If not specified, defaults to empty string. + // +optional + Description string `json:"description,omitempty"` + + // Target specifies which commit SHA to validate with the expression. + // - "active": Validates the currently active/deployed commit (default behavior) + // - "proposed": Validates the proposed commit that will be promoted + // + // The validation result is always reported on the PROPOSED commit (for gating), but this field + // controls which commit's data is used in the expression evaluation. + // + // Examples: + // target: "active" - "Don't promote if a revert commit is detected" + // target: "proposed" - "Don't promote unless new commit follows naming convention" + // + // +optional + // +kubebuilder:default="active" + // +kubebuilder:validation:Enum=active;proposed + Target string `json:"target,omitempty"` + + // Expression is evaluated using the expr library (github.com/expr-lang/expr) against commit data + // for environments in the referenced PromotionStrategy. + // The expression must return a boolean value where true indicates the validation passed. + // + // The commit validated is determined by the Target field: + // - "active" (default): Validates the ACTIVE (currently deployed) commit + // - "proposed": Validates the PROPOSED commit (what will be promoted) + // + // The validation result is always reported on the PROPOSED commit to enable promotion gating. + // + // Use Cases by Mode: + // Active mode: State-based gating - validate current environment before allowing promotion + // - "Don't promote until active commit has required sign-offs" + // - "Verify current deployment meets compliance before next promotion" + // - "Ensure active commit is not a revert before promoting" + // + // Proposed mode: Change-based gating - validate the incoming change itself + // - "Don't promote unless new commit follows naming convention" + // - "Ensure proposed commit has proper JIRA ticket reference" + // - "Require specific author for proposed changes" + // + // + // Available variables in the expression context: + // - Commit.SHA (string): the commit SHA being validated (active or proposed based on Target) + // - Commit.Subject (string): the first line of the commit message + // - Commit.Body (string): the commit message body (everything after the subject line) + // - Commit.Author (string): commit author email address + // - Commit.Committer (string): committer email address + // - Commit.Trailers (map[string][]string): git trailers parsed from commit message + // + // +required + Expression string `json:"expression"` +} + +// GitCommitStatusStatus defines the observed state of GitCommitStatus. +type GitCommitStatusStatus struct { + // Environments holds the validation results for each environment where this validation applies. + // Each entry corresponds to an environment from the PromotionStrategy where the Key matches + // either global or environment-specific proposedCommitStatuses. + // + // The controller validates the commit specified by Target ("active" or "proposed") + // but the CommitStatus is always reported on the PROPOSED commit for promotion gating. + // Each environment entry tracks both the ProposedHydratedSha (where status is reported) and the + // ActiveHydratedSha, with TargetedSha indicating which one was actually evaluated. + // +listType=map + // +listMapKey=branch + // +optional + Environments []GitCommitStatusEnvironmentStatus `json:"environments,omitempty"` + + // Conditions represent the latest available observations of the GitCommitStatus's state. + // Standard condition types include "Ready" which aggregates the status of all environments. + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// GitCommitStatusEnvironmentStatus defines the observed validation status for a specific environment. +type GitCommitStatusEnvironmentStatus struct { + // Branch is the environment branch name being validated. + // +required + Branch string `json:"branch"` + + // ProposedHydratedSha is the proposed hydrated commit SHA where the validation result is reported. + // This comes from the PromotionStrategy's environment status. + // The CommitStatus resource is created with this SHA, allowing the PromotionStrategy to gate + // promotions based on the validation of the ACTIVE commit. + // May be empty if the PromotionStrategy hasn't reconciled yet. + // +required + ProposedHydratedSha string `json:"proposedHydratedSha"` + + // ActiveHydratedSha is the currently active (deployed) hydrated commit SHA that was validated. + // This comes from the PromotionStrategy's environment status. + // The expression is evaluated against THIS commit's data, not the proposed commit. + // May be empty if the PromotionStrategy hasn't reconciled yet. + // +optional + ActiveHydratedSha string `json:"activeHydratedSha,omitempty"` + + // TargetedSha is the commit SHA that was actually validated by the expression. + // This will match either ProposedHydratedSha or ActiveHydratedSha depending on + // the Target setting ("proposed" or "active"). + // This field clarifies which commit's data was used in the expression evaluation. + // +optional + TargetedSha string `json:"targetedSha,omitempty"` + + // Phase represents the current validation state of the commit. + // - "pending": validation has not completed, commit data is not yet available, or SHAs are empty + // - "success": expression evaluated to true, validation passed + // - "failure": expression evaluated to false, validation failed, or expression compilation failed + // +kubebuilder:validation:Enum=pending;success;failure + // +required + Phase string `json:"phase"` + + // ExpressionResult contains the boolean result of the expression evaluation. + // Only set when the expression successfully evaluates to a boolean. + // nil indicates the expression has not yet been evaluated, failed to compile, or failed to evaluate. + // +optional + ExpressionResult *bool `json:"expressionResult,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Key",type=string,JSONPath=`.spec.key` +// +kubebuilder:printcolumn:name="PromotionStrategy",type=string,JSONPath=`.spec.promotionStrategyRef.name` +// +kubebuilder:printcolumn:name="Validates",type=string,JSONPath=`.spec.target` +// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status` + +// GitCommitStatus is the Schema for the gitcommitstatuses API. +// +// It validates commits from PromotionStrategy environments using configurable expressions +// and creates CommitStatus resources with the validation results. +// +// Use the Target field to control which commit is validated: +// - "active" (default): Validates the currently deployed commit +// - "proposed": Validates the incoming commit +// +// The validation result is always reported on the PROPOSED commit to enable promotion gating, +// regardless of which commit was validated. +// +// Workflow: +// 1. Controller reads PromotionStrategy to get ProposedHydratedSha and ActiveHydratedSha +// 2. Controller selects SHA to validate based on Target field +// 3. Controller fetches commit data (subject, body, author, trailers) for selected SHA +// 4. Controller evaluates expression against selected commit data +// 5. Controller creates/updates CommitStatus with result attached to PROPOSED SHA +// 6. PromotionStrategy checks CommitStatus on PROPOSED SHA before allowing promotion +// +// Common use cases: +// - "Ensure active commit is not a revert before promoting" +type GitCommitStatus struct { + metav1.TypeMeta `json:",inline"` + + // metadata is a standard object metadata + // +optional + metav1.ObjectMeta `json:"metadata,omitempty"` + + // spec defines the desired state of GitCommitStatus + // +required + Spec GitCommitStatusSpec `json:"spec"` + + // status defines the observed state of GitCommitStatus + // +optional + Status GitCommitStatusStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// GitCommitStatusList contains a list of GitCommitStatus +type GitCommitStatusList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []GitCommitStatus `json:"items"` +} + +// GetConditions returns the conditions of the GitCommitStatus. +func (g *GitCommitStatus) GetConditions() *[]metav1.Condition { + return &g.Status.Conditions +} + +func init() { + SchemeBuilder.Register(&GitCommitStatus{}, &GitCommitStatusList{}) +} diff --git a/api/v1alpha1/pullrequest_types.go b/api/v1alpha1/pullrequest_types.go index d38d12c85..8fff91eda 100644 --- a/api/v1alpha1/pullrequest_types.go +++ b/api/v1alpha1/pullrequest_types.go @@ -102,7 +102,7 @@ func (ps *PullRequest) GetConditions() *[]metav1.Condition { // PullRequest is the Schema for the pullrequests API // +kubebuilder:printcolumn:name="State",type=string,JSONPath=`.status.state` -// +kubebuilder:printcolumn:name="ID",type=string,JSONPath=`.status.ID` +// +kubebuilder:printcolumn:name="ID",type=string,JSONPath=`.status.id` // +kubebuilder:printcolumn:name="Source",type=string,JSONPath=`.spec.sourceBranch`,priority=1 // +kubebuilder:printcolumn:name="Target",type=string,JSONPath=`.spec.targetBranch`,priority=1 // +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status` diff --git a/api/v1alpha1/timedcommitstatus_types.go b/api/v1alpha1/timedcommitstatus_types.go index a7d4682be..b73808c1a 100644 --- a/api/v1alpha1/timedcommitstatus_types.go +++ b/api/v1alpha1/timedcommitstatus_types.go @@ -102,7 +102,7 @@ type TimedCommitStatusEnvironmentsStatus struct { // +kubebuilder:subresource:status // TimedCommitStatus is the Schema for the timedcommitstatuses API -// +kubebuilder:printcolumn:name="Strategy",type=string,JSONPath=`.spec.promotionStrategyRef.name` +// +kubebuilder:printcolumn:name="PromotionStrategy",type=string,JSONPath=`.spec.promotionStrategyRef.name` // +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status` type TimedCommitStatus struct { metav1.TypeMeta `json:",inline"` diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index c3f0f7e86..f4a3232c6 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -733,6 +733,7 @@ func (in *ControllerConfigurationSpec) DeepCopyInto(out *ControllerConfiguration in.CommitStatus.DeepCopyInto(&out.CommitStatus) in.ArgoCDCommitStatus.DeepCopyInto(&out.ArgoCDCommitStatus) in.TimedCommitStatus.DeepCopyInto(&out.TimedCommitStatus) + in.GitCommitStatus.DeepCopyInto(&out.GitCommitStatus) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ControllerConfigurationSpec. @@ -920,6 +921,146 @@ func (in *ForgejoRepo) DeepCopy() *ForgejoRepo { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GitCommitStatus) DeepCopyInto(out *GitCommitStatus) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitCommitStatus. +func (in *GitCommitStatus) DeepCopy() *GitCommitStatus { + if in == nil { + return nil + } + out := new(GitCommitStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *GitCommitStatus) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GitCommitStatusConfiguration) DeepCopyInto(out *GitCommitStatusConfiguration) { + *out = *in + in.WorkQueue.DeepCopyInto(&out.WorkQueue) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitCommitStatusConfiguration. +func (in *GitCommitStatusConfiguration) DeepCopy() *GitCommitStatusConfiguration { + if in == nil { + return nil + } + out := new(GitCommitStatusConfiguration) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GitCommitStatusEnvironmentStatus) DeepCopyInto(out *GitCommitStatusEnvironmentStatus) { + *out = *in + if in.ExpressionResult != nil { + in, out := &in.ExpressionResult, &out.ExpressionResult + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitCommitStatusEnvironmentStatus. +func (in *GitCommitStatusEnvironmentStatus) DeepCopy() *GitCommitStatusEnvironmentStatus { + if in == nil { + return nil + } + out := new(GitCommitStatusEnvironmentStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GitCommitStatusList) DeepCopyInto(out *GitCommitStatusList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]GitCommitStatus, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitCommitStatusList. +func (in *GitCommitStatusList) DeepCopy() *GitCommitStatusList { + if in == nil { + return nil + } + out := new(GitCommitStatusList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *GitCommitStatusList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GitCommitStatusSpec) DeepCopyInto(out *GitCommitStatusSpec) { + *out = *in + out.PromotionStrategyRef = in.PromotionStrategyRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitCommitStatusSpec. +func (in *GitCommitStatusSpec) DeepCopy() *GitCommitStatusSpec { + if in == nil { + return nil + } + out := new(GitCommitStatusSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GitCommitStatusStatus) DeepCopyInto(out *GitCommitStatusStatus) { + *out = *in + if in.Environments != nil { + in, out := &in.Environments, &out.Environments + *out = make([]GitCommitStatusEnvironmentStatus, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitCommitStatusStatus. +func (in *GitCommitStatusStatus) DeepCopy() *GitCommitStatusStatus { + if in == nil { + return nil + } + out := new(GitCommitStatusStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GitHub) DeepCopyInto(out *GitHub) { *out = *in diff --git a/cmd/main.go b/cmd/main.go index d63d24b7f..83175d7d5 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -292,6 +292,16 @@ func runController( }).SetupWithManager(processSignalsCtx, localManager); err != nil { panic("unable to create TimedCommitStatus controller") } + if err := (&controller.GitCommitStatusReconciler{ + Client: localManager.GetClient(), + Scheme: localManager.GetScheme(), + Recorder: localManager.GetEventRecorderFor("GitCommitStatus"), + SettingsMgr: settingsMgr, + EnqueueCTP: ctpReconciler.GetEnqueueFunc(), + }).SetupWithManager(processSignalsCtx, localManager); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "GitCommitStatus") + panic(fmt.Errorf("unable to create GitCommitStatus controller: %w", err)) + } //+kubebuilder:scaffold:builder if err := localManager.AddHealthzCheck("healthz", healthz.Ping); err != nil { diff --git a/config/config/controllerconfiguration.yaml b/config/config/controllerconfiguration.yaml index c4b6558c1..6b6af4f90 100644 --- a/config/config/controllerconfiguration.yaml +++ b/config/config/controllerconfiguration.yaml @@ -89,3 +89,16 @@ spec: fastDelay: "1s" slowDelay: "5m" maxFastAttempts: 3 + gitCommitStatus: + workQueue: + maxConcurrentReconciles: 10 + requeueDuration: "5m" + rateLimiter: + maxOf: + - bucket: + qps: 10 + bucket: 100 + - fastSlow: + fastDelay: "1s" + slowDelay: "5m" + maxFastAttempts: 3 diff --git a/config/crd/bases/promoter.argoproj.io_argocdcommitstatuses.yaml b/config/crd/bases/promoter.argoproj.io_argocdcommitstatuses.yaml index 6a75b3c68..b10008457 100644 --- a/config/crd/bases/promoter.argoproj.io_argocdcommitstatuses.yaml +++ b/config/crd/bases/promoter.argoproj.io_argocdcommitstatuses.yaml @@ -16,7 +16,7 @@ spec: versions: - additionalPrinterColumns: - jsonPath: .spec.promotionStrategyRef.name - name: Strategy + name: PromotionStrategy priority: 1 type: string - jsonPath: .status.conditions[?(@.type=="Ready")].status diff --git a/config/crd/bases/promoter.argoproj.io_controllerconfigurations.yaml b/config/crd/bases/promoter.argoproj.io_controllerconfigurations.yaml index d430a13c0..edcb10181 100644 --- a/config/crd/bases/promoter.argoproj.io_controllerconfigurations.yaml +++ b/config/crd/bases/promoter.argoproj.io_controllerconfigurations.yaml @@ -661,6 +661,208 @@ spec: required: - workQueue type: object + gitCommitStatus: + description: |- + GitCommitStatus contains the configuration for the GitCommitStatus controller, + including WorkQueue settings that control reconciliation behavior. + properties: + workQueue: + description: |- + WorkQueue contains the work queue configuration for the GitCommitStatus controller. + This includes requeue duration, maximum concurrent reconciles, and rate limiter settings. + properties: + maxConcurrentReconciles: + description: |- + MaxConcurrentReconciles defines the maximum number of concurrent reconcile operations + that can run for this controller. Higher values increase throughput but consume more + resources. Must be at least 1. + type: integer + rateLimiter: + description: |- + RateLimiter defines the rate limiting strategy for the controller's work queue. + Rate limiting controls how quickly failed reconciliations are retried and helps + prevent overwhelming external APIs or systems. + properties: + bucket: + description: |- + Bucket rate limiter uses a token bucket algorithm to control request rate. + Allows bursts while maintaining an average rate limit. + properties: + bucket: + description: |- + Bucket is the maximum number of tokens that can be accumulated in the bucket. + This defines the maximum burst size - how many operations can occur in rapid + succession before rate limiting takes effect. Must be non-negative. + type: integer + qps: + description: |- + Qps (queries per second) is the rate at which tokens are added to the bucket. + This defines the sustained rate limit for operations. Must be non-negative. + type: integer + required: + - bucket + - qps + type: object + exponentialFailure: + description: |- + ExponentialFailure rate limiter increases delay exponentially with each failure. + Standard approach for backing off when operations fail repeatedly. + properties: + baseDelay: + description: |- + BaseDelay is the initial delay after the first failure. Subsequent failures will exponentially + increase this delay (2x, 4x, 8x, etc.) until MaxDelay is reached. + Format follows Go's time.Duration syntax (e.g., "1s" for 1 second). + type: string + maxDelay: + description: |- + MaxDelay is the maximum delay between retry attempts. Once the exponential backoff reaches + this value, all subsequent retries will use this delay. + Format follows Go's time.Duration syntax (e.g., "1m" for 1 minute). + type: string + required: + - baseDelay + - maxDelay + type: object + fastSlow: + description: |- + FastSlow rate limiter provides fast retries initially, then switches to slow retries. + Useful for quickly retrying transient errors while backing off for persistent failures. + properties: + fastDelay: + description: |- + FastDelay is the delay used for the first MaxFastAttempts retry attempts. + Format follows Go's time.Duration syntax (e.g., "100ms" for 100 milliseconds). + type: string + maxFastAttempts: + description: |- + MaxFastAttempts is the number of retry attempts that use FastDelay before switching to SlowDelay. + Must be at least 1. + type: integer + slowDelay: + description: |- + SlowDelay is the delay used for retry attempts after MaxFastAttempts have been exhausted. + Format follows Go's time.Duration syntax (e.g., "10s" for 10 seconds). + type: string + required: + - fastDelay + - maxFastAttempts + - slowDelay + type: object + maxOf: + description: |- + MaxOf allows combining multiple rate limiters, where the maximum delay from all + limiters is used. This enables sophisticated rate limiting that respects multiple + constraints simultaneously (e.g., both per-item exponential backoff and global rate limits). + items: + description: |- + RateLimiterTypes defines the different algorithms available for rate limiting. + + Exactly one of the three rate limiter types must be specified: + - FastSlow: Quick retry for transient errors, then slower retry for persistent failures + - ExponentialFailure: Standard exponential backoff for repeated failures + - Bucket: Token bucket algorithm for controlling overall request rate + + See https://pkg.go.dev/k8s.io/client-go/util/workqueue for implementation details. + properties: + bucket: + description: |- + Bucket rate limiter uses a token bucket algorithm to control request rate. + Allows bursts while maintaining an average rate limit. + properties: + bucket: + description: |- + Bucket is the maximum number of tokens that can be accumulated in the bucket. + This defines the maximum burst size - how many operations can occur in rapid + succession before rate limiting takes effect. Must be non-negative. + type: integer + qps: + description: |- + Qps (queries per second) is the rate at which tokens are added to the bucket. + This defines the sustained rate limit for operations. Must be non-negative. + type: integer + required: + - bucket + - qps + type: object + exponentialFailure: + description: |- + ExponentialFailure rate limiter increases delay exponentially with each failure. + Standard approach for backing off when operations fail repeatedly. + properties: + baseDelay: + description: |- + BaseDelay is the initial delay after the first failure. Subsequent failures will exponentially + increase this delay (2x, 4x, 8x, etc.) until MaxDelay is reached. + Format follows Go's time.Duration syntax (e.g., "1s" for 1 second). + type: string + maxDelay: + description: |- + MaxDelay is the maximum delay between retry attempts. Once the exponential backoff reaches + this value, all subsequent retries will use this delay. + Format follows Go's time.Duration syntax (e.g., "1m" for 1 minute). + type: string + required: + - baseDelay + - maxDelay + type: object + fastSlow: + description: |- + FastSlow rate limiter provides fast retries initially, then switches to slow retries. + Useful for quickly retrying transient errors while backing off for persistent failures. + properties: + fastDelay: + description: |- + FastDelay is the delay used for the first MaxFastAttempts retry attempts. + Format follows Go's time.Duration syntax (e.g., "100ms" for 100 milliseconds). + type: string + maxFastAttempts: + description: |- + MaxFastAttempts is the number of retry attempts that use FastDelay before switching to SlowDelay. + Must be at least 1. + type: integer + slowDelay: + description: |- + SlowDelay is the delay used for retry attempts after MaxFastAttempts have been exhausted. + Format follows Go's time.Duration syntax (e.g., "10s" for 10 seconds). + type: string + required: + - fastDelay + - maxFastAttempts + - slowDelay + type: object + type: object + x-kubernetes-validations: + - message: at most one of the fields in [fastSlow exponentialFailure + bucket] may be set + rule: '[has(self.fastSlow),has(self.exponentialFailure),has(self.bucket)].filter(x,x==true).size() + <= 1' + maxItems: 3 + type: array + type: object + x-kubernetes-validations: + - message: at most one of the fields in [fastSlow exponentialFailure + bucket maxOf] may be set + rule: '[has(self.fastSlow),has(self.exponentialFailure),has(self.bucket),has(self.maxOf)].filter(x,x==true).size() + <= 1' + - message: at most one of the fields in [fastSlow exponentialFailure + bucket] may be set + rule: '[has(self.fastSlow),has(self.exponentialFailure),has(self.bucket)].filter(x,x==true).size() + <= 1' + requeueDuration: + description: |- + RequeueDuration specifies how frequently resources should be requeued for automatic reconciliation. + This creates a periodic reconciliation loop that ensures the desired state is maintained even + without external triggers. Format follows Go's time.Duration syntax (e.g., "5m" for 5 minutes). + type: string + required: + - maxConcurrentReconciles + - rateLimiter + - requeueDuration + type: object + required: + - workQueue + type: object promotionStrategy: description: |- PromotionStrategy contains the configuration for the PromotionStrategy controller, @@ -1291,6 +1493,7 @@ spec: - argocdCommitStatus - changeTransferPolicy - commitStatus + - gitCommitStatus - promotionStrategy - pullRequest - timedCommitStatus diff --git a/config/crd/bases/promoter.argoproj.io_gitcommitstatuses.yaml b/config/crd/bases/promoter.argoproj.io_gitcommitstatuses.yaml new file mode 100644 index 000000000..0655daf72 --- /dev/null +++ b/config/crd/bases/promoter.argoproj.io_gitcommitstatuses.yaml @@ -0,0 +1,297 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: gitcommitstatuses.promoter.argoproj.io +spec: + group: promoter.argoproj.io + names: + kind: GitCommitStatus + listKind: GitCommitStatusList + plural: gitcommitstatuses + singular: gitcommitstatus + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.key + name: Key + type: string + - jsonPath: .spec.promotionStrategyRef.name + name: PromotionStrategy + type: string + - jsonPath: .spec.target + name: Validates + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + GitCommitStatus is the Schema for the gitcommitstatuses API. + + It validates commits from PromotionStrategy environments using configurable expressions + and creates CommitStatus resources with the validation results. + + Use the Target field to control which commit is validated: + - "active" (default): Validates the currently deployed commit + - "proposed": Validates the incoming commit + + The validation result is always reported on the PROPOSED commit to enable promotion gating, + regardless of which commit was validated. + + Workflow: + 1. Controller reads PromotionStrategy to get ProposedHydratedSha and ActiveHydratedSha + 2. Controller selects SHA to validate based on Target field + 3. Controller fetches commit data (subject, body, author, trailers) for selected SHA + 4. Controller evaluates expression against selected commit data + 5. Controller creates/updates CommitStatus with result attached to PROPOSED SHA + 6. PromotionStrategy checks CommitStatus on PROPOSED SHA before allowing promotion + + Common use cases: + - "Ensure active commit is not a revert before promoting" + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: spec defines the desired state of GitCommitStatus + properties: + description: + description: |- + Description is a human-readable description of this validation that will be shown in the SCM provider + (GitHub, GitLab, etc.) as the commit status description. + If not specified, defaults to empty string. + type: string + expression: + description: |- + Expression is evaluated using the expr library (github.com/expr-lang/expr) against commit data + for environments in the referenced PromotionStrategy. + The expression must return a boolean value where true indicates the validation passed. + + The commit validated is determined by the Target field: + - "active" (default): Validates the ACTIVE (currently deployed) commit + - "proposed": Validates the PROPOSED commit (what will be promoted) + + The validation result is always reported on the PROPOSED commit to enable promotion gating. + + Use Cases by Mode: + Active mode: State-based gating - validate current environment before allowing promotion + - "Don't promote until active commit has required sign-offs" + - "Verify current deployment meets compliance before next promotion" + - "Ensure active commit is not a revert before promoting" + + Proposed mode: Change-based gating - validate the incoming change itself + - "Don't promote unless new commit follows naming convention" + - "Ensure proposed commit has proper JIRA ticket reference" + - "Require specific author for proposed changes" + + Available variables in the expression context: + - Commit.SHA (string): the commit SHA being validated (active or proposed based on Target) + - Commit.Subject (string): the first line of the commit message + - Commit.Body (string): the commit message body (everything after the subject line) + - Commit.Author (string): commit author email address + - Commit.Committer (string): committer email address + - Commit.Trailers (map[string][]string): git trailers parsed from commit message + type: string + key: + description: |- + Key is the unique identifier for this validation rule. + It is used as the commit status key and in status messages. + This key is matched against PromotionStrategy's proposedCommitStatuses or activeCommitStatuses + to determine which environments this validation applies to. + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + promotionStrategyRef: + description: |- + PromotionStrategyRef is a reference to the promotion strategy that this commit status applies to. + The controller will validate commits from ALL environments in the referenced PromotionStrategy + where this GitCommitStatus.Spec.Key matches an entry in either: + - PromotionStrategy.Spec.ProposedCommitStatuses (applies to all environments), OR + - Environment.ProposedCommitStatuses (applies to specific environment) + properties: + name: + description: Name is the name of the object to refer to. + type: string + required: + - name + type: object + target: + default: active + description: |- + Target specifies which commit SHA to validate with the expression. + - "active": Validates the currently active/deployed commit (default behavior) + - "proposed": Validates the proposed commit that will be promoted + + The validation result is always reported on the PROPOSED commit (for gating), but this field + controls which commit's data is used in the expression evaluation. + + Examples: + target: "active" - "Don't promote if a revert commit is detected" + target: "proposed" - "Don't promote unless new commit follows naming convention" + enum: + - active + - proposed + type: string + required: + - expression + - key + - promotionStrategyRef + type: object + status: + description: status defines the observed state of GitCommitStatus + properties: + conditions: + description: |- + Conditions represent the latest available observations of the GitCommitStatus's state. + Standard condition types include "Ready" which aggregates the status of all environments. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + environments: + description: |- + Environments holds the validation results for each environment where this validation applies. + Each entry corresponds to an environment from the PromotionStrategy where the Key matches + either global or environment-specific proposedCommitStatuses. + + The controller validates the commit specified by Target ("active" or "proposed") + but the CommitStatus is always reported on the PROPOSED commit for promotion gating. + Each environment entry tracks both the ProposedHydratedSha (where status is reported) and the + ActiveHydratedSha, with TargetedSha indicating which one was actually evaluated. + items: + description: GitCommitStatusEnvironmentStatus defines the observed + validation status for a specific environment. + properties: + activeHydratedSha: + description: |- + ActiveHydratedSha is the currently active (deployed) hydrated commit SHA that was validated. + This comes from the PromotionStrategy's environment status. + The expression is evaluated against THIS commit's data, not the proposed commit. + May be empty if the PromotionStrategy hasn't reconciled yet. + type: string + branch: + description: Branch is the environment branch name being validated. + type: string + expressionResult: + description: |- + ExpressionResult contains the boolean result of the expression evaluation. + Only set when the expression successfully evaluates to a boolean. + nil indicates the expression has not yet been evaluated, failed to compile, or failed to evaluate. + type: boolean + phase: + description: |- + Phase represents the current validation state of the commit. + - "pending": validation has not completed, commit data is not yet available, or SHAs are empty + - "success": expression evaluated to true, validation passed + - "failure": expression evaluated to false, validation failed, or expression compilation failed + enum: + - pending + - success + - failure + type: string + proposedHydratedSha: + description: |- + ProposedHydratedSha is the proposed hydrated commit SHA where the validation result is reported. + This comes from the PromotionStrategy's environment status. + The CommitStatus resource is created with this SHA, allowing the PromotionStrategy to gate + promotions based on the validation of the ACTIVE commit. + May be empty if the PromotionStrategy hasn't reconciled yet. + type: string + targetedSha: + description: |- + TargetedSha is the commit SHA that was actually validated by the expression. + This will match either ProposedHydratedSha or ActiveHydratedSha depending on + the Target setting ("proposed" or "active"). + This field clarifies which commit's data was used in the expression evaluation. + type: string + required: + - branch + - phase + - proposedHydratedSha + type: object + type: array + x-kubernetes-list-map-keys: + - branch + x-kubernetes-list-type: map + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/promoter.argoproj.io_pullrequests.yaml b/config/crd/bases/promoter.argoproj.io_pullrequests.yaml index badf6310f..472a36d3f 100644 --- a/config/crd/bases/promoter.argoproj.io_pullrequests.yaml +++ b/config/crd/bases/promoter.argoproj.io_pullrequests.yaml @@ -18,7 +18,7 @@ spec: - jsonPath: .status.state name: State type: string - - jsonPath: .status.ID + - jsonPath: .status.id name: ID type: string - jsonPath: .spec.sourceBranch diff --git a/config/crd/bases/promoter.argoproj.io_timedcommitstatuses.yaml b/config/crd/bases/promoter.argoproj.io_timedcommitstatuses.yaml index 43e81f5eb..edec9acd7 100644 --- a/config/crd/bases/promoter.argoproj.io_timedcommitstatuses.yaml +++ b/config/crd/bases/promoter.argoproj.io_timedcommitstatuses.yaml @@ -16,7 +16,7 @@ spec: versions: - additionalPrinterColumns: - jsonPath: .spec.promotionStrategyRef.name - name: Strategy + name: PromotionStrategy type: string - jsonPath: .status.conditions[?(@.type=="Ready")].status name: Ready diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index fac0257b8..c10d1c64d 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -13,6 +13,7 @@ resources: - bases/promoter.argoproj.io_controllerconfigurations.yaml - bases/promoter.argoproj.io_clusterscmproviders.yaml - bases/promoter.argoproj.io_timedcommitstatuses.yaml +- bases/promoter.argoproj.io_gitcommitstatuses.yaml #+kubebuilder:scaffold:crdkustomizeresource patches: diff --git a/config/rbac/gitcommitstatus_admin_role.yaml b/config/rbac/gitcommitstatus_admin_role.yaml new file mode 100644 index 000000000..95f42120a --- /dev/null +++ b/config/rbac/gitcommitstatus_admin_role.yaml @@ -0,0 +1,27 @@ +# This rule is not used by the project promoter itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants full permissions ('*') over promoter.argoproj.io. +# This role is intended for users authorized to modify roles and bindings within the cluster, +# enabling them to delegate specific permissions to other users or groups as needed. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: promoter + app.kubernetes.io/managed-by: kustomize + name: gitcommitstatus-admin-role +rules: +- apiGroups: + - promoter.argoproj.io + resources: + - gitcommitstatuses + verbs: + - '*' +- apiGroups: + - promoter.argoproj.io + resources: + - gitcommitstatuses/status + verbs: + - get diff --git a/config/rbac/gitcommitstatus_editor_role.yaml b/config/rbac/gitcommitstatus_editor_role.yaml new file mode 100644 index 000000000..996e58c92 --- /dev/null +++ b/config/rbac/gitcommitstatus_editor_role.yaml @@ -0,0 +1,33 @@ +# This rule is not used by the project promoter itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants permissions to create, update, and delete resources within the promoter.argoproj.io. +# This role is intended for users who need to manage these resources +# but should not control RBAC or manage permissions for others. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: promoter + app.kubernetes.io/managed-by: kustomize + name: gitcommitstatus-editor-role +rules: +- apiGroups: + - promoter.argoproj.io + resources: + - gitcommitstatuses + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - promoter.argoproj.io + resources: + - gitcommitstatuses/status + verbs: + - get diff --git a/config/rbac/gitcommitstatus_viewer_role.yaml b/config/rbac/gitcommitstatus_viewer_role.yaml new file mode 100644 index 000000000..2d991a84d --- /dev/null +++ b/config/rbac/gitcommitstatus_viewer_role.yaml @@ -0,0 +1,29 @@ +# This rule is not used by the project promoter itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants read-only access to promoter.argoproj.io resources. +# This role is intended for users who need visibility into these resources +# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: promoter + app.kubernetes.io/managed-by: kustomize + name: gitcommitstatus-viewer-role +rules: +- apiGroups: + - promoter.argoproj.io + resources: + - gitcommitstatuses + verbs: + - get + - list + - watch +- apiGroups: + - promoter.argoproj.io + resources: + - gitcommitstatuses/status + verbs: + - get diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml index f8a47a61d..14e9495b4 100644 --- a/config/rbac/kustomization.yaml +++ b/config/rbac/kustomization.yaml @@ -37,6 +37,9 @@ resources: # default, aiding admins in cluster management. Those roles are # not used by the promoter itself. You can comment the following lines # if you do not want those helpers be installed with your Project. +- gitcommitstatus_admin_role.yaml +- gitcommitstatus_editor_role.yaml +- gitcommitstatus_viewer_role.yaml - timedcommitstatus_admin_role.yaml - timedcommitstatus_editor_role.yaml - timedcommitstatus_viewer_role.yaml \ No newline at end of file diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 5a700466f..6617af234 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -37,6 +37,7 @@ rules: - clusterscmproviders - commitstatuses - controllerconfigurations + - gitcommitstatuses - gitrepositories - promotionstrategies - pullrequests @@ -59,6 +60,7 @@ rules: - clusterscmproviders/finalizers - commitstatuses/finalizers - controllerconfigurations/finalizers + - gitcommitstatuses/finalizers - gitrepositories/finalizers - promotionstrategies/finalizers - pullrequests/finalizers @@ -75,6 +77,7 @@ rules: - clusterscmproviders/status - commitstatuses/status - controllerconfigurations/status + - gitcommitstatuses/status - gitrepositories/status - promotionstrategies/status - pullrequests/status diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index 2031ccb4a..136283002 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -10,4 +10,5 @@ resources: - promoter_v1alpha1_controllerconfiguration.yaml - promoter_v1alpha1_clusterscmprovider.yaml - promoter_v1alpha1_timedcommitstatus.yaml +- promoter_v1alpha1_gitcommitstatus.yaml #+kubebuilder:scaffold:manifestskustomizesamples diff --git a/config/samples/promoter_v1alpha1_gitcommitstatus.yaml b/config/samples/promoter_v1alpha1_gitcommitstatus.yaml new file mode 100644 index 000000000..746412f50 --- /dev/null +++ b/config/samples/promoter_v1alpha1_gitcommitstatus.yaml @@ -0,0 +1,33 @@ +apiVersion: promoter.argoproj.io/v1alpha1 +kind: GitCommitStatus +metadata: + name: gitcommitstatus-active-mode +spec: + promotionStrategyRef: + name: promotion-strategy-sample + key: revert-check + description: "Revert commit validation on active deployment" + # Default: validates the ACTIVE (currently deployed) commit + # Omitting target defaults to "active" + target: active + # This expression returns false if ANY of these conditions are true: + # 1. Commit subject starts with "Revert" → fail + # 2. Commit body starts with "Revert" → fail + # 3. Commit has a "Git-commit-status: pending" trailer → fail + # The validation passes only if NONE of these conditions are true + # Note: startsWith is an infix operator, 'in' is used to check map key existence + expression: '!(Commit.Subject startsWith "Revert" || Commit.Body startsWith "Revert" || ("Git-commit-status" in Commit.Trailers && Commit.Trailers["Git-commit-status"][0] == "pending"))' +--- +apiVersion: promoter.argoproj.io/v1alpha1 +kind: GitCommitStatus +metadata: + name: gitcommitstatus-proposed-mode +spec: + promotionStrategyRef: + name: promotion-strategy-sample + key: commit-format-check + description: "Commit message format validation" + # NEW: validates the PROPOSED commit (what will be promoted) + target: proposed + # Ensure proposed commits follow conventional commit format + expression: 'Commit.Subject matches "^(feat|fix|docs|style|refactor|test|chore)(\\(.+\\))?: .+"' diff --git a/dist/install.yaml b/dist/install.yaml index b583222ab..5dc8a0596 100644 --- a/dist/install.yaml +++ b/dist/install.yaml @@ -28,7 +28,7 @@ spec: versions: - additionalPrinterColumns: - jsonPath: .spec.promotionStrategyRef.name - name: Strategy + name: PromotionStrategy priority: 1 type: string - jsonPath: .status.conditions[?(@.type=="Ready")].status @@ -2487,6 +2487,208 @@ spec: required: - workQueue type: object + gitCommitStatus: + description: |- + GitCommitStatus contains the configuration for the GitCommitStatus controller, + including WorkQueue settings that control reconciliation behavior. + properties: + workQueue: + description: |- + WorkQueue contains the work queue configuration for the GitCommitStatus controller. + This includes requeue duration, maximum concurrent reconciles, and rate limiter settings. + properties: + maxConcurrentReconciles: + description: |- + MaxConcurrentReconciles defines the maximum number of concurrent reconcile operations + that can run for this controller. Higher values increase throughput but consume more + resources. Must be at least 1. + type: integer + rateLimiter: + description: |- + RateLimiter defines the rate limiting strategy for the controller's work queue. + Rate limiting controls how quickly failed reconciliations are retried and helps + prevent overwhelming external APIs or systems. + properties: + bucket: + description: |- + Bucket rate limiter uses a token bucket algorithm to control request rate. + Allows bursts while maintaining an average rate limit. + properties: + bucket: + description: |- + Bucket is the maximum number of tokens that can be accumulated in the bucket. + This defines the maximum burst size - how many operations can occur in rapid + succession before rate limiting takes effect. Must be non-negative. + type: integer + qps: + description: |- + Qps (queries per second) is the rate at which tokens are added to the bucket. + This defines the sustained rate limit for operations. Must be non-negative. + type: integer + required: + - bucket + - qps + type: object + exponentialFailure: + description: |- + ExponentialFailure rate limiter increases delay exponentially with each failure. + Standard approach for backing off when operations fail repeatedly. + properties: + baseDelay: + description: |- + BaseDelay is the initial delay after the first failure. Subsequent failures will exponentially + increase this delay (2x, 4x, 8x, etc.) until MaxDelay is reached. + Format follows Go's time.Duration syntax (e.g., "1s" for 1 second). + type: string + maxDelay: + description: |- + MaxDelay is the maximum delay between retry attempts. Once the exponential backoff reaches + this value, all subsequent retries will use this delay. + Format follows Go's time.Duration syntax (e.g., "1m" for 1 minute). + type: string + required: + - baseDelay + - maxDelay + type: object + fastSlow: + description: |- + FastSlow rate limiter provides fast retries initially, then switches to slow retries. + Useful for quickly retrying transient errors while backing off for persistent failures. + properties: + fastDelay: + description: |- + FastDelay is the delay used for the first MaxFastAttempts retry attempts. + Format follows Go's time.Duration syntax (e.g., "100ms" for 100 milliseconds). + type: string + maxFastAttempts: + description: |- + MaxFastAttempts is the number of retry attempts that use FastDelay before switching to SlowDelay. + Must be at least 1. + type: integer + slowDelay: + description: |- + SlowDelay is the delay used for retry attempts after MaxFastAttempts have been exhausted. + Format follows Go's time.Duration syntax (e.g., "10s" for 10 seconds). + type: string + required: + - fastDelay + - maxFastAttempts + - slowDelay + type: object + maxOf: + description: |- + MaxOf allows combining multiple rate limiters, where the maximum delay from all + limiters is used. This enables sophisticated rate limiting that respects multiple + constraints simultaneously (e.g., both per-item exponential backoff and global rate limits). + items: + description: |- + RateLimiterTypes defines the different algorithms available for rate limiting. + + Exactly one of the three rate limiter types must be specified: + - FastSlow: Quick retry for transient errors, then slower retry for persistent failures + - ExponentialFailure: Standard exponential backoff for repeated failures + - Bucket: Token bucket algorithm for controlling overall request rate + + See https://pkg.go.dev/k8s.io/client-go/util/workqueue for implementation details. + properties: + bucket: + description: |- + Bucket rate limiter uses a token bucket algorithm to control request rate. + Allows bursts while maintaining an average rate limit. + properties: + bucket: + description: |- + Bucket is the maximum number of tokens that can be accumulated in the bucket. + This defines the maximum burst size - how many operations can occur in rapid + succession before rate limiting takes effect. Must be non-negative. + type: integer + qps: + description: |- + Qps (queries per second) is the rate at which tokens are added to the bucket. + This defines the sustained rate limit for operations. Must be non-negative. + type: integer + required: + - bucket + - qps + type: object + exponentialFailure: + description: |- + ExponentialFailure rate limiter increases delay exponentially with each failure. + Standard approach for backing off when operations fail repeatedly. + properties: + baseDelay: + description: |- + BaseDelay is the initial delay after the first failure. Subsequent failures will exponentially + increase this delay (2x, 4x, 8x, etc.) until MaxDelay is reached. + Format follows Go's time.Duration syntax (e.g., "1s" for 1 second). + type: string + maxDelay: + description: |- + MaxDelay is the maximum delay between retry attempts. Once the exponential backoff reaches + this value, all subsequent retries will use this delay. + Format follows Go's time.Duration syntax (e.g., "1m" for 1 minute). + type: string + required: + - baseDelay + - maxDelay + type: object + fastSlow: + description: |- + FastSlow rate limiter provides fast retries initially, then switches to slow retries. + Useful for quickly retrying transient errors while backing off for persistent failures. + properties: + fastDelay: + description: |- + FastDelay is the delay used for the first MaxFastAttempts retry attempts. + Format follows Go's time.Duration syntax (e.g., "100ms" for 100 milliseconds). + type: string + maxFastAttempts: + description: |- + MaxFastAttempts is the number of retry attempts that use FastDelay before switching to SlowDelay. + Must be at least 1. + type: integer + slowDelay: + description: |- + SlowDelay is the delay used for retry attempts after MaxFastAttempts have been exhausted. + Format follows Go's time.Duration syntax (e.g., "10s" for 10 seconds). + type: string + required: + - fastDelay + - maxFastAttempts + - slowDelay + type: object + type: object + x-kubernetes-validations: + - message: at most one of the fields in [fastSlow exponentialFailure + bucket] may be set + rule: '[has(self.fastSlow),has(self.exponentialFailure),has(self.bucket)].filter(x,x==true).size() + <= 1' + maxItems: 3 + type: array + type: object + x-kubernetes-validations: + - message: at most one of the fields in [fastSlow exponentialFailure + bucket maxOf] may be set + rule: '[has(self.fastSlow),has(self.exponentialFailure),has(self.bucket),has(self.maxOf)].filter(x,x==true).size() + <= 1' + - message: at most one of the fields in [fastSlow exponentialFailure + bucket] may be set + rule: '[has(self.fastSlow),has(self.exponentialFailure),has(self.bucket)].filter(x,x==true).size() + <= 1' + requeueDuration: + description: |- + RequeueDuration specifies how frequently resources should be requeued for automatic reconciliation. + This creates a periodic reconciliation loop that ensures the desired state is maintained even + without external triggers. Format follows Go's time.Duration syntax (e.g., "5m" for 5 minutes). + type: string + required: + - maxConcurrentReconciles + - rateLimiter + - requeueDuration + type: object + required: + - workQueue + type: object promotionStrategy: description: |- PromotionStrategy contains the configuration for the PromotionStrategy controller, @@ -3117,6 +3319,7 @@ spec: - argocdCommitStatus - changeTransferPolicy - commitStatus + - gitCommitStatus - promotionStrategy - pullRequest - timedCommitStatus @@ -3137,6 +3340,303 @@ spec: --- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: gitcommitstatuses.promoter.argoproj.io +spec: + group: promoter.argoproj.io + names: + kind: GitCommitStatus + listKind: GitCommitStatusList + plural: gitcommitstatuses + singular: gitcommitstatus + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.key + name: Key + type: string + - jsonPath: .spec.promotionStrategyRef.name + name: PromotionStrategy + type: string + - jsonPath: .spec.target + name: Validates + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + GitCommitStatus is the Schema for the gitcommitstatuses API. + + It validates commits from PromotionStrategy environments using configurable expressions + and creates CommitStatus resources with the validation results. + + Use the Target field to control which commit is validated: + - "active" (default): Validates the currently deployed commit + - "proposed": Validates the incoming commit + + The validation result is always reported on the PROPOSED commit to enable promotion gating, + regardless of which commit was validated. + + Workflow: + 1. Controller reads PromotionStrategy to get ProposedHydratedSha and ActiveHydratedSha + 2. Controller selects SHA to validate based on Target field + 3. Controller fetches commit data (subject, body, author, trailers) for selected SHA + 4. Controller evaluates expression against selected commit data + 5. Controller creates/updates CommitStatus with result attached to PROPOSED SHA + 6. PromotionStrategy checks CommitStatus on PROPOSED SHA before allowing promotion + + Common use cases: + - "Ensure active commit is not a revert before promoting" + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: spec defines the desired state of GitCommitStatus + properties: + description: + description: |- + Description is a human-readable description of this validation that will be shown in the SCM provider + (GitHub, GitLab, etc.) as the commit status description. + If not specified, defaults to empty string. + type: string + expression: + description: |- + Expression is evaluated using the expr library (github.com/expr-lang/expr) against commit data + for environments in the referenced PromotionStrategy. + The expression must return a boolean value where true indicates the validation passed. + + The commit validated is determined by the Target field: + - "active" (default): Validates the ACTIVE (currently deployed) commit + - "proposed": Validates the PROPOSED commit (what will be promoted) + + The validation result is always reported on the PROPOSED commit to enable promotion gating. + + Use Cases by Mode: + Active mode: State-based gating - validate current environment before allowing promotion + - "Don't promote until active commit has required sign-offs" + - "Verify current deployment meets compliance before next promotion" + - "Ensure active commit is not a revert before promoting" + + Proposed mode: Change-based gating - validate the incoming change itself + - "Don't promote unless new commit follows naming convention" + - "Ensure proposed commit has proper JIRA ticket reference" + - "Require specific author for proposed changes" + + Available variables in the expression context: + - Commit.SHA (string): the commit SHA being validated (active or proposed based on Target) + - Commit.Subject (string): the first line of the commit message + - Commit.Body (string): the commit message body (everything after the subject line) + - Commit.Author (string): commit author email address + - Commit.Committer (string): committer email address + - Commit.Trailers (map[string][]string): git trailers parsed from commit message + type: string + key: + description: |- + Key is the unique identifier for this validation rule. + It is used as the commit status key and in status messages. + This key is matched against PromotionStrategy's proposedCommitStatuses or activeCommitStatuses + to determine which environments this validation applies to. + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + promotionStrategyRef: + description: |- + PromotionStrategyRef is a reference to the promotion strategy that this commit status applies to. + The controller will validate commits from ALL environments in the referenced PromotionStrategy + where this GitCommitStatus.Spec.Key matches an entry in either: + - PromotionStrategy.Spec.ProposedCommitStatuses (applies to all environments), OR + - Environment.ProposedCommitStatuses (applies to specific environment) + properties: + name: + description: Name is the name of the object to refer to. + type: string + required: + - name + type: object + target: + default: active + description: |- + Target specifies which commit SHA to validate with the expression. + - "active": Validates the currently active/deployed commit (default behavior) + - "proposed": Validates the proposed commit that will be promoted + + The validation result is always reported on the PROPOSED commit (for gating), but this field + controls which commit's data is used in the expression evaluation. + + Examples: + target: "active" - "Don't promote if a revert commit is detected" + target: "proposed" - "Don't promote unless new commit follows naming convention" + enum: + - active + - proposed + type: string + required: + - expression + - key + - promotionStrategyRef + type: object + status: + description: status defines the observed state of GitCommitStatus + properties: + conditions: + description: |- + Conditions represent the latest available observations of the GitCommitStatus's state. + Standard condition types include "Ready" which aggregates the status of all environments. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + environments: + description: |- + Environments holds the validation results for each environment where this validation applies. + Each entry corresponds to an environment from the PromotionStrategy where the Key matches + either global or environment-specific proposedCommitStatuses. + + The controller validates the commit specified by Target ("active" or "proposed") + but the CommitStatus is always reported on the PROPOSED commit for promotion gating. + Each environment entry tracks both the ProposedHydratedSha (where status is reported) and the + ActiveHydratedSha, with TargetedSha indicating which one was actually evaluated. + items: + description: GitCommitStatusEnvironmentStatus defines the observed + validation status for a specific environment. + properties: + activeHydratedSha: + description: |- + ActiveHydratedSha is the currently active (deployed) hydrated commit SHA that was validated. + This comes from the PromotionStrategy's environment status. + The expression is evaluated against THIS commit's data, not the proposed commit. + May be empty if the PromotionStrategy hasn't reconciled yet. + type: string + branch: + description: Branch is the environment branch name being validated. + type: string + expressionResult: + description: |- + ExpressionResult contains the boolean result of the expression evaluation. + Only set when the expression successfully evaluates to a boolean. + nil indicates the expression has not yet been evaluated, failed to compile, or failed to evaluate. + type: boolean + phase: + description: |- + Phase represents the current validation state of the commit. + - "pending": validation has not completed, commit data is not yet available, or SHAs are empty + - "success": expression evaluated to true, validation passed + - "failure": expression evaluated to false, validation failed, or expression compilation failed + enum: + - pending + - success + - failure + type: string + proposedHydratedSha: + description: |- + ProposedHydratedSha is the proposed hydrated commit SHA where the validation result is reported. + This comes from the PromotionStrategy's environment status. + The CommitStatus resource is created with this SHA, allowing the PromotionStrategy to gate + promotions based on the validation of the ACTIVE commit. + May be empty if the PromotionStrategy hasn't reconciled yet. + type: string + targetedSha: + description: |- + TargetedSha is the commit SHA that was actually validated by the expression. + This will match either ProposedHydratedSha or ActiveHydratedSha depending on + the Target setting ("proposed" or "active"). + This field clarifies which commit's data was used in the expression evaluation. + type: string + required: + - branch + - phase + - proposedHydratedSha + type: object + type: array + x-kubernetes-list-map-keys: + - branch + x-kubernetes-list-type: map + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.19.0 @@ -4693,7 +5193,7 @@ spec: - jsonPath: .status.state name: State type: string - - jsonPath: .status.ID + - jsonPath: .status.id name: ID type: string - jsonPath: .spec.sourceBranch @@ -5169,7 +5669,7 @@ spec: versions: - additionalPrinterColumns: - jsonPath: .spec.promotionStrategyRef.name - name: Strategy + name: PromotionStrategy type: string - jsonPath: .status.conditions[?(@.type=="Ready")].status name: Ready @@ -5601,6 +6101,77 @@ rules: --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole +metadata: + labels: + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/name: promoter + name: promoter-gitcommitstatus-admin-role +rules: +- apiGroups: + - promoter.argoproj.io + resources: + - gitcommitstatuses + verbs: + - '*' +- apiGroups: + - promoter.argoproj.io + resources: + - gitcommitstatuses/status + verbs: + - get +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/name: promoter + name: promoter-gitcommitstatus-editor-role +rules: +- apiGroups: + - promoter.argoproj.io + resources: + - gitcommitstatuses + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - promoter.argoproj.io + resources: + - gitcommitstatuses/status + verbs: + - get +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/name: promoter + name: promoter-gitcommitstatus-viewer-role +rules: +- apiGroups: + - promoter.argoproj.io + resources: + - gitcommitstatuses + verbs: + - get + - list + - watch +- apiGroups: + - promoter.argoproj.io + resources: + - gitcommitstatuses/status + verbs: + - get +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole metadata: name: promoter-manager-role rules: @@ -5637,6 +6208,7 @@ rules: - clusterscmproviders - commitstatuses - controllerconfigurations + - gitcommitstatuses - gitrepositories - promotionstrategies - pullrequests @@ -5659,6 +6231,7 @@ rules: - clusterscmproviders/finalizers - commitstatuses/finalizers - controllerconfigurations/finalizers + - gitcommitstatuses/finalizers - gitrepositories/finalizers - promotionstrategies/finalizers - pullrequests/finalizers @@ -5675,6 +6248,7 @@ rules: - clusterscmproviders/status - commitstatuses/status - controllerconfigurations/status + - gitcommitstatuses/status - gitrepositories/status - promotionstrategies/status - pullrequests/status @@ -6051,6 +6625,19 @@ spec: maxFastAttempts: 3 slowDelay: 5m requeueDuration: 5m + gitCommitStatus: + workQueue: + maxConcurrentReconciles: 10 + rateLimiter: + maxOf: + - bucket: + bucket: 100 + qps: 10 + - fastSlow: + fastDelay: 1s + maxFastAttempts: 3 + slowDelay: 5m + requeueDuration: 5m promotionStrategy: workQueue: maxConcurrentReconciles: 10 diff --git a/docs/commit-status-controllers/development-best-practices.md b/docs/commit-status-controllers/development-best-practices.md index 45da05407..29f5c4ea1 100644 --- a/docs/commit-status-controllers/development-best-practices.md +++ b/docs/commit-status-controllers/development-best-practices.md @@ -16,7 +16,7 @@ All commit status controllers should set the following standard labels on the `C commitStatus.Labels[promoterv1alpha1.CommitStatusLabel] = "your-controller-key" ``` -**Purpose:** This label identifies which controller created the commit status. The value should match the `key` used in the PromotionStrategy's `activeCommitStatuses` or `proposedCommitStatuses` configuration. +**Purpose:** This label identifies which controller created the commit status. The value should match the `key` used in the PromotionStrategy's `proposedCommitStatuses` configuration. **Examples:** - `"argocd-health"` - Used by ArgoCDCommitStatus controller @@ -115,44 +115,62 @@ When your commit status controller detects important state transitions (e.g., a ### The Pattern -Touch the **specific ChangeTransferPolicy** for the environment that changed: +Use the `EnqueueCTP` function to trigger reconciliation of the **specific ChangeTransferPolicy** for the environment that changed. This approach directly enqueues a reconcile request without modifying the CTP object. +#### Controller Setup -### Important Considerations +Add the `EnqueueCTP` field to your reconciler struct: -1. **Only Trigger on Real Changes**: Don't touch annotations on every reconciliation, only when state actually changes -2. **Handle Not Found Gracefully**: The ChangeTransferPolicy might not exist yet (or might have been deleted) -3. **Use Patch, Not Update**: Patching is safer for concurrent modifications -4. **Log Actions**: Always log when you trigger reconciliation for debugging +```go +type MyCommitStatusReconciler struct { + client.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder + SettingsMgr *settings.Manager + + // EnqueueCTP is a function to enqueue CTP reconcile requests without modifying the CTP object. + EnqueueCTP CTPEnqueueFunc +} +``` + +#### Triggering Reconciliation + +Create a method to trigger CTP reconciliation when state transitions: -### Testing +```go +func (r *MyCommitStatusReconciler) touchChangeTransferPolicies(ctx context.Context, ps *promoterv1alpha1.PromotionStrategy, transitionedEnvironments []string) { + logger := log.FromContext(ctx) + + for _, envBranch := range transitionedEnvironments { + // Generate the ChangeTransferPolicy name using the same logic as the PromotionStrategy controller + ctpName := utils.KubeSafeUniqueName(ctx, utils.GetChangeTransferPolicyName(ps.Name, envBranch)) + + logger.Info("Triggering ChangeTransferPolicy reconciliation", + "changeTransferPolicy", ctpName, + "branch", envBranch) + + // Use the enqueue function to trigger reconciliation + if r.EnqueueCTP != nil { + r.EnqueueCTP(ps.Namespace, ctpName) + } + } +} +``` -When writing tests for this pattern, verify: +#### Wiring in main.go + +Pass the enqueue function when creating your controller: ```go -It("should add ReconcileAtAnnotation to ChangeTransferPolicy when state transitions", func() { - Eventually(func(g Gomega) { - // Get the ChangeTransferPolicy for the environment - ctpName := utils.KubeSafeUniqueName(ctx, - utils.GetChangeTransferPolicyName(ps.Name, "environment/development")) - - var ctp promoterv1alpha1.ChangeTransferPolicy - err := k8sClient.Get(ctx, types.NamespacedName{ - Name: ctpName, - Namespace: "default", - }, &ctp) - g.Expect(err).NotTo(HaveOccurred()) - - // Verify the annotation is present - g.Expect(ctp.Annotations).To( - HaveKey(promoterv1alpha1.ReconcileAtAnnotation)) - - // Verify it's a valid timestamp - annotationValue := ctp.Annotations[promoterv1alpha1.ReconcileAtAnnotation] - _, err = time.Parse(time.RFC3339Nano, annotationValue) - g.Expect(err).NotTo(HaveOccurred()) - }, constants.EventuallyTimeout).Should(Succeed()) -}) +if err := (&controller.MyCommitStatusReconciler{ + Client: localManager.GetClient(), + Scheme: localManager.GetScheme(), + Recorder: localManager.GetEventRecorderFor("MyCommitStatus"), + SettingsMgr: settingsMgr, + EnqueueCTP: ctpReconciler.GetEnqueueFunc(), +}).SetupWithManager(processSignalsCtx, localManager); err != nil { + panic(fmt.Errorf("unable to create MyCommitStatus controller: %w", err)) +} ``` ## Validation diff --git a/docs/commit-status-controllers/git-commit.md b/docs/commit-status-controllers/git-commit.md new file mode 100644 index 000000000..771eaa409 --- /dev/null +++ b/docs/commit-status-controllers/git-commit.md @@ -0,0 +1,206 @@ +## Overview + +The GitCommitStatus controller evaluates custom expressions against commit data and automatically creates CommitStatus resources that can be used as gates in your PromotionStrategy. + +### How It Works + +For each environment configured in a GitCommitStatus resource: + +1. The controller reads the PromotionStrategy to get commit SHAs +2. The controller selects which commit to validate based on the `target` field: + - `active` (default): Validates the currently deployed commit + - `proposed`: Validates the commit that will be promoted +3. The controller fetches commit data (message, author, committer, trailers) from git +4. The controller evaluates your custom expression against the commit data +5. The controller creates/updates a CommitStatus with the result, always attached to the **proposed** SHA for promotion gating +6. The PromotionStrategy checks the CommitStatus before allowing promotion + +### Validation Modes + +GitCommitStatus supports two validation modes: + +#### Active Mode + +Validates the **currently deployed** commit. Use this when you want to validate the current environment state before allowing new changes to be promoted. + +**Use cases:** +- "Don't promote if a revert commit is detected in production" +- "Ensure active commit is not missing required sign-offs" +- "Block promotions if active commit violates policy" + +#### Proposed Mode + +Validates the **incoming** commit that will be promoted. Use this when you want to validate the change itself. + +**Use cases:** +- "Don't promote unless new commit follows naming convention" +- "Ensure proposed commit has proper JIRA ticket reference" +- "Require specific author for proposed changes" + +## Example Configurations + +### Basic Revert Detection (Active Mode) + +In this example, we block promotions if the currently active commit is a revert: + +```yaml +apiVersion: promoter.argoproj.io/v1alpha1 +kind: GitCommitStatus +metadata: + name: no-revert-in-active +spec: + promotionStrategyRef: + name: webservice-tier-1 + key: revert-check + description: "Block promotions if active commit is a revert" + target: active # Targets currently deployed commit + expression: '!(Commit.Subject startsWith "Revert" || Commit.Body startsWith "Revert")' +``` + +### Hydrator Version Check (Proposed Mode) + +Ensure the hydration tooling version is the latest approved version before allowing promotions: + +```yaml +apiVersion: promoter.argoproj.io/v1alpha1 +kind: GitCommitStatus +metadata: + name: hydrator-version-check +spec: + promotionStrategyRef: + name: webservice-tier-1 + key: hydrator-version + description: "Verify active hydrator version is the latest" + target: active # Targets currently deployed commit + expression: '"Hydrator-version" in Commit.Trailers && Commit.Trailers["Hydrator-version"][0] == "v2.1.0"' +``` + +### Integrating with PromotionStrategy + +To use GitCommitStatus-based gating, configure your PromotionStrategy to check for the commit status key: + +> **Important:** GitCommitStatus must always be configured as a `proposedCommitStatuses` in your PromotionStrategy, regardless of whether it validates the active or proposed commit. This is because the CommitStatus is always reported on the **proposed** commit SHA, which is what gates the promotion. + +#### As Proposed Commit Status + +```yaml +apiVersion: promoter.argoproj.io/v1alpha1 +kind: PromotionStrategy +metadata: + name: webservice-tier-1 +spec: + gitRepositoryRef: + name: webservice-tier-1 + proposedCommitStatuses: + - key: commit-format # Must match GitCommitStatus.spec.key + environments: + - branch: environment/development + - branch: environment/staging + - branch: environment/production +``` + +#### Environment-Specific Validation + +You can apply different validations to different environments: + +```yaml +apiVersion: promoter.argoproj.io/v1alpha1 +kind: PromotionStrategy +metadata: + name: webservice-tier-1 +spec: + gitRepositoryRef: + name: webservice-tier-1 + environments: + - branch: environment/development + - branch: environment/staging + - branch: environment/production + proposedCommitStatuses: + - key: production-specific-check # Only for production +--- +apiVersion: promoter.argoproj.io/v1alpha1 +kind: GitCommitStatus +metadata: + name: production-gate +spec: + promotionStrategyRef: + name: webservice-tier-1 + key: production-specific-check + description: "Extra validation for production" + target: proposed + expression: '"Approved-for-production" in Commit.Trailers' +``` + +## Expression Language + +GitCommitStatus uses the [expr](https://github.com/expr-lang/expr) library for expression evaluation. Expressions must return a boolean value where `true` indicates validation passed. + +### Available Variables + +Each expression has access to a `Commit` object with the following fields: + +- `Commit.SHA` (string): The commit SHA being validated +- `Commit.Subject` (string): The first line of the commit message +- `Commit.Body` (string): The commit message body (everything after the subject line) +- `Commit.Author` (string): Commit author email address +- `Commit.Committer` (string): Committer email address +- `Commit.Trailers` (map[string][]string): Git trailers parsed from commit message + + +## Field Reference + +### spec.target + +Controls which commit SHA is validated by the expression. + +**Values:** +- `active` (default): Validates the currently deployed commit +- `proposed`: Validates the commit that will be promoted + +**Default:** `active` + +The validation result is always reported on the proposed commit for promotion gating, regardless of which commit was validated. + +### spec.key + +Unique identifier for this validation rule. This key is matched against the PromotionStrategy's `activeCommitStatuses` or `proposedCommitStatuses`. + +**Requirements:** +- Must be lowercase alphanumeric with hyphens +- Max 63 characters +- Pattern: `^[a-z0-9]([-a-z0-9]*[a-z0-9])?$` + +### spec.description + +Human-readable description shown in the SCM provider (GitHub, GitLab, etc.) as the commit status description. Keep this concise. + +**Optional** + +### spec.expression + +Expression evaluated against commit data. Must return boolean. + +**Required** + +### Status Fields + +The GitCommitStatus resource maintains detailed status information: + +```yaml +status: + environments: + - branch: environment/development + proposedHydratedSha: abc123def456 + activeHydratedSha: bef859def431 + targetedSha: bef859def431 # Which SHA was actually validated + phase: success + expressionResult: true +``` + +Fields: +- `branch` - The environment branch being validated +- `proposedHydratedSha` - The proposed commit SHA (where status is reported) +- `activeHydratedSha` - The active commit SHA (currently deployed) +- `targetedSha` - The commit SHA that was actually validated +- `phase` - Current validation status (`pending`, `success`, or `failure`) +- `expressionResult` - Boolean result of expression evaluation (nil if failed to evaluate) diff --git a/go.mod b/go.mod index 3c51c382d..310301096 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 github.com/bradleyfalzon/ghinstallation/v2 v2.17.0 github.com/cespare/xxhash/v2 v2.3.0 + github.com/expr-lang/expr v1.17.6 github.com/gin-contrib/gzip v1.2.5 github.com/gin-gonic/gin v1.11.0 github.com/go-logr/logr v1.4.3 diff --git a/go.sum b/go.sum index 47e481252..818643786 100644 --- a/go.sum +++ b/go.sum @@ -31,6 +31,8 @@ github.com/evanphx/json-patch v5.7.0+incompatible h1:vgGkfT/9f8zE6tvSCe74nfpAVDQ github.com/evanphx/json-patch v5.7.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/expr-lang/expr v1.17.6 h1:1h6i8ONk9cexhDmowO/A64VPxHScu7qfSl2k8OlINec= +github.com/expr-lang/expr v1.17.6/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= diff --git a/internal/controller/argocdcommitstatus_controller.go b/internal/controller/argocdcommitstatus_controller.go index ef008abec..646176a4b 100644 --- a/internal/controller/argocdcommitstatus_controller.go +++ b/internal/controller/argocdcommitstatus_controller.go @@ -44,12 +44,8 @@ import ( promoterv1alpha1 "github.com/argoproj-labs/gitops-promoter/api/v1alpha1" "github.com/argoproj-labs/gitops-promoter/internal/git" + "github.com/argoproj-labs/gitops-promoter/internal/gitauth" "github.com/argoproj-labs/gitops-promoter/internal/scms" - bitbucket_cloud "github.com/argoproj-labs/gitops-promoter/internal/scms/bitbucket_cloud" - "github.com/argoproj-labs/gitops-promoter/internal/scms/fake" - "github.com/argoproj-labs/gitops-promoter/internal/scms/forgejo" - "github.com/argoproj-labs/gitops-promoter/internal/scms/github" - "github.com/argoproj-labs/gitops-promoter/internal/scms/gitlab" "github.com/argoproj-labs/gitops-promoter/internal/settings" "github.com/argoproj-labs/gitops-promoter/internal/types/argocd" promoterConditions "github.com/argoproj-labs/gitops-promoter/internal/types/conditions" @@ -705,8 +701,6 @@ func (r *ArgoCDCommitStatusReconciler) getPromotionStrategy(ctx context.Context, } func (r *ArgoCDCommitStatusReconciler) getGitAuthProvider(ctx context.Context, argoCDCommitStatus promoterv1alpha1.ArgoCDCommitStatus) (scms.GitOperationsProvider, promoterv1alpha1.ObjectReference, error) { - logger := log.FromContext(ctx) - ps, err := r.getPromotionStrategy(ctx, argoCDCommitStatus.GetNamespace(), argoCDCommitStatus.Spec.PromotionStrategyRef) if ps == nil { return nil, promoterv1alpha1.ObjectReference{}, fmt.Errorf("PromotionStrategy is nil for ArgoCDCommitStatus %s", argoCDCommitStatus.Name) @@ -720,37 +714,12 @@ func (r *ArgoCDCommitStatusReconciler) getGitAuthProvider(ctx context.Context, a return nil, ps.Spec.RepositoryReference, fmt.Errorf("failed to get ScmProvider and secret for PromotionStrategy %q: %w", ps.Name, err) } - switch { - case scmProvider.GetSpec().Fake != nil: - logger.V(4).Info("Creating fake git authentication provider") - return fake.NewFakeGitAuthenticationProvider(scmProvider, secret), ps.Spec.RepositoryReference, nil - case scmProvider.GetSpec().GitHub != nil: - logger.V(4).Info("Creating GitHub git authentication provider") - provider, err := github.NewGithubGitAuthenticationProvider(ctx, r.localClient, scmProvider, secret, client.ObjectKey{Namespace: argoCDCommitStatus.Namespace, Name: ps.Spec.RepositoryReference.Name}) - if err != nil { - return nil, ps.Spec.RepositoryReference, fmt.Errorf("failed to create GitHub client: %w", err) - } - return provider, ps.Spec.RepositoryReference, nil - case scmProvider.GetSpec().GitLab != nil: - logger.V(4).Info("Creating GitLab git authentication provider") - gitlabClient, err := gitlab.NewGitlabGitAuthenticationProvider(scmProvider, secret) - if err != nil { - return nil, ps.Spec.RepositoryReference, fmt.Errorf("failed to create GitLab client: %w", err) - } - return gitlabClient, ps.Spec.RepositoryReference, nil - case scmProvider.GetSpec().BitbucketCloud != nil: - logger.V(4).Info("Creating Bitbucket Cloud git authentication provider") - bitbucketClient, err := bitbucket_cloud.NewBitbucketCloudGitAuthenticationProvider(scmProvider, secret) - if err != nil { - return nil, ps.Spec.RepositoryReference, fmt.Errorf("failed to create Bitbucket Cloud client: %w", err) - } - return bitbucketClient, ps.Spec.RepositoryReference, nil - case scmProvider.GetSpec().Forgejo != nil: - logger.V(4).Info("Creating Forgejo git authentication provider") - return forgejo.NewForgejoGitAuthenticationProvider(scmProvider, secret), ps.Spec.RepositoryReference, nil - default: - return nil, ps.Spec.RepositoryReference, errors.New("no supported git authentication provider found") + provider, err := gitauth.CreateGitOperationsProvider(ctx, r.localClient, scmProvider, secret, client.ObjectKey{Namespace: argoCDCommitStatus.Namespace, Name: ps.Spec.RepositoryReference.Name}) + if err != nil { + return nil, ps.Spec.RepositoryReference, fmt.Errorf("failed to create git operations provider: %w", err) } + + return provider, ps.Spec.RepositoryReference, nil } func hash(data []byte) string { diff --git a/internal/controller/changetransferpolicy_controller.go b/internal/controller/changetransferpolicy_controller.go index da68310d1..5066b9e03 100644 --- a/internal/controller/changetransferpolicy_controller.go +++ b/internal/controller/changetransferpolicy_controller.go @@ -29,12 +29,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "github.com/argoproj-labs/gitops-promoter/internal/git" + "github.com/argoproj-labs/gitops-promoter/internal/gitauth" "github.com/argoproj-labs/gitops-promoter/internal/scms" - bitbucket_cloud "github.com/argoproj-labs/gitops-promoter/internal/scms/bitbucket_cloud" - "github.com/argoproj-labs/gitops-promoter/internal/scms/fake" - "github.com/argoproj-labs/gitops-promoter/internal/scms/forgejo" - "github.com/argoproj-labs/gitops-promoter/internal/scms/github" - "github.com/argoproj-labs/gitops-promoter/internal/scms/gitlab" "github.com/argoproj-labs/gitops-promoter/internal/settings" "github.com/argoproj-labs/gitops-promoter/internal/utils" v1 "k8s.io/api/core/v1" @@ -245,6 +241,14 @@ func (r *ChangeTransferPolicyReconciler) buildHistoryEntry(ctx context.Context, return historyEntry, true, nil } +// getFirstTrailerValue returns the first value for a given trailer key, or an empty string if not found. +func getFirstTrailerValue(trailers map[string][]string, key string) string { + if values, ok := trailers[key]; ok && len(values) > 0 { + return values[0] + } + return "" +} + // populateActiveMetadata populates the active metadata for a history entry func (r *ChangeTransferPolicyReconciler) populateActiveMetadata(ctx context.Context, h *promoterv1alpha1.History, sha string, gitOperations *git.EnvironmentOperations) { logger := log.FromContext(ctx) @@ -263,10 +267,10 @@ func (r *ChangeTransferPolicyReconciler) populateActiveMetadata(ctx context.Cont } // populateProposedMetadata populates the proposed metadata for a history entry -func (r *ChangeTransferPolicyReconciler) populateProposedMetadata(ctx context.Context, h *promoterv1alpha1.History, activeTrailers map[string]string, gitOperations *git.EnvironmentOperations) { +func (r *ChangeTransferPolicyReconciler) populateProposedMetadata(ctx context.Context, h *promoterv1alpha1.History, activeTrailers map[string][]string, gitOperations *git.EnvironmentOperations) { logger := log.FromContext(ctx) - proposedHydratedSha := activeTrailers[constants.TrailerShaHydratedProposed] + proposedHydratedSha := getFirstTrailerValue(activeTrailers, constants.TrailerShaHydratedProposed) if proposedHydratedSha == "" { logger.V(4).Info("No " + constants.TrailerShaHydratedProposed + " trailer found") return @@ -280,16 +284,16 @@ func (r *ChangeTransferPolicyReconciler) populateProposedMetadata(ctx context.Co } // populatePullRequestMetadata populates the pull request metadata for a history entry -func (r *ChangeTransferPolicyReconciler) populatePullRequestMetadata(ctx context.Context, h *promoterv1alpha1.History, activeTrailers map[string]string) { +func (r *ChangeTransferPolicyReconciler) populatePullRequestMetadata(ctx context.Context, h *promoterv1alpha1.History, activeTrailers map[string][]string) { logger := log.FromContext(ctx) - if pullRequestID := activeTrailers[constants.TrailerPullRequestID]; pullRequestID != "" { + if pullRequestID := getFirstTrailerValue(activeTrailers, constants.TrailerPullRequestID); pullRequestID != "" { h.PullRequest.ID = pullRequestID } else { logger.V(4).Info("No " + constants.TrailerPullRequestID + " found in trailers") } - if pullRequestUrl := activeTrailers[constants.TrailerPullRequestUrl]; pullRequestUrl != "" { + if pullRequestUrl := getFirstTrailerValue(activeTrailers, constants.TrailerPullRequestUrl); pullRequestUrl != "" { if !strings.HasPrefix(pullRequestUrl, "http://") && !strings.HasPrefix(pullRequestUrl, "https://") { logger.V(4).Info("pull request URL does not start with http:// or https://", "url", pullRequestUrl) } else { @@ -299,7 +303,7 @@ func (r *ChangeTransferPolicyReconciler) populatePullRequestMetadata(ctx context logger.V(4).Info("No " + constants.TrailerPullRequestUrl + " found in trailers") } - if timeStr := activeTrailers[constants.TrailerPullRequestCreationTime]; timeStr != "" { + if timeStr := getFirstTrailerValue(activeTrailers, constants.TrailerPullRequestCreationTime); timeStr != "" { if creationTime, err := time.Parse(time.RFC3339, timeStr); err != nil { logger.V(4).Info("failed to parse "+constants.TrailerPullRequestCreationTime, "time", timeStr, "err", err) } else { @@ -309,7 +313,7 @@ func (r *ChangeTransferPolicyReconciler) populatePullRequestMetadata(ctx context logger.V(4).Info("No " + constants.TrailerPullRequestCreationTime + " found in trailers") } - if timeStr := activeTrailers[constants.TrailerPullRequestMergeTime]; timeStr != "" { + if timeStr := getFirstTrailerValue(activeTrailers, constants.TrailerPullRequestMergeTime); timeStr != "" { if mergeTime, err := time.Parse(time.RFC3339, timeStr); err != nil { logger.V(4).Info("failed to parse "+constants.TrailerPullRequestMergeTime, "time", timeStr, "err", err) } else { @@ -321,40 +325,40 @@ func (r *ChangeTransferPolicyReconciler) populatePullRequestMetadata(ctx context } // populateCommitStatuses populates the commit statuses for a history entry -func (r *ChangeTransferPolicyReconciler) populateCommitStatuses(ctx context.Context, h *promoterv1alpha1.History, activeTrailers map[string]string) { +func (r *ChangeTransferPolicyReconciler) populateCommitStatuses(ctx context.Context, h *promoterv1alpha1.History, activeTrailers map[string][]string) { activeKeys, proposedKeys := getCommitStatusKeysFromTrailers(ctx, activeTrailers) h.Active.CommitStatuses = make([]promoterv1alpha1.ChangeRequestPolicyCommitStatusPhase, 0, len(activeKeys)) for _, key := range activeKeys { - url := activeTrailers[constants.TrailerCommitStatusActivePrefix+key+"-url"] + url := getFirstTrailerValue(activeTrailers, constants.TrailerCommitStatusActivePrefix+key+"-url") if url != "" && !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { log.FromContext(ctx).Error(errors.New("invalid URL"), "active commit status URL does not start with http:// or https://", "url", url, "key", key) url = "" } h.Active.CommitStatuses = append(h.Active.CommitStatuses, promoterv1alpha1.ChangeRequestPolicyCommitStatusPhase{ Key: key, - Phase: activeTrailers[constants.TrailerCommitStatusActivePrefix+key+"-phase"], + Phase: getFirstTrailerValue(activeTrailers, constants.TrailerCommitStatusActivePrefix+key+"-phase"), Url: url, }) } h.Proposed.CommitStatuses = make([]promoterv1alpha1.ChangeRequestPolicyCommitStatusPhase, 0, len(proposedKeys)) for _, key := range proposedKeys { - url := activeTrailers[constants.TrailerCommitStatusProposedPrefix+key+"-url"] + url := getFirstTrailerValue(activeTrailers, constants.TrailerCommitStatusProposedPrefix+key+"-url") if url != "" && !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { log.FromContext(ctx).Error(errors.New("invalid URL"), "proposed commit status URL does not start with http:// or https://", "url", url, "key", key) url = "" } h.Proposed.CommitStatuses = append(h.Proposed.CommitStatuses, promoterv1alpha1.ChangeRequestPolicyCommitStatusPhase{ Key: key, - Phase: activeTrailers[constants.TrailerCommitStatusProposedPrefix+key+"-phase"], + Phase: getFirstTrailerValue(activeTrailers, constants.TrailerCommitStatusProposedPrefix+key+"-phase"), Url: url, }) } } // getCommitStatusKeysFromTrailers extracts the commit status keys from the trailers in the given context. -func getCommitStatusKeysFromTrailers(ctx context.Context, trailers map[string]string) (activeKeys []string, proposedKeys []string) { +func getCommitStatusKeysFromTrailers(ctx context.Context, trailers map[string][]string) (activeKeys []string, proposedKeys []string) { logger := log.FromContext(ctx) // This function extracts commit status keys from trailers with the given prefix. @@ -509,38 +513,11 @@ func (r *ChangeTransferPolicyReconciler) SetupWithManager(ctx context.Context, m } func (r *ChangeTransferPolicyReconciler) getGitAuthProvider(ctx context.Context, scmProvider promoterv1alpha1.GenericScmProvider, secret *v1.Secret, namespace string, repoRef promoterv1alpha1.ObjectReference) (scms.GitOperationsProvider, error) { - logger := log.FromContext(ctx) - switch { - case scmProvider.GetSpec().Fake != nil: - logger.V(4).Info("Creating fake git authentication provider") - return fake.NewFakeGitAuthenticationProvider(scmProvider, secret), nil - case scmProvider.GetSpec().GitHub != nil: - logger.V(4).Info("Creating GitHub git authentication provider") - p, err := github.NewGithubGitAuthenticationProvider(ctx, r.Client, scmProvider, secret, client.ObjectKey{Namespace: namespace, Name: repoRef.Name}) - if err != nil { - return nil, fmt.Errorf("failed to create GitHub Auth Provider: %w", err) - } - return p, nil - case scmProvider.GetSpec().GitLab != nil: - logger.V(4).Info("Creating GitLab git authentication provider") - provider, err := gitlab.NewGitlabGitAuthenticationProvider(scmProvider, secret) - if err != nil { - return nil, fmt.Errorf("failed to create GitLab Auth Provider: %w", err) - } - return provider, nil - case scmProvider.GetSpec().Forgejo != nil: - logger.V(4).Info("Creating Forgejo git authentication provider") - return forgejo.NewForgejoGitAuthenticationProvider(scmProvider, secret), nil - case scmProvider.GetSpec().BitbucketCloud != nil: - logger.V(4).Info("Creating Bitbucket Cloud git authentication provider") - provider, err := bitbucket_cloud.NewBitbucketCloudGitAuthenticationProvider(scmProvider, secret) - if err != nil { - return nil, fmt.Errorf("failed to create Bitbucket Cloud Auth Provider: %w", err) - } - return provider, nil - default: - return nil, errors.New("no supported git authentication provider found") + provider, err := gitauth.CreateGitOperationsProvider(ctx, r.Client, scmProvider, secret, client.ObjectKey{Namespace: namespace, Name: repoRef.Name}) + if err != nil { + return nil, fmt.Errorf("failed to create git operations provider: %w", err) } + return provider, nil } func (r *ChangeTransferPolicyReconciler) calculateStatus(ctx context.Context, ctp *promoterv1alpha1.ChangeTransferPolicy, gitOperations *git.EnvironmentOperations) error { diff --git a/internal/controller/gitcommitstatus_controller.go b/internal/controller/gitcommitstatus_controller.go new file mode 100644 index 000000000..9d28c9f3a --- /dev/null +++ b/internal/controller/gitcommitstatus_controller.go @@ -0,0 +1,569 @@ +/* +Copyright 2024. + +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 ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/argoproj-labs/gitops-promoter/internal/git" + "github.com/argoproj-labs/gitops-promoter/internal/gitauth" + "github.com/argoproj-labs/gitops-promoter/internal/scms" + "github.com/argoproj-labs/gitops-promoter/internal/settings" + promoterConditions "github.com/argoproj-labs/gitops-promoter/internal/types/conditions" + "github.com/argoproj-labs/gitops-promoter/internal/utils" + "github.com/expr-lang/expr" + "github.com/expr-lang/expr/vm" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + promoterv1alpha1 "github.com/argoproj-labs/gitops-promoter/api/v1alpha1" +) + +// GitCommitStatusReconciler reconciles a GitCommitStatus object +type GitCommitStatusReconciler struct { + client.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder + SettingsMgr *settings.Manager + + // EnqueueCTP is a function to enqueue CTP reconcile requests without modifying the CTP object. + EnqueueCTP CTPEnqueueFunc + + // expressionCache caches compiled expressions to avoid recompilation on every reconciliation + // Key: expression string, Value: compiled *vm.Program + expressionCache sync.Map +} + +// +kubebuilder:rbac:groups=promoter.argoproj.io,resources=gitcommitstatuses,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=promoter.argoproj.io,resources=gitcommitstatuses/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=promoter.argoproj.io,resources=gitcommitstatuses/finalizers,verbs=update + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +// +// For each configured environment in the GitCommitStatus, the controller: +// 1. Fetches the PromotionStrategy to get the proposed hydrated commit SHA +// 2. Retrieves commit details (message, author, trailers) from git +// 3. Evaluates the configured expression against the commit data +// 4. Creates/updates a CommitStatus resource with the validation result +func (r *GitCommitStatusReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, err error) { + logger := log.FromContext(ctx) + logger.Info("Reconciling GitCommitStatus", "name", req.Name) + startTime := time.Now() + + var gcs promoterv1alpha1.GitCommitStatus + defer utils.HandleReconciliationResult(ctx, startTime, &gcs, r.Client, r.Recorder, &err) + + err = r.Get(ctx, req.NamespacedName, &gcs, &client.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + logger.Info("GitCommitStatus not found") + return ctrl.Result{}, nil + } + + logger.Error(err, "failed to get GitCommitStatus") + return ctrl.Result{}, fmt.Errorf("failed to get GitCommitStatus %q: %w", req.Name, err) + } + + // Remove any existing Ready condition. We want to start fresh. + meta.RemoveStatusCondition(gcs.GetConditions(), string(promoterConditions.Ready)) + + // Fetch the referenced PromotionStrategy + var ps promoterv1alpha1.PromotionStrategy + psKey := client.ObjectKey{ + Namespace: gcs.Namespace, + Name: gcs.Spec.PromotionStrategyRef.Name, + } + err = r.Get(ctx, psKey, &ps) + if err != nil { + if k8serrors.IsNotFound(err) { + return ctrl.Result{}, fmt.Errorf("referenced PromotionStrategy %q not found: %w", gcs.Spec.PromotionStrategyRef.Name, err) + } + return ctrl.Result{}, fmt.Errorf("failed to get PromotionStrategy %q: %w", gcs.Spec.PromotionStrategyRef.Name, err) + } + + // Process each environment and evaluate expressions + transitionedEnvironments, commitStatuses, err := r.processEnvironments(ctx, &gcs, &ps) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to process environments: %w", err) + } + + // Inherit conditions from CommitStatus objects + utils.InheritNotReadyConditionFromObjects(&gcs, promoterConditions.CommitStatusesNotReady, commitStatuses...) + + // Update status + err = r.Status().Update(ctx, &gcs) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to update GitCommitStatus status: %w", err) + } + + // If any validations transitioned to success, touch the corresponding ChangeTransferPolicies + if len(transitionedEnvironments) > 0 { + r.touchChangeTransferPolicies(ctx, &ps, transitionedEnvironments) + } + + return ctrl.Result{}, nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *GitCommitStatusReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager) error { + // Use Direct methods to read configuration from the API server without cache during setup. + // The cache is not started during SetupWithManager, so we must use the non-cached API reader. + rateLimiter, err := settings.GetRateLimiterDirect[promoterv1alpha1.GitCommitStatusConfiguration, ctrl.Request](ctx, r.SettingsMgr) + if err != nil { + return fmt.Errorf("failed to get GitCommitStatus rate limiter: %w", err) + } + + maxConcurrentReconciles, err := settings.GetMaxConcurrentReconcilesDirect[promoterv1alpha1.GitCommitStatusConfiguration](ctx, r.SettingsMgr) + if err != nil { + return fmt.Errorf("failed to get GitCommitStatus max concurrent reconciles: %w", err) + } + + err = ctrl.NewControllerManagedBy(mgr). + For(&promoterv1alpha1.GitCommitStatus{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). + Watches(&promoterv1alpha1.PromotionStrategy{}, r.enqueueGitCommitStatusForPromotionStrategy()). + Named("gitcommitstatus"). + WithOptions(controller.Options{ + MaxConcurrentReconciles: maxConcurrentReconciles, + RateLimiter: rateLimiter, + }). + Complete(r) + if err != nil { + return fmt.Errorf("failed to create controller: %w", err) + } + return nil +} + +// CommitData represents the data structure available to expressions during validation. +type CommitData struct { + Trailers map[string][]string `expr:"Trailers"` + SHA string `expr:"SHA"` + Subject string `expr:"Subject"` + Body string `expr:"Body"` + Author string `expr:"Author"` + Committer string `expr:"Committer"` +} + +// getApplicableEnvironments returns the environments from the PromotionStrategy that this GitCommitStatus applies to. +// An environment is applicable if the GitCommitStatus key is referenced in either: +// - The global ps.Spec.ProposedCommitStatuses, or +// - The environment-specific psEnv.ProposedCommitStatuses +func (r *GitCommitStatusReconciler) getApplicableEnvironments(ps *promoterv1alpha1.PromotionStrategy, key string) []promoterv1alpha1.Environment { + // Check if globally referenced + globallyProposed := false + for _, selector := range ps.Spec.ProposedCommitStatuses { + if selector.Key == key { + globallyProposed = true + break + } + } + + applicable := make([]promoterv1alpha1.Environment, 0, len(ps.Spec.Environments)) + for _, env := range ps.Spec.Environments { + if globallyProposed { + applicable = append(applicable, env) + continue + } + for _, selector := range env.ProposedCommitStatuses { + if selector.Key == key { + applicable = append(applicable, env) + break + } + } + } + return applicable +} + +// processEnvironments processes each environment defined in the GitCommitStatus spec, +// evaluating expressions against the proposed hydrated commit for each environment. +// Returns a list of environment branches that transitioned from non-success to success +// and the CommitStatus objects created/updated. +func (r *GitCommitStatusReconciler) processEnvironments(ctx context.Context, gcs *promoterv1alpha1.GitCommitStatus, ps *promoterv1alpha1.PromotionStrategy) ([]string, []*promoterv1alpha1.CommitStatus, error) { + logger := log.FromContext(ctx) + + // Save the previous status before clearing it, so we can detect transitions + previousStatus := gcs.Status.DeepCopy() + if previousStatus == nil { + previousStatus = &promoterv1alpha1.GitCommitStatusStatus{} + } + + // Build a map of environment statuses for efficient lookup + envStatusMap := make(map[string]*promoterv1alpha1.EnvironmentStatus, len(ps.Status.Environments)) + for i := range ps.Status.Environments { + envStatusMap[ps.Status.Environments[i].Branch] = &ps.Status.Environments[i] + } + + // Get environments this GitCommitStatus applies to + applicableEnvs := r.getApplicableEnvironments(ps, gcs.Spec.Key) + + // Initialize tracking variables + transitionedEnvironments := make([]string, 0) + commitStatuses := make([]*promoterv1alpha1.CommitStatus, 0, len(applicableEnvs)) + gcs.Status.Environments = make([]promoterv1alpha1.GitCommitStatusEnvironmentStatus, 0, len(applicableEnvs)) + + for _, env := range applicableEnvs { + branch := env.Branch + + // Look up the environment status + envStatus, found := envStatusMap[branch] + if !found { + return nil, nil, fmt.Errorf("environment %q not found in PromotionStrategy status", branch) + } + + // Get the proposed and active hydrated SHAs for this environment + proposedSha := envStatus.Proposed.Hydrated.Sha + activeHydratedSha := envStatus.Active.Hydrated.Sha + + // Determine which commit SHA to validate based on the Target field + // The field is defaulted to "active" by the API server and validated to be "active" or "proposed" + shaToValidate := activeHydratedSha + if gcs.Spec.Target == "proposed" { + shaToValidate = proposedSha + } + + // Validate we have the SHA to work with - if PromotionStrategy hasn't fully reconciled, + // the SHA might be empty which would cause git operations to fail + if shaToValidate == "" { + return nil, nil, fmt.Errorf("commit SHA not yet available for branch %q (target=%s): PromotionStrategy may not be fully reconciled", branch, gcs.Spec.Target) + } + + // Get commit details for validation using the selected SHA + commitData, err := r.getCommitData(ctx, gcs, ps, shaToValidate, branch) + if err != nil { + return nil, nil, fmt.Errorf("failed to get commit data for branch %q at SHA %q: %w", branch, shaToValidate, err) + } + + // Evaluate the same expression for all environments + phase, expressionResult, err := r.evaluateExpression(gcs.Spec.Expression, commitData) + if err != nil { + return nil, nil, fmt.Errorf("failed to evaluate expression for branch %q: %w", branch, err) + } + + // Check if this validation transitioned to success + var previousPhase string + for _, prevEnv := range previousStatus.Environments { + if prevEnv.Branch == branch { + previousPhase = prevEnv.Phase + break + } + } + if previousPhase != string(promoterv1alpha1.CommitPhaseSuccess) && phase == string(promoterv1alpha1.CommitPhaseSuccess) { + transitionedEnvironments = append(transitionedEnvironments, branch) + logger.Info("Validation transitioned to success", + "branch", branch, + "sha", proposedSha) + } + + // Update status for this environment + envValidationStatus := promoterv1alpha1.GitCommitStatusEnvironmentStatus{ + Branch: branch, + ProposedHydratedSha: proposedSha, + ActiveHydratedSha: activeHydratedSha, + TargetedSha: shaToValidate, + Phase: phase, + ExpressionResult: expressionResult, + } + gcs.Status.Environments = append(gcs.Status.Environments, envValidationStatus) + + // Create or update the CommitStatus for the proposed hydrated SHA + // Use the same key from gcs.Spec.Key for all environments + cs, err := r.upsertCommitStatus(ctx, gcs, ps, branch, proposedSha, phase, gcs.Spec.Key) + if err != nil { + return nil, nil, fmt.Errorf("failed to upsert CommitStatus for environment %q: %w", branch, err) + } + commitStatuses = append(commitStatuses, cs) + + logger.Info("Processed environment validation", + "branch", branch, + "proposedSha", proposedSha, + "targetedSha", shaToValidate, + "target", gcs.Spec.Target, + "phase", phase, + "key", gcs.Spec.Key, + "expression", gcs.Spec.Expression) + } + + return transitionedEnvironments, commitStatuses, nil +} + +// getCommitData retrieves commit details from git for the given SHA. +func (r *GitCommitStatusReconciler) getCommitData(ctx context.Context, gcs *promoterv1alpha1.GitCommitStatus, ps *promoterv1alpha1.PromotionStrategy, sha string, branch string) (*CommitData, error) { + // Get the GitRepository and SCM provider + gitAuthProvider, repositoryRef, err := r.getGitAuthProvider(ctx, gcs, ps) + if err != nil { + return nil, fmt.Errorf("failed to get git auth provider: %w", err) + } + + gitRepo, err := utils.GetGitRepositoryFromObjectKey(ctx, r.Client, client.ObjectKey{Namespace: gcs.GetNamespace(), Name: repositoryRef.Name}) + if err != nil { + return nil, fmt.Errorf("failed to get GitRepository: %w", err) + } + + // Create environment operations for git access + envOps := git.NewEnvironmentOperations(gitRepo, gitAuthProvider, branch) + + // Clone the repo if needed + err = envOps.CloneRepo(ctx) + if err != nil { + return nil, fmt.Errorf("failed to clone repository: %w", err) + } + + // We don't need to explicitly fetch/checkout since GetShaMetadataFromGit will work with the SHA directly + + // Get commit metadata + commitMeta, err := envOps.GetShaMetadataFromGit(ctx, sha) + if err != nil { + return nil, fmt.Errorf("failed to get commit metadata for SHA %q: %w", sha, err) + } + + // Get author and committer emails + authorEmail, err := r.getCommitAuthorEmail(ctx, envOps, sha) + if err != nil { + return nil, fmt.Errorf("failed to get author email: %w", err) + } + + committerEmail, err := r.getCommitCommitterEmail(ctx, envOps, sha) + if err != nil { + return nil, fmt.Errorf("failed to get committer email: %w", err) + } + + // Get trailers using git interpret-trailers + trailers, err := envOps.GetTrailers(ctx, sha) + if err != nil { + return nil, fmt.Errorf("failed to get trailers: %w", err) + } + + return &CommitData{ + SHA: sha, + Subject: commitMeta.Subject, + Body: commitMeta.Body, + Author: authorEmail, + Committer: committerEmail, + Trailers: trailers, + }, nil +} + +// getCompiledExpression retrieves a compiled expression from the cache or compiles and caches it. +// This avoids recompiling the same expression on every reconciliation. +func (r *GitCommitStatusReconciler) getCompiledExpression(expression string) (*vm.Program, error) { + // Check cache first + if cached, ok := r.expressionCache.Load(expression); ok { + program, ok := cached.(*vm.Program) + if !ok { + return nil, errors.New("cached value is not a *vm.Program") + } + return program, nil + } + + // Compile with type information (using nil pointer provides type info without actual data) + env := map[string]any{ + "Commit": (*CommitData)(nil), + } + program, err := expr.Compile(expression, expr.Env(env), expr.AsBool()) + if err != nil { + return nil, fmt.Errorf("failed to compile expression: %w", err) + } + + // Store in cache + r.expressionCache.Store(expression, program) + return program, nil +} + +// evaluateExpression evaluates the configured expression against commit data. +// Returns the phase (success/failure) and the boolean result. +func (r *GitCommitStatusReconciler) evaluateExpression(expression string, commitData *CommitData) (string, *bool, error) { + // Get compiled expression from cache or compile it + program, err := r.getCompiledExpression(expression) + if err != nil { + return "", nil, fmt.Errorf("failed to compile expression: %w", err) + } + + // Run the expression with actual commit data + env := map[string]any{ + "Commit": commitData, + } + output, err := expr.Run(program, env) + if err != nil { + return "", nil, fmt.Errorf("failed to evaluate expression: %w", err) + } + + // Check the result + result, ok := output.(bool) + if !ok { + return "", nil, fmt.Errorf("expression must return boolean, got %T", output) + } + + if result { + return string(promoterv1alpha1.CommitPhaseSuccess), ptr.To(true), nil + } + return string(promoterv1alpha1.CommitPhaseFailure), ptr.To(false), nil +} + +// upsertCommitStatus creates or updates a CommitStatus resource for the validation result. +func (r *GitCommitStatusReconciler) upsertCommitStatus(ctx context.Context, gcs *promoterv1alpha1.GitCommitStatus, ps *promoterv1alpha1.PromotionStrategy, branch, sha, phase, validationName string) (*promoterv1alpha1.CommitStatus, error) { + // Generate a consistent name for the CommitStatus + commitStatusName := utils.KubeSafeUniqueName(ctx, fmt.Sprintf("%s-%s-%s", gcs.Name, branch, validationName)) + + commitStatus := promoterv1alpha1.CommitStatus{ + ObjectMeta: metav1.ObjectMeta{ + Name: commitStatusName, + Namespace: gcs.Namespace, + }, + } + + // Create or update the CommitStatus + _, err := ctrl.CreateOrUpdate(ctx, r.Client, &commitStatus, func() error { + // Set owner reference to the GitCommitStatus + if err := ctrl.SetControllerReference(gcs, &commitStatus, r.Scheme); err != nil { + return fmt.Errorf("failed to set controller reference: %w", err) + } + + // Set labels for easy identification + if commitStatus.Labels == nil { + commitStatus.Labels = make(map[string]string) + } + commitStatus.Labels["promoter.argoproj.io/git-commit-status"] = utils.KubeSafeLabel(gcs.Name) + commitStatus.Labels[promoterv1alpha1.EnvironmentLabel] = utils.KubeSafeLabel(branch) + commitStatus.Labels[promoterv1alpha1.CommitStatusLabel] = validationName + + // Convert phase string to CommitStatusPhase + var commitPhase promoterv1alpha1.CommitStatusPhase + switch phase { + case string(promoterv1alpha1.CommitPhaseSuccess): + commitPhase = promoterv1alpha1.CommitPhaseSuccess + case string(promoterv1alpha1.CommitPhaseFailure): + commitPhase = promoterv1alpha1.CommitPhaseFailure + default: + commitPhase = promoterv1alpha1.CommitPhasePending + } + + // Set the spec + commitStatus.Spec.RepositoryReference = ps.Spec.RepositoryReference + commitStatus.Spec.Name = validationName + "/" + branch + commitStatus.Spec.Description = gcs.Spec.Description + commitStatus.Spec.Phase = commitPhase + commitStatus.Spec.Sha = sha + + return nil + }) + if err != nil { + return nil, fmt.Errorf("failed to create or update CommitStatus: %w", err) + } + + return &commitStatus, nil +} + +// touchChangeTransferPolicies triggers reconciliation of the ChangeTransferPolicies +// for the environments that had validations transition to success. +// This triggers the ChangeTransferPolicy controller to reconcile and potentially merge PRs. +func (r *GitCommitStatusReconciler) touchChangeTransferPolicies(ctx context.Context, ps *promoterv1alpha1.PromotionStrategy, transitionedEnvironments []string) { + logger := log.FromContext(ctx) + + // For each transitioned environment, trigger reconciliation of the corresponding ChangeTransferPolicy + for _, envBranch := range transitionedEnvironments { + // Generate the ChangeTransferPolicy name using the same logic as the PromotionStrategy controller + ctpName := utils.KubeSafeUniqueName(ctx, utils.GetChangeTransferPolicyName(ps.Name, envBranch)) + + logger.Info("Triggering ChangeTransferPolicy reconciliation due to validation transition", + "changeTransferPolicy", ctpName, + "branch", envBranch) + + // Use the enqueue function to trigger reconciliation. + if r.EnqueueCTP != nil { + r.EnqueueCTP(ps.Namespace, ctpName) + } + } +} + +// getGitAuthProvider retrieves the git authentication provider for accessing the repository. +func (r *GitCommitStatusReconciler) getGitAuthProvider(ctx context.Context, gcs *promoterv1alpha1.GitCommitStatus, ps *promoterv1alpha1.PromotionStrategy) (scms.GitOperationsProvider, promoterv1alpha1.ObjectReference, error) { + scmProvider, secret, err := utils.GetScmProviderAndSecretFromRepositoryReference(ctx, r.Client, r.SettingsMgr.GetControllerNamespace(), ps.Spec.RepositoryReference, gcs) + if err != nil { + return nil, ps.Spec.RepositoryReference, fmt.Errorf("failed to get ScmProvider and secret for repo %q: %w", ps.Spec.RepositoryReference.Name, err) + } + + provider, err := gitauth.CreateGitOperationsProvider(ctx, r.Client, scmProvider, secret, client.ObjectKey{Namespace: gcs.Namespace, Name: ps.Spec.RepositoryReference.Name}) + if err != nil { + return nil, ps.Spec.RepositoryReference, fmt.Errorf("failed to create git operations provider: %w", err) + } + + return provider, ps.Spec.RepositoryReference, nil +} + +// enqueueGitCommitStatusForPromotionStrategy returns a handler that enqueues all GitCommitStatus resources +// that reference a PromotionStrategy when that PromotionStrategy changes. +func (r *GitCommitStatusReconciler) enqueueGitCommitStatusForPromotionStrategy() handler.EventHandler { + return handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []ctrl.Request { + ps, ok := obj.(*promoterv1alpha1.PromotionStrategy) + if !ok { + return nil + } + + // List all GitCommitStatus resources in the same namespace + var gcsList promoterv1alpha1.GitCommitStatusList + if err := r.List(ctx, &gcsList, client.InNamespace(ps.Namespace)); err != nil { + log.FromContext(ctx).Error(err, "failed to list GitCommitStatus resources") + return nil + } + + // Enqueue all GitCommitStatus resources that reference this PromotionStrategy + var requests []ctrl.Request + for _, gcs := range gcsList.Items { + if gcs.Spec.PromotionStrategyRef.Name == ps.Name { + requests = append(requests, ctrl.Request{ + NamespacedName: client.ObjectKeyFromObject(&gcs), + }) + } + } + + return requests + }) +} + +// getCommitAuthorEmail retrieves the author email for a commit. +func (r *GitCommitStatusReconciler) getCommitAuthorEmail(ctx context.Context, envOps *git.EnvironmentOperations, sha string) (string, error) { + email, err := envOps.GitShow(ctx, sha, "%ae") + if err != nil { + return "", fmt.Errorf("failed to get author email: %w", err) + } + return email, nil +} + +// getCommitCommitterEmail retrieves the committer email for a commit. +func (r *GitCommitStatusReconciler) getCommitCommitterEmail(ctx context.Context, envOps *git.EnvironmentOperations, sha string) (string, error) { + email, err := envOps.GitShow(ctx, sha, "%ce") + if err != nil { + return "", fmt.Errorf("failed to get committer email: %w", err) + } + return email, nil +} diff --git a/internal/controller/gitcommitstatus_controller_test.go b/internal/controller/gitcommitstatus_controller_test.go new file mode 100644 index 000000000..66a400898 --- /dev/null +++ b/internal/controller/gitcommitstatus_controller_test.go @@ -0,0 +1,794 @@ +/* +Copyright 2024. + +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 ( + "context" + "os" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + "github.com/argoproj-labs/gitops-promoter/internal/types/constants" + "github.com/argoproj-labs/gitops-promoter/internal/utils" + + promoterv1alpha1 "github.com/argoproj-labs/gitops-promoter/api/v1alpha1" +) + +var _ = Describe("GitCommitStatus Controller", Ordered, func() { + var ( + ctx context.Context + name string + scmSecret *v1.Secret + scmProvider *promoterv1alpha1.ScmProvider + gitRepo *promoterv1alpha1.GitRepository + promotionStrategy *promoterv1alpha1.PromotionStrategy + gitCommitStatus *promoterv1alpha1.GitCommitStatus + ) + + BeforeAll(func() { + ctx = context.Background() + + By("Setting up test git repository and resources") + name, scmSecret, scmProvider, gitRepo, _, _, promotionStrategy = promotionStrategyResource(ctx, "git-commit-status-test", "default") + + // Configure ProposedCommitStatuses to check for git commit status + promotionStrategy.Spec.ProposedCommitStatuses = []promoterv1alpha1.CommitStatusSelector{ + {Key: "test-validation"}, + } + + setupInitialTestGitRepoOnServer(ctx, name, name) + + Expect(k8sClient.Create(ctx, scmSecret)).To(Succeed()) + Expect(k8sClient.Create(ctx, scmProvider)).To(Succeed()) + Expect(k8sClient.Create(ctx, gitRepo)).To(Succeed()) + Expect(k8sClient.Create(ctx, promotionStrategy)).To(Succeed()) + + By("Waiting for PromotionStrategy to be reconciled with initial state") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: name, + Namespace: "default", + }, promotionStrategy) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(promotionStrategy.Status.Environments).To(HaveLen(3)) + // Ensure active hydrated SHAs are populated + for _, env := range promotionStrategy.Status.Environments { + g.Expect(env.Active.Hydrated.Sha).ToNot(BeEmpty(), "Active hydrated SHA should be set for "+env.Branch) + } + }, constants.EventuallyTimeout).Should(Succeed()) + }) + + AfterAll(func() { + By("Cleaning up test resources") + if promotionStrategy != nil { + _ = k8sClient.Delete(ctx, promotionStrategy) + } + if gitRepo != nil { + _ = k8sClient.Delete(ctx, gitRepo) + } + if scmProvider != nil { + _ = k8sClient.Delete(ctx, scmProvider) + } + if scmSecret != nil { + _ = k8sClient.Delete(ctx, scmSecret) + } + }) + + Describe("Basic Expression Evaluation", func() { + BeforeEach(func() { + By("Creating a GitCommitStatus with a simple passing expression") + gitCommitStatus = &promoterv1alpha1.GitCommitStatus{ + ObjectMeta: metav1.ObjectMeta{ + Name: name + "-simple", + Namespace: "default", + }, + Spec: promoterv1alpha1.GitCommitStatusSpec{ + PromotionStrategyRef: promoterv1alpha1.ObjectReference{ + Name: name, + }, + Key: "test-validation", + Description: "Test validation check", + Expression: `Commit.Author == Commit.Committer`, // Should pass - same author/committer + }, + } + Expect(k8sClient.Create(ctx, gitCommitStatus)).To(Succeed()) + }) + + AfterEach(func() { + if gitCommitStatus != nil { + _ = k8sClient.Delete(ctx, gitCommitStatus) + } + }) + + It("should evaluate the active commit and report success", func() { + By("Waiting for GitCommitStatus to process all environments") + Eventually(func(g Gomega) { + var gcs promoterv1alpha1.GitCommitStatus + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: name + "-simple", + Namespace: "default", + }, &gcs) + g.Expect(err).NotTo(HaveOccurred()) + + // Should have status for all three environments + g.Expect(gcs.Status.Environments).To(HaveLen(3)) + + // Verify each environment has proper status + for _, env := range gcs.Status.Environments { + g.Expect(env.Phase).To(Equal(string(promoterv1alpha1.CommitPhaseSuccess)), "Environment "+env.Branch+" should succeed") + g.Expect(env.ProposedHydratedSha).ToNot(BeEmpty(), "ProposedHydratedSha should be populated") + g.Expect(env.ActiveHydratedSha).ToNot(BeEmpty(), "ActiveHydratedSha should be populated") + g.Expect(env.ExpressionResult).ToNot(BeNil()) + g.Expect(*env.ExpressionResult).To(BeTrue()) + } + }, constants.EventuallyTimeout).Should(Succeed()) + + By("Verifying CommitStatus was created with custom description") + Eventually(func(g Gomega) { + commitStatusName := utils.KubeSafeUniqueName(ctx, name+"-simple-"+testEnvironmentDevelopment+"-test-validation") + var cs promoterv1alpha1.CommitStatus + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: commitStatusName, + Namespace: "default", + }, &cs) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(cs.Spec.Phase).To(Equal(promoterv1alpha1.CommitPhaseSuccess)) + g.Expect(cs.Spec.Description).To(Equal("Test validation check")) + g.Expect(cs.Spec.Name).To(Equal("test-validation/" + testEnvironmentDevelopment)) + }, constants.EventuallyTimeout).Should(Succeed()) + }) + }) + + Describe("Expression with Commit Subject and Body", func() { + BeforeEach(func() { + By("Creating a GitCommitStatus that checks commit subject") + gitCommitStatus = &promoterv1alpha1.GitCommitStatus{ + ObjectMeta: metav1.ObjectMeta{ + Name: name + "-subject", + Namespace: "default", + }, + Spec: promoterv1alpha1.GitCommitStatusSpec{ + PromotionStrategyRef: promoterv1alpha1.ObjectReference{ + Name: name, + }, + Key: "test-validation", + Expression: `Commit.Subject != ""`, // Check subject is not empty + }, + } + Expect(k8sClient.Create(ctx, gitCommitStatus)).To(Succeed()) + }) + + AfterEach(func() { + if gitCommitStatus != nil { + _ = k8sClient.Delete(ctx, gitCommitStatus) + } + }) + + It("should evaluate expression against commit subject field", func() { + By("Waiting for evaluation to complete") + Eventually(func(g Gomega) { + var gcs promoterv1alpha1.GitCommitStatus + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: name + "-subject", + Namespace: "default", + }, &gcs) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(gcs.Status.Environments).ToNot(BeEmpty()) + + // The subject should not be empty - should pass for all environments + for _, env := range gcs.Status.Environments { + g.Expect(env.Phase).To(Equal(string(promoterv1alpha1.CommitPhaseSuccess))) + g.Expect(env.ExpressionResult).ToNot(BeNil()) + g.Expect(*env.ExpressionResult).To(BeTrue()) + } + }, constants.EventuallyTimeout).Should(Succeed()) + }) + }) + + Describe("Failing Expression", func() { + BeforeEach(func() { + By("Creating a GitCommitStatus with an expression that always fails") + gitCommitStatus = &promoterv1alpha1.GitCommitStatus{ + ObjectMeta: metav1.ObjectMeta{ + Name: name + "-fail", + Namespace: "default", + }, + Spec: promoterv1alpha1.GitCommitStatusSpec{ + PromotionStrategyRef: promoterv1alpha1.ObjectReference{ + Name: name, + }, + Key: "test-validation", + Expression: `false`, // Always fails + }, + } + Expect(k8sClient.Create(ctx, gitCommitStatus)).To(Succeed()) + }) + + AfterEach(func() { + if gitCommitStatus != nil { + _ = k8sClient.Delete(ctx, gitCommitStatus) + } + }) + + It("should report failure status for all environments", func() { + By("Waiting for evaluation to complete") + Eventually(func(g Gomega) { + var gcs promoterv1alpha1.GitCommitStatus + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: name + "-fail", + Namespace: "default", + }, &gcs) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(gcs.Status.Environments).To(HaveLen(3)) + + for _, env := range gcs.Status.Environments { + g.Expect(env.Phase).To(Equal(string(promoterv1alpha1.CommitPhaseFailure))) + g.Expect(env.ExpressionResult).ToNot(BeNil()) + g.Expect(*env.ExpressionResult).To(BeFalse()) + } + }, constants.EventuallyTimeout).Should(Succeed()) + + By("Verifying CommitStatus was created with failure phase") + Eventually(func(g Gomega) { + commitStatusName := utils.KubeSafeUniqueName(ctx, name+"-fail-"+testEnvironmentDevelopment+"-test-validation") + var cs promoterv1alpha1.CommitStatus + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: commitStatusName, + Namespace: "default", + }, &cs) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(cs.Spec.Phase).To(Equal(promoterv1alpha1.CommitPhaseFailure)) + }, constants.EventuallyTimeout).Should(Succeed()) + }) + }) + + Describe("Invalid Expression", func() { + BeforeEach(func() { + By("Creating a GitCommitStatus with invalid expression syntax") + gitCommitStatus = &promoterv1alpha1.GitCommitStatus{ + ObjectMeta: metav1.ObjectMeta{ + Name: name + "-invalid", + Namespace: "default", + }, + Spec: promoterv1alpha1.GitCommitStatusSpec{ + PromotionStrategyRef: promoterv1alpha1.ObjectReference{ + Name: name, + }, + Key: "test-validation", + Expression: `Commit.Invalid..Field`, // Invalid syntax + }, + } + Expect(k8sClient.Create(ctx, gitCommitStatus)).To(Succeed()) + }) + + AfterEach(func() { + if gitCommitStatus != nil { + _ = k8sClient.Delete(ctx, gitCommitStatus) + } + }) + + It("should report failure with compilation error message", func() { + By("Waiting for the Ready condition to show the compilation error") + Eventually(func(g Gomega) { + var gcs promoterv1alpha1.GitCommitStatus + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: name + "-invalid", + Namespace: "default", + }, &gcs) + g.Expect(err).NotTo(HaveOccurred()) + + // The controller should error out due to expression compilation failure + // This will set Ready=False via HandleReconciliationResult + readyCondition := meta.FindStatusCondition(gcs.Status.Conditions, "Ready") + g.Expect(readyCondition).NotTo(BeNil()) + g.Expect(readyCondition.Status).To(Equal(metav1.ConditionFalse)) + g.Expect(readyCondition.Message).To(ContainSubstring("failed to evaluate expression")) + }, constants.EventuallyTimeout).Should(Succeed()) + }) + }) + + Describe("Missing PromotionStrategy", func() { + It("should handle missing PromotionStrategy gracefully", func() { + By("Creating a GitCommitStatus referencing non-existent PromotionStrategy") + gcs := &promoterv1alpha1.GitCommitStatus{ + ObjectMeta: metav1.ObjectMeta{ + Name: name + "-missing-ps", + Namespace: "default", + }, + Spec: promoterv1alpha1.GitCommitStatusSpec{ + PromotionStrategyRef: promoterv1alpha1.ObjectReference{ + Name: "non-existent", + }, + Key: "test-validation", + Expression: `true`, + }, + } + Expect(k8sClient.Create(ctx, gcs)).To(Succeed()) + defer func() { + _ = k8sClient.Delete(ctx, gcs) + }() + + // The controller should handle this gracefully - it will error but not crash + Consistently(func(g Gomega) { + var retrieved promoterv1alpha1.GitCommitStatus + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: name + "-missing-ps", + Namespace: "default", + }, &retrieved) + g.Expect(err).NotTo(HaveOccurred()) + // Status should remain empty since PromotionStrategy doesn't exist + g.Expect(retrieved.Status.Environments).To(BeEmpty()) + }, "5s", "1s").Should(Succeed()) + }) + }) + + Describe("CommitStatus Ownership and Cleanup", func() { + BeforeEach(func() { + By("Creating a GitCommitStatus") + gitCommitStatus = &promoterv1alpha1.GitCommitStatus{ + ObjectMeta: metav1.ObjectMeta{ + Name: name + "-cleanup", + Namespace: "default", + }, + Spec: promoterv1alpha1.GitCommitStatusSpec{ + PromotionStrategyRef: promoterv1alpha1.ObjectReference{ + Name: name, + }, + Key: "test-validation", + Expression: `true`, + }, + } + Expect(k8sClient.Create(ctx, gitCommitStatus)).To(Succeed()) + + By("Waiting for CommitStatus to be created") + Eventually(func(g Gomega) { + commitStatusName := utils.KubeSafeUniqueName(ctx, name+"-cleanup-"+testEnvironmentDevelopment+"-test-validation") + var cs promoterv1alpha1.CommitStatus + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: commitStatusName, + Namespace: "default", + }, &cs) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(cs.OwnerReferences).ToNot(BeEmpty()) + g.Expect(cs.OwnerReferences[0].Name).To(Equal(name + "-cleanup")) + }, constants.EventuallyTimeout).Should(Succeed()) + }) + + // Test marked as pending due to garbage collection timing in test environment + // The ownership relationship is correctly established (tested above), + // but K8s GC in test env may not cleanup within reasonable timeout + PIt("should cleanup CommitStatus resources when GitCommitStatus is deleted", func() { + commitStatusName := utils.KubeSafeUniqueName(ctx, name+"-cleanup-"+testEnvironmentDevelopment+"-test-validation") + + By("Deleting the GitCommitStatus") + Expect(k8sClient.Delete(ctx, gitCommitStatus)).To(Succeed()) + + By("Waiting for CommitStatus to be garbage collected") + Eventually(func() bool { + var cs promoterv1alpha1.CommitStatus + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: commitStatusName, + Namespace: "default", + }, &cs) + return errors.IsNotFound(err) + }, "2m", "1s").Should(BeTrue(), "CommitStatus should be deleted by garbage collector") + }) + }) + + Describe("Multiple Environments", func() { + It("should evaluate independently for each environment", func() { + By("Creating a GitCommitStatus that applies to all environments") + gcs := &promoterv1alpha1.GitCommitStatus{ + ObjectMeta: metav1.ObjectMeta{ + Name: name + "-multi-env", + Namespace: "default", + }, + Spec: promoterv1alpha1.GitCommitStatusSpec{ + PromotionStrategyRef: promoterv1alpha1.ObjectReference{ + Name: name, + }, + Key: "test-validation", + Expression: `true`, // Always passes + }, + } + Expect(k8sClient.Create(ctx, gcs)).To(Succeed()) + defer func() { + _ = k8sClient.Delete(ctx, gcs) + }() + + By("Verifying all three environments are evaluated") + Eventually(func(g Gomega) { + var retrieved promoterv1alpha1.GitCommitStatus + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: name + "-multi-env", + Namespace: "default", + }, &retrieved) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(retrieved.Status.Environments).To(HaveLen(3)) + + // All should succeed + for _, env := range retrieved.Status.Environments { + g.Expect(env.Phase).To(Equal(string(promoterv1alpha1.CommitPhaseSuccess))) + } + }, constants.EventuallyTimeout).Should(Succeed()) + + By("Verifying CommitStatus created for each environment") + for _, envBranch := range []string{testEnvironmentDevelopment, testEnvironmentStaging, testEnvironmentProduction} { + Eventually(func(g Gomega) { + commitStatusName := utils.KubeSafeUniqueName(ctx, name+"-multi-env-"+envBranch+"-test-validation") + var cs promoterv1alpha1.CommitStatus + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: commitStatusName, + Namespace: "default", + }, &cs) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(cs.Spec.Phase).To(Equal(promoterv1alpha1.CommitPhaseSuccess)) + }, constants.EventuallyTimeout).Should(Succeed()) + } + }) + }) + + Describe("Default Description Behavior", func() { + It("should use empty description when not specified", func() { + By("Creating a GitCommitStatus without description") + gcs := &promoterv1alpha1.GitCommitStatus{ + ObjectMeta: metav1.ObjectMeta{ + Name: name + "-no-desc", + Namespace: "default", + }, + Spec: promoterv1alpha1.GitCommitStatusSpec{ + PromotionStrategyRef: promoterv1alpha1.ObjectReference{ + Name: name, + }, + Key: "test-validation", + Expression: `true`, + // Description not set + }, + } + Expect(k8sClient.Create(ctx, gcs)).To(Succeed()) + defer func() { + _ = k8sClient.Delete(ctx, gcs) + }() + + By("Verifying CommitStatus has empty description") + Eventually(func(g Gomega) { + commitStatusName := utils.KubeSafeUniqueName(ctx, name+"-no-desc-"+testEnvironmentDevelopment+"-test-validation") + var cs promoterv1alpha1.CommitStatus + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: commitStatusName, + Namespace: "default", + }, &cs) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(cs.Spec.Description).To(BeEmpty()) + }, constants.EventuallyTimeout).Should(Succeed()) + }) + }) + + Describe("Active vs Proposed SHA Validation", func() { + It("should give different validation results when active and proposed commits have different properties", func() { + By("Updating git repo to create a commit with a different subject") + gitPath, err := os.MkdirTemp("", "*") + Expect(err).NotTo(HaveOccurred()) + defer func() { + _ = os.RemoveAll(gitPath) + }() + + // Make a change with hydrated commit message that starts with "feat:" + // Note: dryCommitMessage is for dry branch, hydratedCommitMessage is for hydrated branches (what we validate) + makeChangeAndHydrateRepo(gitPath, name, name, "new commit", "feat: new feature content") + + By("Waiting for PromotionStrategy to update") + var developmentProposedSHA string + var developmentActiveSHA string + Eventually(func(g Gomega) { + var ps promoterv1alpha1.PromotionStrategy + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: name, + Namespace: "default", + }, &ps) + g.Expect(err).NotTo(HaveOccurred()) + + // Check development environment + for _, env := range ps.Status.Environments { + if env.Branch == testEnvironmentDevelopment { + g.Expect(env.Proposed.Hydrated.Sha).ToNot(BeEmpty()) + g.Expect(env.Active.Hydrated.Sha).ToNot(BeEmpty()) + developmentProposedSHA = env.Proposed.Hydrated.Sha + developmentActiveSHA = env.Active.Hydrated.Sha + } + } + g.Expect(developmentProposedSHA).ToNot(BeEmpty()) + g.Expect(developmentActiveSHA).ToNot(BeEmpty()) + }, constants.EventuallyTimeout).Should(Succeed()) + + By("Creating GitCommitStatus that checks for 'feat:' prefix with active mode") + gcsActive := &promoterv1alpha1.GitCommitStatus{ + ObjectMeta: metav1.ObjectMeta{ + Name: name + "-active-feat-check", + Namespace: "default", + }, + Spec: promoterv1alpha1.GitCommitStatusSpec{ + PromotionStrategyRef: promoterv1alpha1.ObjectReference{ + Name: name, + }, + Key: "test-validation", + Target: "active", + // Check if commit subject startsWith "feat:" + Expression: `Commit.Subject startsWith "feat:"`, + }, + } + Expect(k8sClient.Create(ctx, gcsActive)).To(Succeed()) + + By("Verifying active mode result depends on active SHA and expression fails") + var activePhase string + Eventually(func(g Gomega) { + var retrieved promoterv1alpha1.GitCommitStatus + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: name + "-active-feat-check", + Namespace: "default", + }, &retrieved) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(retrieved.Status.Environments).ToNot(BeEmpty()) + + var devEnv *promoterv1alpha1.GitCommitStatusEnvironmentStatus + for i, env := range retrieved.Status.Environments { + if env.Branch == testEnvironmentDevelopment { + devEnv = &retrieved.Status.Environments[i] + break + } + } + g.Expect(devEnv).ToNot(BeNil()) + + // Verify it's checking the active SHA + g.Expect(devEnv.TargetedSha).To(Equal(developmentActiveSHA)) + g.Expect(devEnv.Phase).ToNot(BeEmpty()) + activePhase = devEnv.Phase + + // Active commit doesn't have "feat:" prefix, so expression should fail + g.Expect(devEnv.Phase).To(Equal(string(promoterv1alpha1.CommitPhaseFailure))) + g.Expect(devEnv.ExpressionResult).ToNot(BeNil()) + g.Expect(*devEnv.ExpressionResult).To(BeFalse()) + }, constants.EventuallyTimeout).Should(Succeed()) + + By("Deleting active mode GitCommitStatus") + Expect(k8sClient.Delete(ctx, gcsActive)).To(Succeed()) + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: name + "-active-feat-check", + Namespace: "default", + }, &promoterv1alpha1.GitCommitStatus{}) + g.Expect(errors.IsNotFound(err)).To(BeTrue()) + }, constants.EventuallyTimeout).Should(Succeed()) + + By("Creating GitCommitStatus that checks for 'feat:' prefix with proposed mode") + gcsProposed := &promoterv1alpha1.GitCommitStatus{ + ObjectMeta: metav1.ObjectMeta{ + Name: name + "-proposed-feat-check", + Namespace: "default", + }, + Spec: promoterv1alpha1.GitCommitStatusSpec{ + PromotionStrategyRef: promoterv1alpha1.ObjectReference{ + Name: name, + }, + Key: "test-validation", + Target: "proposed", + // Check if commit subject starts with "feat:" + Expression: `Commit.Subject startsWith "feat:"`, + }, + } + Expect(k8sClient.Create(ctx, gcsProposed)).To(Succeed()) + defer func() { + _ = k8sClient.Delete(ctx, gcsProposed) + }() + + By("Verifying proposed mode gives success (proposed SHA has 'feat:' prefix)") + Eventually(func(g Gomega) { + var retrieved promoterv1alpha1.GitCommitStatus + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: name + "-proposed-feat-check", + Namespace: "default", + }, &retrieved) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(retrieved.Status.Environments).ToNot(BeEmpty()) + + var devEnv *promoterv1alpha1.GitCommitStatusEnvironmentStatus + for i, env := range retrieved.Status.Environments { + if env.Branch == testEnvironmentDevelopment { + devEnv = &retrieved.Status.Environments[i] + break + } + } + g.Expect(devEnv).ToNot(BeNil()) + + // Verify it's checking the proposed SHA + g.Expect(devEnv.TargetedSha).To(Equal(developmentProposedSHA)) + // Proposed commit subject starts with "feat:", so should succeed + g.Expect(devEnv.Phase).To(Equal(string(promoterv1alpha1.CommitPhaseSuccess))) + g.Expect(devEnv.ExpressionResult).ToNot(BeNil()) + g.Expect(*devEnv.ExpressionResult).To(BeTrue()) + }, constants.EventuallyTimeout).Should(Succeed()) + + By("Verifying the two modes can give different results") + // If active and proposed SHAs are different and have different properties, + // the validation results should differ. Here, proposed will succeed with "feat:" prefix + // while active may or may not depending on whether it's been promoted + Expect(activePhase).ToNot(BeEmpty(), "Active validation should have completed") + }) + }) + + Describe("Revert Detection Flow", func() { + It("should detect reverts on staging and fail the commit status", func() { + By("Creating a GitCommitStatus that detects 'Revert' prefix on active commits") + // Use the existing "test-validation" key from BeforeAll setup + gcsRevertCheck := &promoterv1alpha1.GitCommitStatus{ + ObjectMeta: metav1.ObjectMeta{ + Name: name + "-revert-check", + Namespace: "default", + }, + Spec: promoterv1alpha1.GitCommitStatusSpec{ + PromotionStrategyRef: promoterv1alpha1.ObjectReference{ + Name: name, + }, + Key: "test-validation", + Target: "active", + // Check if commit subject does NOT start with "Revert" - if it does, fail + Expression: `!(Commit.Subject startsWith "Revert")`, + }, + } + Expect(k8sClient.Create(ctx, gcsRevertCheck)).To(Succeed()) + defer func() { + _ = k8sClient.Delete(ctx, gcsRevertCheck) + }() + + By("Waiting for initial validation to pass on all environments (no reverts yet)") + Eventually(func(g Gomega) { + var retrieved promoterv1alpha1.GitCommitStatus + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: name + "-revert-check", + Namespace: "default", + }, &retrieved) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(retrieved.Status.Environments).To(HaveLen(3)) + + // All environments should pass initially (no reverts) + for _, env := range retrieved.Status.Environments { + g.Expect(env.Phase).To(Equal(string(promoterv1alpha1.CommitPhaseSuccess)), + "Environment %s should pass initially", env.Branch) + } + }, constants.EventuallyTimeout).Should(Succeed()) + + By("Simulating a revert on the staging active branch using git revert") + gitPath, err := os.MkdirTemp("", "*") + Expect(err).NotTo(HaveOccurred()) + defer func() { + _ = os.RemoveAll(gitPath) + }() + + // Clone the repo + repoURL := "http://localhost:" + gitServerPort + "/" + name + "/" + name + _, err = runGitCmd(ctx, gitPath, "clone", "--verbose", "--progress", "--filter=blob:none", repoURL, ".") + Expect(err).NotTo(HaveOccurred()) + + _, err = runGitCmd(ctx, gitPath, "config", "user.name", "testuser") + Expect(err).NotTo(HaveOccurred()) + _, err = runGitCmd(ctx, gitPath, "config", "user.email", "testmail@test.com") + Expect(err).NotTo(HaveOccurred()) + + // Checkout the staging active branch + _, err = runGitCmd(ctx, gitPath, "checkout", "-B", testEnvironmentStaging, "origin/"+testEnvironmentStaging) + Expect(err).NotTo(HaveOccurred()) + + // First, create a commit that we can revert + f, err := os.Create(gitPath + "/feature-to-revert.yaml") + Expect(err).NotTo(HaveOccurred()) + _, err = f.WriteString("feature: enabled\n") + Expect(err).NotTo(HaveOccurred()) + err = f.Close() + Expect(err).NotTo(HaveOccurred()) + + _, err = runGitCmd(ctx, gitPath, "add", "feature-to-revert.yaml") + Expect(err).NotTo(HaveOccurred()) + _, err = runGitCmd(ctx, gitPath, "commit", "-m", "feat: add new feature") + Expect(err).NotTo(HaveOccurred()) + + // Get the SHA of the commit we just created (to revert it) + commitToRevert, err := runGitCmd(ctx, gitPath, "rev-parse", "HEAD") + Expect(err).NotTo(HaveOccurred()) + commitToRevert = strings.TrimSpace(commitToRevert) + + // Get the SHA before we make the revert for the webhook + beforeSha, err := runGitCmd(ctx, gitPath, "rev-parse", testEnvironmentStaging) + Expect(err).NotTo(HaveOccurred()) + beforeSha = strings.TrimSpace(beforeSha) + + // Use actual git revert command - this creates a commit with "Revert" prefix automatically + _, err = runGitCmd(ctx, gitPath, "revert", "--no-edit", commitToRevert) + Expect(err).NotTo(HaveOccurred()) + + // Push the revert commit + _, err = runGitCmd(ctx, gitPath, "push", "-u", "origin", testEnvironmentStaging) + Expect(err).NotTo(HaveOccurred()) + + // Send webhook to trigger reconciliation + sendWebhookForPush(ctx, beforeSha, testEnvironmentStaging) + + By("Verifying staging environment fails due to revert detection") + Eventually(func(g Gomega) { + var retrieved promoterv1alpha1.GitCommitStatus + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: name + "-revert-check", + Namespace: "default", + }, &retrieved) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(retrieved.Status.Environments).To(HaveLen(3)) + + var stagingEnv *promoterv1alpha1.GitCommitStatusEnvironmentStatus + var devEnv *promoterv1alpha1.GitCommitStatusEnvironmentStatus + var prodEnv *promoterv1alpha1.GitCommitStatusEnvironmentStatus + + for i, env := range retrieved.Status.Environments { + switch env.Branch { + case testEnvironmentDevelopment: + devEnv = &retrieved.Status.Environments[i] + case testEnvironmentStaging: + stagingEnv = &retrieved.Status.Environments[i] + case testEnvironmentProduction: + prodEnv = &retrieved.Status.Environments[i] + default: + // Ignore unknown environments + } + } + + g.Expect(stagingEnv).ToNot(BeNil(), "Staging environment should exist") + g.Expect(devEnv).ToNot(BeNil(), "Development environment should exist") + g.Expect(prodEnv).ToNot(BeNil(), "Production environment should exist") + + // Staging should fail because active commit starts with "Revert" + g.Expect(stagingEnv.Phase).To(Equal(string(promoterv1alpha1.CommitPhaseFailure)), + "Staging should fail due to revert commit") + g.Expect(stagingEnv.ExpressionResult).ToNot(BeNil()) + g.Expect(*stagingEnv.ExpressionResult).To(BeFalse(), + "Expression should evaluate to false for revert commit") + + // Development and Production should still pass (no reverts on those branches) + g.Expect(devEnv.Phase).To(Equal(string(promoterv1alpha1.CommitPhaseSuccess)), + "Development should still pass") + g.Expect(prodEnv.Phase).To(Equal(string(promoterv1alpha1.CommitPhaseSuccess)), + "Production should still pass") + }, constants.EventuallyTimeout).Should(Succeed()) + + By("Verifying the CommitStatus for staging shows failure") + Eventually(func(g Gomega) { + commitStatusName := utils.KubeSafeUniqueName(ctx, name+"-revert-check-"+testEnvironmentStaging+"-test-validation") + var cs promoterv1alpha1.CommitStatus + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: commitStatusName, + Namespace: "default", + }, &cs) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(cs.Spec.Phase).To(Equal(promoterv1alpha1.CommitPhaseFailure)) + }, constants.EventuallyTimeout).Should(Succeed()) + }) + }) +}) diff --git a/internal/controller/promotionstrategy_controller_test.go b/internal/controller/promotionstrategy_controller_test.go index 4422b4569..ba722cce4 100644 --- a/internal/controller/promotionstrategy_controller_test.go +++ b/internal/controller/promotionstrategy_controller_test.go @@ -3103,7 +3103,8 @@ var _ = Describe("PromotionStrategy Bug Tests", func() { if promotionStrategy.Annotations == nil { promotionStrategy.Annotations = make(map[string]string) } - promotionStrategy.Annotations[promoterv1alpha1.ReconcileAtAnnotation] = metav1.Now().Format(time.RFC3339) + // Use a test annotation to trigger reconciliation by modifying the object + promotionStrategy.Annotations["test-trigger"] = metav1.Now().Format(time.RFC3339) err = k8sClient.Patch(ctx, promotionStrategy, client.MergeFrom(orig)) g.Expect(err).To(Succeed()) }, constants.EventuallyTimeout).Should(Succeed()) diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index 6d01f7783..ea2b6daec 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -311,6 +311,28 @@ var _ = BeforeSuite(func() { }, }, }, + GitCommitStatus: promoterv1alpha1.GitCommitStatusConfiguration{ + WorkQueue: promoterv1alpha1.WorkQueue{ + RequeueDuration: metav1.Duration{Duration: time.Minute * 5}, + MaxConcurrentReconciles: 10, + RateLimiter: promoterv1alpha1.RateLimiter{ + MaxOf: []promoterv1alpha1.RateLimiterTypes{ + { + Bucket: &promoterv1alpha1.Bucket{ + Qps: 10, + Bucket: 100, + }, + }, + { + ExponentialFailure: &promoterv1alpha1.ExponentialFailure{ + BaseDelay: metav1.Duration{Duration: time.Millisecond * 5}, + MaxDelay: metav1.Duration{Duration: time.Minute * 1}, + }, + }, + }, + }, + }, + }, }, } Expect(k8sClient.Create(ctx, controllerConfiguration)).To(Succeed()) @@ -402,6 +424,14 @@ var _ = BeforeSuite(func() { }).SetupWithManager(ctx, multiClusterManager) Expect(err).ToNot(HaveOccurred()) + err = (&GitCommitStatusReconciler{ + Client: k8sManager.GetClient(), + Scheme: k8sManager.GetScheme(), + Recorder: k8sManager.GetEventRecorderFor("GitCommitStatus"), + SettingsMgr: settingsMgr, + }).SetupWithManager(ctx, k8sManager) + Expect(err).ToNot(HaveOccurred()) + webhookReceiverPort = constants.WebhookReceiverPort + GinkgoParallelProcess() whr := webhookreceiver.NewWebhookReceiver(k8sManager, webhookreceiver.EnqueueFunc(ctpReconciler.GetEnqueueFunc())) go func() { diff --git a/internal/git/git.go b/internal/git/git.go index 3c490c994..8d8687f47 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -571,7 +571,8 @@ func (g *EnvironmentOperations) GetHydratorNote(ctx context.Context, sha string) } // GetTrailers retrieves the trailers from the last commit in the repository using git interpret-trailers. -func (g *EnvironmentOperations) GetTrailers(ctx context.Context, sha string) (map[string]string, error) { +// Returns a map where each key can have multiple values (e.g., multiple "Signed-off-by" trailers). +func (g *EnvironmentOperations) GetTrailers(ctx context.Context, sha string) (map[string][]string, error) { logger := log.FromContext(ctx) // run git interpret-trailers to get the trailers from the last commit gitPath := gitpaths.Get(g.gap.GetGitHttpsRepoUrl(*g.gitRepo) + g.activeBranch) @@ -607,12 +608,14 @@ func (g *EnvironmentOperations) GetTrailers(ctx context.Context, sha string) (ma stdout := stdoutBuf.String() lines := strings.Split(strings.TrimSpace(stdout), "\n") - trailers := make(map[string]string, len(lines)) + trailers := make(map[string][]string) for _, line := range lines { if strings.Contains(line, ":") { key, value, found := strings.Cut(line, ":") if found { - trailers[strings.TrimSpace(key)] = strings.TrimSpace(value) + trimmedKey := strings.TrimSpace(key) + trimmedValue := strings.TrimSpace(value) + trailers[trimmedKey] = append(trailers[trimmedKey], trimmedValue) } else { logger.Error(fmt.Errorf("invalid trailer line: %s", line), "could not parse trailer line") } @@ -621,3 +624,23 @@ func (g *EnvironmentOperations) GetTrailers(ctx context.Context, sha string) (ma logger.V(4).Info("Got trailers", "sha", sha, "trailers", trailers) return trailers, nil } + +// GitShow runs git show with a specific format string for a commit SHA. +// The format parameter uses git's pretty-format placeholders (e.g., %ae for author email, %ce for committer email). +// See https://git-scm.com/docs/git-show#_pretty_formats for available format options. +func (g *EnvironmentOperations) GitShow(ctx context.Context, sha, format string) (string, error) { + logger := log.FromContext(ctx) + gitPath := gitpaths.Get(g.gap.GetGitHttpsRepoUrl(*g.gitRepo) + g.activeBranch) + if gitPath == "" { + return "", fmt.Errorf("no repo path found for repo %q", g.gitRepo.Name) + } + + stdout, stderr, err := g.runCmd(ctx, gitPath, "show", "-s", "--format="+format, sha) + if err != nil { + logger.Error(err, "could not git show", "gitError", stderr, "sha", sha, "format", format) + return "", fmt.Errorf("failed to run git show for sha %q with format %q: %w", sha, format, err) + } + logger.V(4).Info("Git show completed", "sha", sha, "format", format) + + return strings.TrimSpace(stdout), nil +} diff --git a/internal/gitauth/provider.go b/internal/gitauth/provider.go new file mode 100644 index 000000000..bc53cbcd7 --- /dev/null +++ b/internal/gitauth/provider.go @@ -0,0 +1,76 @@ +package gitauth + +import ( + "context" + "errors" + "fmt" + + "github.com/argoproj-labs/gitops-promoter/api/v1alpha1" + "github.com/argoproj-labs/gitops-promoter/internal/scms" + bitbucket_cloud "github.com/argoproj-labs/gitops-promoter/internal/scms/bitbucket_cloud" + "github.com/argoproj-labs/gitops-promoter/internal/scms/fake" + "github.com/argoproj-labs/gitops-promoter/internal/scms/forgejo" + "github.com/argoproj-labs/gitops-promoter/internal/scms/github" + "github.com/argoproj-labs/gitops-promoter/internal/scms/gitlab" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +// CreateGitOperationsProvider creates the appropriate git operations provider based on the SCM provider type. +// This is a shared utility function used by multiple controllers to avoid code duplication. +// +// Parameters: +// - ctx: Context for logging and cancellation +// - k8sClient: Kubernetes client for fetching resources (needed by GitHub provider) +// - scmProvider: The SCM provider configuration (GitHub, GitLab, Forgejo, Bitbucket Cloud, or Fake) +// - secret: The secret containing authentication credentials +// - repoObjectKey: The namespace/name reference to the GitRepository resource +// +// Returns the GitOperationsProvider interface implementation and any error encountered. +func CreateGitOperationsProvider( + ctx context.Context, + k8sClient client.Client, + scmProvider v1alpha1.GenericScmProvider, + secret *corev1.Secret, + repoObjectKey client.ObjectKey, +) (scms.GitOperationsProvider, error) { + logger := log.FromContext(ctx) + + switch { + case scmProvider.GetSpec().Fake != nil: + logger.V(4).Info("Creating fake git authentication provider") + return fake.NewFakeGitAuthenticationProvider(scmProvider, secret), nil + + case scmProvider.GetSpec().GitHub != nil: + logger.V(4).Info("Creating GitHub git authentication provider") + provider, err := github.NewGithubGitAuthenticationProvider(ctx, k8sClient, scmProvider, secret, repoObjectKey) + if err != nil { + return nil, fmt.Errorf("failed to create GitHub Auth Provider: %w", err) + } + return provider, nil + + case scmProvider.GetSpec().GitLab != nil: + logger.V(4).Info("Creating GitLab git authentication provider") + provider, err := gitlab.NewGitlabGitAuthenticationProvider(scmProvider, secret) + if err != nil { + return nil, fmt.Errorf("failed to create GitLab Auth Provider: %w", err) + } + return provider, nil + + case scmProvider.GetSpec().Forgejo != nil: + logger.V(4).Info("Creating Forgejo git authentication provider") + return forgejo.NewForgejoGitAuthenticationProvider(scmProvider, secret), nil + + case scmProvider.GetSpec().BitbucketCloud != nil: + logger.V(4).Info("Creating Bitbucket Cloud git authentication provider") + provider, err := bitbucket_cloud.NewBitbucketCloudGitAuthenticationProvider(scmProvider, secret) + if err != nil { + return nil, fmt.Errorf("failed to create Bitbucket Cloud Auth Provider: %w", err) + } + return provider, nil + + default: + return nil, errors.New("no supported git authentication provider found") + } +} diff --git a/internal/settings/manager.go b/internal/settings/manager.go index 3892636fa..31c76b3e9 100644 --- a/internal/settings/manager.go +++ b/internal/settings/manager.go @@ -28,13 +28,15 @@ const ( // - CommitStatusConfiguration // - ArgoCDCommitStatusConfiguration // - TimedCommitStatusConfiguration +// - GitCommitStatusConfiguration type ControllerConfigurationTypes interface { promoterv1alpha1.PromotionStrategyConfiguration | promoterv1alpha1.ChangeTransferPolicyConfiguration | promoterv1alpha1.PullRequestConfiguration | promoterv1alpha1.CommitStatusConfiguration | promoterv1alpha1.ArgoCDCommitStatusConfiguration | - promoterv1alpha1.TimedCommitStatusConfiguration + promoterv1alpha1.TimedCommitStatusConfiguration | + promoterv1alpha1.GitCommitStatusConfiguration } // ControllerResultTypes is a constraint that defines the set of result types returned by controller @@ -277,6 +279,8 @@ func getWorkQueueForController[T ControllerConfigurationTypes](ctx context.Conte return config.Spec.ArgoCDCommitStatus.WorkQueue, nil case promoterv1alpha1.TimedCommitStatusConfiguration: return config.Spec.TimedCommitStatus.WorkQueue, nil + case promoterv1alpha1.GitCommitStatusConfiguration: + return config.Spec.GitCommitStatus.WorkQueue, nil default: return promoterv1alpha1.WorkQueue{}, fmt.Errorf("unsupported configuration type: %T", cfg) } diff --git a/mkdocs.yml b/mkdocs.yml index f3299c461..99696878b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -33,6 +33,7 @@ nav: - Gating Promotions: gating-promotions.md - CommitStatus Controllers: - Argo CD: commit-status-controllers/argocd.md + - Git Commit: commit-status-controllers/git-commit.md - Timed: commit-status-controllers/timed.md - Development Best Practices: commit-status-controllers/development-best-practices.md - Multi-Tenancy: multi-tenancy.md