diff --git a/api/v1/annotations.go b/api/v1/annotations.go
index 0f06dd7a..05d80c4d 100644
--- a/api/v1/annotations.go
+++ b/api/v1/annotations.go
@@ -4,4 +4,18 @@ const (
// GitLabCITemplateAnnotation is an annotation on a Codebase CR that specifies the ConfigMap
// name to use as the GitLab CI template. When absent, the operator falls back to "gitlab-ci-default".
GitLabCITemplateAnnotation = "app.edp.epam.com/gitlab-ci-template"
+
+ // BranchCleanupStrategyAnnotation is an annotation on a Codebase CR that defines what the
+ // operator does with CodebaseBranch resources whose branch no longer exists in git.
+ // Supported values: "mark" (default) - only mark the branch as stale;
+ // "auto" - delete the stale branch when it is not referenced by any CDPipeline/Stage.
+ BranchCleanupStrategyAnnotation = "app.edp.epam.com/branch-cleanup-strategy"
+)
+
+const (
+ // BranchCleanupStrategyMark marks stale branches for manual cleanup.
+ BranchCleanupStrategyMark = "mark"
+
+ // BranchCleanupStrategyAuto deletes stale branches that are not used by any CDPipeline/Stage.
+ BranchCleanupStrategyAuto = "auto"
)
diff --git a/api/v1/codebasebranch_types.go b/api/v1/codebasebranch_types.go
index 3e86aa11..2efbedaa 100644
--- a/api/v1/codebasebranch_types.go
+++ b/api/v1/codebasebranch_types.go
@@ -6,6 +6,13 @@ import (
const (
CodebaseBranchGitStatusBranchCreated = "branch-created"
+
+ // ConditionStale is a condition type indicating whether the branch
+ // no longer exists in the git repository ("True" means the branch is missing).
+ ConditionStale = "Stale"
+
+ ReasonBranchNotFoundInGit = "BranchNotFoundInGit"
+ ReasonBranchFoundInGit = "BranchFoundInGit"
)
// CodebaseBranchSpec defines the desired state of CodebaseBranch.
@@ -83,6 +90,17 @@ type CodebaseBranchStatus struct {
// Specifies a status of action for git.
Git string `json:"git,omitempty"`
+
+ // Conditions represent the latest available observations of an object's state.
+ // Each condition type is an independent observation (e.g. Stale); new types can be
+ // added over time without schema changes.
+ // +optional
+ // +nullable
+ // +listType=map
+ // +listMapKey=type
+ // +patchStrategy=merge
+ // +patchMergeKey=type
+ Conditions []metaV1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"`
}
// +kubebuilder:object:root=true
@@ -94,6 +112,7 @@ type CodebaseBranchStatus struct {
// +kubebuilder:printcolumn:name="Codebase Name",type="string",JSONPath=".spec.codebaseName",description="Owner of object"
// +kubebuilder:printcolumn:name="Release",type="boolean",JSONPath=".spec.release",description="Is a release branch"
// +kubebuilder:printcolumn:name="Branch",type="string",JSONPath=".spec.branchName",description="Name of branch"
+// +kubebuilder:printcolumn:name="Stale",type="string",JSONPath=`.status.conditions[?(@.type=="Stale")].status`,description="Branch is missing in git"
// CodebaseBranch is the Schema for the CodebaseBranches API.
type CodebaseBranch struct {
diff --git a/api/v1/labels.go b/api/v1/labels.go
index 698ef831..481525b7 100644
--- a/api/v1/labels.go
+++ b/api/v1/labels.go
@@ -28,4 +28,10 @@ const (
// GitServerLabel is a label used to store the name of the GitServer in related resources.
GitServerLabel = "app.edp.epam.com/gitServer"
+
+ // StaleLabel marks a CodebaseBranch whose branch no longer exists in the git repository.
+ // It mirrors the Stale status condition so that resources can be filtered with a label
+ // selector; the condition remains the source of truth and the label is re-asserted by
+ // the operator on every staleness check.
+ StaleLabel = "app.edp.epam.com/stale"
)
diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go
index a2803ee0..613a8001 100644
--- a/api/v1/zz_generated.deepcopy.go
+++ b/api/v1/zz_generated.deepcopy.go
@@ -5,6 +5,7 @@
package v1
import (
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
runtime "k8s.io/apimachinery/pkg/runtime"
)
@@ -251,6 +252,13 @@ func (in *CodebaseBranchStatus) DeepCopyInto(out *CodebaseBranchStatus) {
*out = new(string)
**out = **in
}
+ if in.Conditions != nil {
+ in, out := &in.Conditions, &out.Conditions
+ *out = make([]metav1.Condition, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CodebaseBranchStatus.
diff --git a/cmd/main.go b/cmd/main.go
index 55c287e8..656e4c7f 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -37,11 +37,13 @@ import (
"github.com/epam/edp-codebase-operator/v2/controllers/cdstagedeploy/chain"
"github.com/epam/edp-codebase-operator/v2/controllers/codebase"
"github.com/epam/edp-codebase-operator/v2/controllers/codebasebranch"
+ "github.com/epam/edp-codebase-operator/v2/controllers/codebasebranch/stalecheck"
"github.com/epam/edp-codebase-operator/v2/controllers/codebaseimagestream"
"github.com/epam/edp-codebase-operator/v2/controllers/gitserver"
"github.com/epam/edp-codebase-operator/v2/controllers/integrationsecret"
"github.com/epam/edp-codebase-operator/v2/controllers/jiraissuemetadata"
"github.com/epam/edp-codebase-operator/v2/controllers/jiraserver"
+ gitproviderv2 "github.com/epam/edp-codebase-operator/v2/pkg/git"
"github.com/epam/edp-codebase-operator/v2/pkg/telemetry"
"github.com/epam/edp-codebase-operator/v2/pkg/util"
"github.com/epam/edp-codebase-operator/v2/pkg/webhook"
@@ -59,6 +61,8 @@ const (
telemetryDefaultDelay = time.Hour
telemetrySendEvery = time.Hour * 24
telemetryUrl = "https://telemetry.kuberocketci.io"
+ branchStaleCheckIntervalEnv = "BRANCH_STALE_CHECK_INTERVAL"
+ branchStaleCheckDefaultInterval = time.Hour * 24
)
func main() {
@@ -322,6 +326,27 @@ func main() {
os.Exit(1)
}
+ if interval := getBranchStaleCheckInterval(); interval > 0 {
+ staleRecorder := mgr.GetEventRecorderFor("stale-branch-checker")
+
+ markAction := stalecheck.NewMarkAction(mgr.GetClient(), staleRecorder)
+
+ checker := stalecheck.NewChecker(
+ mgr.GetClient(),
+ ns,
+ interval,
+ gitproviderv2.DefaultGitProviderFactory,
+ markAction,
+ stalecheck.NewCleanupAction(mgr.GetClient(), staleRecorder, markAction),
+ )
+ if err := mgr.Add(checker); err != nil {
+ setupLog.Error(err, "failed to add stale branch checker to manager")
+ os.Exit(1)
+ }
+ } else {
+ setupLog.Info("Stale branch checker is disabled", "env", branchStaleCheckIntervalEnv)
+ }
+
setupLog.Info("starting manager")
ctx := ctrl.SetupSignalHandler()
@@ -351,6 +376,25 @@ func getMaxConcurrentReconciles(envVar string) int {
return int(n)
}
+// getBranchStaleCheckInterval accepts Go duration strings (e.g. "24h", "30m");
+// a zero or negative duration disables the check.
+func getBranchStaleCheckInterval() time.Duration {
+ val, exists := os.LookupEnv(branchStaleCheckIntervalEnv)
+ if !exists {
+ return branchStaleCheckDefaultInterval
+ }
+
+ d, err := time.ParseDuration(val)
+ if err != nil {
+ setupLog.Error(err, "Invalid stale branch check interval, using default",
+ "env", branchStaleCheckIntervalEnv, "value", val, "default", branchStaleCheckDefaultInterval)
+
+ return branchStaleCheckDefaultInterval
+ }
+
+ return d
+}
+
func getTelemetryDelay() time.Duration {
val, exists := os.LookupEnv("TELEMETRY_DELAY")
if !exists {
diff --git a/config/crd/bases/v2.edp.epam.com_codebasebranches.yaml b/config/crd/bases/v2.edp.epam.com_codebasebranches.yaml
index e410a038..68368ac0 100644
--- a/config/crd/bases/v2.edp.epam.com_codebasebranches.yaml
+++ b/config/crd/bases/v2.edp.epam.com_codebasebranches.yaml
@@ -37,6 +37,10 @@ spec:
jsonPath: .spec.branchName
name: Branch
type: string
+ - description: Branch is missing in git
+ jsonPath: .status.conditions[?(@.type=="Stale")].status
+ name: Stale
+ type: string
name: v1
schema:
openAPIV3Schema:
@@ -109,6 +113,70 @@ spec:
build:
nullable: true
type: string
+ conditions:
+ description: |-
+ Conditions represent the latest available observations of an object's state.
+ Each condition type is an independent observation (e.g. Stale); new types can be
+ added over time without schema changes.
+ 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
+ nullable: true
+ type: array
+ x-kubernetes-list-map-keys:
+ - type
+ x-kubernetes-list-type: map
detailedMessage:
description: |-
Detailed information regarding action result
diff --git a/controllers/codebasebranch/chain/check_reference.go b/controllers/codebasebranch/chain/check_reference.go
index fc8092f9..4129effd 100644
--- a/controllers/codebasebranch/chain/check_reference.go
+++ b/controllers/codebasebranch/chain/check_reference.go
@@ -134,5 +134,6 @@ func (CheckReferenceExists) setFailedFields(
Build: codebaseBranch.Status.Build,
FailureCount: codebaseBranch.Status.FailureCount,
Git: codebaseBranch.Status.Git,
+ Conditions: codebaseBranch.Status.Conditions,
}
}
diff --git a/controllers/codebasebranch/chain/clean_tmp_directory/clean_tmp_directory.go b/controllers/codebasebranch/chain/clean_tmp_directory/clean_tmp_directory.go
index 43ab1f6f..f8a93b8a 100644
--- a/controllers/codebasebranch/chain/clean_tmp_directory/clean_tmp_directory.go
+++ b/controllers/codebasebranch/chain/clean_tmp_directory/clean_tmp_directory.go
@@ -52,5 +52,6 @@ func setFailedFields(cb *codebaseApi.CodebaseBranch, a codebaseApi.ActionType, m
Git: cb.Status.Git,
VersionHistory: cb.Status.VersionHistory,
Build: cb.Status.Build,
+ Conditions: cb.Status.Conditions,
}
}
diff --git a/controllers/codebasebranch/chain/put_branch_in_git/put_branch_in_git.go b/controllers/codebasebranch/chain/put_branch_in_git/put_branch_in_git.go
index 75ec691c..c3d32389 100644
--- a/controllers/codebasebranch/chain/put_branch_in_git/put_branch_in_git.go
+++ b/controllers/codebasebranch/chain/put_branch_in_git/put_branch_in_git.go
@@ -161,6 +161,7 @@ func (h PutBranchInGit) setIntermediateSuccessFields(
Build: cb.Status.Build,
FailureCount: cb.Status.FailureCount,
Git: cb.Status.Git,
+ Conditions: cb.Status.Conditions,
}
err := h.Client.Status().Update(ctx, cb)
@@ -185,6 +186,7 @@ func putGitBranchSetFailedFields(cb *codebaseApi.CodebaseBranch, message string)
Build: cb.Status.Build,
FailureCount: cb.Status.FailureCount,
Git: cb.Status.Git,
+ Conditions: cb.Status.Conditions,
}
}
diff --git a/controllers/codebasebranch/chain/put_codebase_image_stream/put_codebase_image_stream.go b/controllers/codebasebranch/chain/put_codebase_image_stream/put_codebase_image_stream.go
index d79303bc..6da87dac 100644
--- a/controllers/codebasebranch/chain/put_codebase_image_stream/put_codebase_image_stream.go
+++ b/controllers/codebasebranch/chain/put_codebase_image_stream/put_codebase_image_stream.go
@@ -211,6 +211,7 @@ func setFailedFields(cb *codebaseApi.CodebaseBranch, a codebaseApi.ActionType, m
Git: cb.Status.Git,
VersionHistory: cb.Status.VersionHistory,
Build: cb.Status.Build,
+ Conditions: cb.Status.Conditions,
}
}
@@ -230,6 +231,7 @@ func (h PutCodebaseImageStream) setIntermediateSuccessFields(
LastSuccessfulBuild: cb.Status.LastSuccessfulBuild,
Build: cb.Status.Build,
Git: cb.Status.Git,
+ Conditions: cb.Status.Conditions,
}
err := h.Client.Status().Update(ctx, cb)
diff --git a/controllers/codebasebranch/codebasebranch_controller.go b/controllers/codebasebranch/codebasebranch_controller.go
index f0bbd6b6..3b406e42 100644
--- a/controllers/codebasebranch/codebasebranch_controller.go
+++ b/controllers/codebasebranch/codebasebranch_controller.go
@@ -117,6 +117,22 @@ func (r *ReconcileCodebaseBranch) Reconcile(ctx context.Context, request reconci
c := &codebaseApi.Codebase{}
if err := r.client.Get(ctx, types.NamespacedName{Name: cb.Spec.CodebaseName, Namespace: cb.Namespace}, c); err != nil {
+ // When the owning Codebase is already gone the branch is being garbage-collected
+ // as part of a cascade deletion; the only work left is to run the deletion flow
+ // and release the finalizer, otherwise the branch stays terminating forever.
+ if k8sErrors.IsNotFound(err) && cb.DeletionTimestamp != nil {
+ result, delErr := r.tryToDeleteCodebaseBranch(ctx, cb, factory.GetDeletionChain())
+ if delErr != nil {
+ return reconcile.Result{}, fmt.Errorf("failed to remove codebasebranch %v: %w", cb.Name, delErr)
+ }
+
+ if result != nil {
+ return *result, nil
+ }
+
+ return reconcile.Result{}, nil
+ }
+
return reconcile.Result{}, fmt.Errorf("failed to get Codebase: %w", err)
}
@@ -210,6 +226,7 @@ func (r *ReconcileCodebaseBranch) setSuccessStatus(
LastSuccessfulBuild: cb.Status.LastSuccessfulBuild,
Build: cb.Status.Build,
Git: cb.Status.Git,
+ Conditions: cb.Status.Conditions,
}
return r.updateStatus(ctx, cb)
@@ -223,7 +240,11 @@ func (r *ReconcileCodebaseBranch) updateStatus(ctx context.Context, cb *codebase
return fmt.Errorf("failed to get CodebaseBranch: %w", err)
}
+ // Conditions are owned by the stale branch checker, which may have patched them
+ // after cb was read; the freshly fetched value is always the authoritative one.
+ conditions := cbbranch.Status.Conditions
cbbranch.Status = cb.Status
+ cbbranch.Status.Conditions = conditions
if err := r.client.Status().Update(ctx, cbbranch); err != nil {
return fmt.Errorf("failed to update CodebaseBranch status: %w", err)
diff --git a/controllers/codebasebranch/codebasebranch_controller_test.go b/controllers/codebasebranch/codebasebranch_controller_test.go
index fdf6efc8..6dfe64df 100644
--- a/controllers/codebasebranch/codebasebranch_controller_test.go
+++ b/controllers/codebasebranch/codebasebranch_controller_test.go
@@ -10,6 +10,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
coreV1 "k8s.io/api/core/v1"
+ k8sErrors "k8s.io/apimachinery/pkg/api/errors"
metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
@@ -79,6 +80,46 @@ func TestReconcileCodebaseBranch_Reconcile_ShouldFailNotFound(t *testing.T) {
assert.Equal(t, time.Duration(0), res.RequeueAfter)
}
+func TestReconcileCodebaseBranch_Reconcile_ShouldReleaseFinalizerWhenCodebaseGone(t *testing.T) {
+ t.Setenv("WORKING_DIR", "/tmp/1")
+
+ now := metaV1.Now()
+ cb := &codebaseApi.CodebaseBranch{
+ ObjectMeta: metaV1.ObjectMeta{
+ Name: "app-main",
+ Namespace: "namespace",
+ DeletionTimestamp: &now,
+ Finalizers: []string{codebaseBranchOperatorFinalizerName},
+ },
+ Spec: codebaseApi.CodebaseBranchSpec{
+ CodebaseName: "app",
+ BranchName: "main",
+ },
+ }
+
+ scheme := runtime.NewScheme()
+ require.NoError(t, codebaseApi.AddToScheme(scheme))
+ fakeCl := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(cb).Build()
+
+ r := ReconcileCodebaseBranch{
+ client: fakeCl,
+ log: logr.Discard(),
+ scheme: scheme,
+ }
+
+ res, err := r.Reconcile(context.TODO(), reconcile.Request{
+ NamespacedName: types.NamespacedName{Name: "app-main", Namespace: "namespace"},
+ })
+
+ require.NoError(t, err)
+ assert.Equal(t, time.Duration(0), res.RequeueAfter)
+
+ // With the finalizer released, the fake client removes the terminating object entirely.
+ err = fakeCl.Get(context.TODO(),
+ types.NamespacedName{Name: "app-main", Namespace: "namespace"}, &codebaseApi.CodebaseBranch{})
+ assert.True(t, k8sErrors.IsNotFound(err), "branch must be gone after finalizer release")
+}
+
func TestReconcileCodebaseBranch_Reconcile_ShouldFailGetCodebase(t *testing.T) {
t.Setenv("WORKING_DIR", "/tmp/1")
@@ -741,3 +782,51 @@ func TestSetDefaultValues_ShouldSkipForGitLab(t *testing.T) {
assert.False(t, changed)
assert.Nil(t, cb.Spec.Pipelines)
}
+
+func TestReconcileCodebaseBranch_setSuccessStatus_PreservesStaleCondition(t *testing.T) {
+ cb := &codebaseApi.CodebaseBranch{
+ ObjectMeta: metaV1.ObjectMeta{
+ Name: "app-main",
+ Namespace: "namespace",
+ },
+ Spec: codebaseApi.CodebaseBranchSpec{
+ CodebaseName: "app",
+ BranchName: "main",
+ },
+ Status: codebaseApi.CodebaseBranchStatus{
+ Conditions: []metaV1.Condition{{
+ Type: codebaseApi.ConditionStale,
+ Status: metaV1.ConditionTrue,
+ Reason: codebaseApi.ReasonBranchNotFoundInGit,
+ Message: "Branch was not found in the git repository",
+ LastTransitionTime: metaV1.Now(),
+ }},
+ },
+ }
+
+ scheme := runtime.NewScheme()
+ require.NoError(t, codebaseApi.AddToScheme(scheme))
+
+ fakeCl := fake.NewClientBuilder().
+ WithScheme(scheme).
+ WithRuntimeObjects(cb).
+ WithStatusSubresource(cb).
+ Build()
+
+ r := ReconcileCodebaseBranch{
+ client: fakeCl,
+ log: logr.Discard(),
+ scheme: scheme,
+ }
+
+ require.NoError(t, r.setSuccessStatus(context.TODO(), cb.DeepCopy(), codebaseApi.CIConfiguration))
+
+ updated := &codebaseApi.CodebaseBranch{}
+ require.NoError(t, fakeCl.Get(context.TODO(),
+ types.NamespacedName{Name: "app-main", Namespace: "namespace"}, updated))
+
+ require.Len(t, updated.Status.Conditions, 1,
+ "the Stale condition must survive the success-status rewrite")
+ assert.Equal(t, codebaseApi.ConditionStale, updated.Status.Conditions[0].Type)
+ assert.Equal(t, metaV1.ConditionTrue, updated.Status.Conditions[0].Status)
+}
diff --git a/controllers/codebasebranch/stalecheck/action.go b/controllers/codebasebranch/stalecheck/action.go
new file mode 100644
index 00000000..46c03c9b
--- /dev/null
+++ b/controllers/codebasebranch/stalecheck/action.go
@@ -0,0 +1,151 @@
+package stalecheck
+
+import (
+ "context"
+ "fmt"
+
+ corev1 "k8s.io/api/core/v1"
+ "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/client-go/tools/record"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+
+ codebaseApi "github.com/epam/edp-codebase-operator/v2/api/v1"
+)
+
+const (
+ EventReasonBranchStale = "BranchStale"
+ EventReasonBranchStaleResolved = "BranchStaleResolved"
+ EventReasonStaleBranchDeleted = "StaleBranchDeleted"
+ EventReasonStaleBranchRetained = "StaleBranchRetained"
+)
+
+// Verdict is the result of checking a single CodebaseBranch against the git repository.
+type Verdict struct {
+ ExistsInGit bool
+
+ // RetainedBy describes the deployment resource that prevents automatic cleanup
+ // of a missing branch (empty when the branch exists or is not retained).
+ RetainedBy string
+}
+
+// StaleBranchAction applies a cleanup strategy decision to a CodebaseBranch based on a Verdict.
+type StaleBranchAction interface {
+ Apply(ctx context.Context, branch *codebaseApi.CodebaseBranch, verdict Verdict) error
+}
+
+// MarkAction marks/unmarks a CodebaseBranch as stale via the Stale status condition
+// (source of truth) and the mirrored stale label (for label-selector filtering).
+type MarkAction struct {
+ client client.Client
+ recorder record.EventRecorder
+}
+
+func NewMarkAction(k8sClient client.Client, recorder record.EventRecorder) *MarkAction {
+ return &MarkAction{client: k8sClient, recorder: recorder}
+}
+
+func (a *MarkAction) Apply(ctx context.Context, branch *codebaseApi.CodebaseBranch, verdict Verdict) error {
+ wasStale := meta.IsStatusConditionTrue(branch.Status.Conditions, codebaseApi.ConditionStale)
+
+ if err := a.updateStatusCondition(ctx, branch, verdict); err != nil {
+ return err
+ }
+
+ if err := a.updateStaleLabel(ctx, branch, verdict); err != nil {
+ return err
+ }
+
+ a.emitTransitionEvent(branch, wasStale, verdict)
+
+ return nil
+}
+
+func (a *MarkAction) updateStatusCondition(
+ ctx context.Context,
+ branch *codebaseApi.CodebaseBranch,
+ verdict Verdict,
+) error {
+ // Messages are deliberately static so that SetStatusCondition reports a change
+ // (and triggers a status write) only on real transitions, not on every periodic check.
+ condition := metav1.Condition{
+ Type: codebaseApi.ConditionStale,
+ Status: metav1.ConditionFalse,
+ Reason: codebaseApi.ReasonBranchFoundInGit,
+ Message: "Branch exists in the git repository",
+ ObservedGeneration: branch.Generation,
+ }
+
+ if !verdict.ExistsInGit {
+ condition.Status = metav1.ConditionTrue
+ condition.Reason = codebaseApi.ReasonBranchNotFoundInGit
+ condition.Message = "Branch was not found in the git repository"
+
+ if verdict.RetainedBy != "" {
+ condition.Message = fmt.Sprintf(
+ "Branch was not found in the git repository; retained because it is used by %s", verdict.RetainedBy)
+ }
+ }
+
+ original := branch.DeepCopy()
+ if !meta.SetStatusCondition(&branch.Status.Conditions, condition) {
+ return nil
+ }
+
+ if err := a.client.Status().Patch(ctx, branch, client.MergeFrom(original)); err != nil {
+ return fmt.Errorf("failed to patch Stale condition on CodebaseBranch %s: %w", branch.Name, err)
+ }
+
+ return nil
+}
+
+func (a *MarkAction) updateStaleLabel(ctx context.Context, branch *codebaseApi.CodebaseBranch, verdict Verdict) error {
+ value, labeled := branch.Labels[codebaseApi.StaleLabel]
+
+ upToDate := (verdict.ExistsInGit && !labeled) || (!verdict.ExistsInGit && value == "true")
+ if upToDate {
+ return nil
+ }
+
+ original := branch.DeepCopy()
+
+ if verdict.ExistsInGit {
+ delete(branch.Labels, codebaseApi.StaleLabel)
+ } else {
+ if branch.Labels == nil {
+ branch.Labels = make(map[string]string, 1)
+ }
+
+ branch.Labels[codebaseApi.StaleLabel] = "true"
+ }
+
+ if err := a.client.Patch(ctx, branch, client.MergeFrom(original)); err != nil {
+ return fmt.Errorf("failed to patch stale label on CodebaseBranch %s: %w", branch.Name, err)
+ }
+
+ return nil
+}
+
+func (a *MarkAction) emitTransitionEvent(branch *codebaseApi.CodebaseBranch, wasStale bool, verdict Verdict) {
+ if a.recorder == nil {
+ return
+ }
+
+ if !wasStale && !verdict.ExistsInGit {
+ if verdict.RetainedBy != "" {
+ a.recorder.Eventf(branch, corev1.EventTypeWarning, EventReasonStaleBranchRetained,
+ "Branch %s was not found in the git repository; it is marked as stale but retained because it is used by %s",
+ branch.Spec.BranchName, verdict.RetainedBy)
+
+ return
+ }
+
+ a.recorder.Eventf(branch, corev1.EventTypeWarning, EventReasonBranchStale,
+ "Branch %s was not found in the git repository and is marked as stale", branch.Spec.BranchName)
+ }
+
+ if wasStale && verdict.ExistsInGit {
+ a.recorder.Eventf(branch, corev1.EventTypeNormal, EventReasonBranchStaleResolved,
+ "Branch %s exists in the git repository again, stale mark removed", branch.Spec.BranchName)
+ }
+}
diff --git a/controllers/codebasebranch/stalecheck/checker.go b/controllers/codebasebranch/stalecheck/checker.go
new file mode 100644
index 00000000..51acd284
--- /dev/null
+++ b/controllers/codebasebranch/stalecheck/checker.go
@@ -0,0 +1,180 @@
+package stalecheck
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ corev1 "k8s.io/api/core/v1"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+
+ codebaseApi "github.com/epam/edp-codebase-operator/v2/api/v1"
+ gitproviderv2 "github.com/epam/edp-codebase-operator/v2/pkg/git"
+ "github.com/epam/edp-codebase-operator/v2/pkg/util"
+)
+
+// GitClientFactory is the injection seam for tests;
+// production wiring uses gitproviderv2.DefaultGitProviderFactory.
+type GitClientFactory func(gitServer *codebaseApi.GitServer, secret *corev1.Secret) gitproviderv2.Git
+
+// Checker periodically verifies that every CodebaseBranch still has a corresponding
+// branch in the real git repository and applies the configured cleanup strategy.
+//
+// It runs as a manager Runnable (leader-only) rather than a watch-driven controller
+// because staleness is external state that no Kubernetes event reports.
+type Checker struct {
+ client client.Client
+ namespace string
+ interval time.Duration
+ gitClientFactory GitClientFactory
+ markAction StaleBranchAction
+ cleanupAction StaleBranchAction
+}
+
+func NewChecker(
+ k8sClient client.Client,
+ namespace string,
+ interval time.Duration,
+ gitClientFactory GitClientFactory,
+ markAction StaleBranchAction,
+ cleanupAction StaleBranchAction,
+) *Checker {
+ return &Checker{
+ client: k8sClient,
+ namespace: namespace,
+ interval: interval,
+ gitClientFactory: gitClientFactory,
+ markAction: markAction,
+ cleanupAction: cleanupAction,
+ }
+}
+
+// Start implements manager.Runnable. It sweeps once on startup and then on every tick.
+func (c *Checker) Start(ctx context.Context) error {
+ log := ctrl.Log.WithName("stale-branch-checker")
+ ctx = ctrl.LoggerInto(ctx, log)
+
+ log.Info("Starting stale branch checker", "interval", c.interval)
+
+ c.sweep(ctx)
+
+ ticker := time.NewTicker(c.interval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ log.Info("Stopping stale branch checker")
+ return nil
+ case <-ticker.C:
+ c.sweep(ctx)
+ }
+ }
+}
+
+// NeedLeaderElection ensures only the elected leader talks to git servers.
+func (c *Checker) NeedLeaderElection() bool {
+ return true
+}
+
+func (c *Checker) sweep(ctx context.Context) {
+ log := ctrl.LoggerFrom(ctx)
+ log.Info("Checking codebase branches staleness")
+
+ branches := &codebaseApi.CodebaseBranchList{}
+ if err := c.client.List(ctx, branches, client.InNamespace(c.namespace)); err != nil {
+ log.Error(err, "Failed to list codebase branches")
+ return
+ }
+
+ branchesByCodebase := make(map[string][]*codebaseApi.CodebaseBranch)
+
+ for i := range branches.Items {
+ branch := &branches.Items[i]
+ branchesByCodebase[branch.Spec.CodebaseName] = append(branchesByCodebase[branch.Spec.CodebaseName], branch)
+ }
+
+ for codebaseName, codebaseBranches := range branchesByCodebase {
+ if err := c.checkCodebaseBranches(ctx, codebaseName, codebaseBranches); err != nil {
+ log.Error(err, "Failed to check branches staleness", "codebase", codebaseName)
+ }
+ }
+
+ log.Info("Codebase branches staleness check finished", "codebases", len(branchesByCodebase))
+}
+
+func (c *Checker) checkCodebaseBranches(
+ ctx context.Context,
+ codebaseName string,
+ branches []*codebaseApi.CodebaseBranch,
+) error {
+ log := ctrl.LoggerFrom(ctx).WithValues("codebase", codebaseName)
+
+ codebase := &codebaseApi.Codebase{}
+ if err := c.client.Get(ctx, client.ObjectKey{Namespace: c.namespace, Name: codebaseName}, codebase); err != nil {
+ return fmt.Errorf("failed to get codebase %s: %w", codebaseName, err)
+ }
+
+ gitServer := &codebaseApi.GitServer{}
+ if err := c.client.Get(
+ ctx, client.ObjectKey{Namespace: c.namespace, Name: codebase.Spec.GitServer}, gitServer,
+ ); err != nil {
+ return fmt.Errorf("failed to get git server %s: %w", codebase.Spec.GitServer, err)
+ }
+
+ secret := &corev1.Secret{}
+ if err := c.client.Get(
+ ctx, client.ObjectKey{Namespace: c.namespace, Name: gitServer.Spec.NameSshKeySecret}, secret,
+ ); err != nil {
+ return fmt.Errorf("failed to get secret %s: %w", gitServer.Spec.NameSshKeySecret, err)
+ }
+
+ action := c.markAction
+ if codebase.Annotations[codebaseApi.BranchCleanupStrategyAnnotation] == codebaseApi.BranchCleanupStrategyAuto {
+ action = c.cleanupAction
+ }
+
+ repoURL := util.GetProjectGitUrl(gitServer, secret, codebase.Spec.GetProjectID())
+
+ // A failed listing means the repository state is unknown; branches are marked stale
+ // only on a successful listing that lacks them, never on connectivity/auth errors.
+ remoteBranches, err := c.gitClientFactory(gitServer, secret).ListRemoteBranches(ctx, repoURL)
+ if err != nil {
+ return fmt.Errorf("failed to list remote branches for %s, skipping staleness check: %w", repoURL, err)
+ }
+
+ existsInGit := make(map[string]struct{}, len(remoteBranches))
+ for _, name := range remoteBranches {
+ existsInGit[name] = struct{}{}
+ }
+
+ for _, branch := range branches {
+ if !c.eligibleForCheck(branch, codebase) {
+ continue
+ }
+
+ _, exists := existsInGit[branch.Spec.BranchName]
+
+ if err := action.Apply(ctx, branch, Verdict{ExistsInGit: exists}); err != nil {
+ log.Error(err, "Failed to apply staleness verdict", "branch", branch.Name)
+ }
+ }
+
+ return nil
+}
+
+// eligibleForCheck filters out branches whose absence in git is expected or must not be acted on:
+// branches not yet pushed by the operator, branches being deleted, and the codebase default
+// branch (its disappearance almost always means repository migration/rename, not branch cleanup).
+func (c *Checker) eligibleForCheck(branch *codebaseApi.CodebaseBranch, codebase *codebaseApi.Codebase) bool {
+ if branch.DeletionTimestamp != nil {
+ return false
+ }
+
+ if branch.Status.Git != codebaseApi.CodebaseBranchGitStatusBranchCreated {
+ return false
+ }
+
+ return branch.Spec.BranchName != codebase.Spec.DefaultBranch
+}
diff --git a/controllers/codebasebranch/stalecheck/checker_test.go b/controllers/codebasebranch/stalecheck/checker_test.go
new file mode 100644
index 00000000..2a78cff3
--- /dev/null
+++ b/controllers/codebasebranch/stalecheck/checker_test.go
@@ -0,0 +1,256 @@
+package stalecheck
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/require"
+ corev1 "k8s.io/api/core/v1"
+ "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/client-go/tools/record"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+ codebaseApi "github.com/epam/edp-codebase-operator/v2/api/v1"
+ gitproviderv2 "github.com/epam/edp-codebase-operator/v2/pkg/git"
+ gitmocks "github.com/epam/edp-codebase-operator/v2/pkg/git/mocks"
+)
+
+const testNamespace = "default"
+
+func newScheme(t *testing.T) *runtime.Scheme {
+ t.Helper()
+
+ scheme := runtime.NewScheme()
+ require.NoError(t, codebaseApi.AddToScheme(scheme))
+ require.NoError(t, corev1.AddToScheme(scheme))
+
+ return scheme
+}
+
+func newCodebase() *codebaseApi.Codebase {
+ return &codebaseApi.Codebase{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "app",
+ Namespace: testNamespace,
+ },
+ Spec: codebaseApi.CodebaseSpec{
+ GitServer: "gitlab",
+ GitUrlPath: "/owner/app",
+ DefaultBranch: "main",
+ },
+ }
+}
+
+func newBranch(name, branchName, gitStatus string) *codebaseApi.CodebaseBranch {
+ return &codebaseApi.CodebaseBranch{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: testNamespace,
+ },
+ Spec: codebaseApi.CodebaseBranchSpec{
+ CodebaseName: "app",
+ BranchName: branchName,
+ },
+ Status: codebaseApi.CodebaseBranchStatus{
+ Git: gitStatus,
+ },
+ }
+}
+
+func newGitServerWithSecret() (*codebaseApi.GitServer, *corev1.Secret) {
+ gitServer := &codebaseApi.GitServer{
+ ObjectMeta: metav1.ObjectMeta{Name: "gitlab", Namespace: testNamespace},
+ Spec: codebaseApi.GitServerSpec{
+ GitHost: "gitlab.example.com",
+ GitProvider: codebaseApi.GitProviderGitlab,
+ NameSshKeySecret: "gitlab-secret",
+ },
+ }
+ secret := &corev1.Secret{
+ ObjectMeta: metav1.ObjectMeta{Name: "gitlab-secret", Namespace: testNamespace},
+ Data: map[string][]byte{"token": []byte("token"), "username": []byte("user")},
+ }
+
+ return gitServer, secret
+}
+
+func newChecker(
+ t *testing.T,
+ k8sClient client.Client,
+ gitClient gitproviderv2.Git,
+ recorder record.EventRecorder,
+) *Checker {
+ t.Helper()
+
+ factory := func(_ *codebaseApi.GitServer, _ *corev1.Secret) gitproviderv2.Git {
+ return gitClient
+ }
+
+ mark := NewMarkAction(k8sClient, recorder)
+
+ return NewChecker(k8sClient, testNamespace, 0, factory, mark, NewCleanupAction(k8sClient, recorder, mark))
+}
+
+func getBranch(t *testing.T, k8sClient client.Client, name string) *codebaseApi.CodebaseBranch {
+ t.Helper()
+
+ branch := &codebaseApi.CodebaseBranch{}
+ require.NoError(t, k8sClient.Get(context.Background(), client.ObjectKey{Namespace: testNamespace, Name: name}, branch))
+
+ return branch
+}
+
+func TestChecker_MarksMissingBranchStale(t *testing.T) {
+ codebase := newCodebase()
+ mainBranch := newBranch("app-main", "main", codebaseApi.CodebaseBranchGitStatusBranchCreated)
+ featureBranch := newBranch("app-feature", "feature", codebaseApi.CodebaseBranchGitStatusBranchCreated)
+ gitServer, secret := newGitServerWithSecret()
+
+ k8sClient := fake.NewClientBuilder().
+ WithScheme(newScheme(t)).
+ WithObjects(codebase, mainBranch, featureBranch, gitServer, secret).
+ WithStatusSubresource(mainBranch, featureBranch).
+ Build()
+
+ gitClient := gitmocks.NewMockGit(t)
+ gitClient.On("ListRemoteBranches", mock.Anything, mock.Anything).Return([]string{"main"}, nil)
+
+ recorder := record.NewFakeRecorder(10)
+
+ newChecker(t, k8sClient, gitClient, recorder).sweep(context.Background())
+
+ stale := getBranch(t, k8sClient, "app-feature")
+ assert.True(t, meta.IsStatusConditionTrue(stale.Status.Conditions, codebaseApi.ConditionStale))
+ assert.Equal(t, "true", stale.Labels[codebaseApi.StaleLabel])
+
+ condition := meta.FindStatusCondition(stale.Status.Conditions, codebaseApi.ConditionStale)
+ require.NotNil(t, condition)
+ assert.Equal(t, codebaseApi.ReasonBranchNotFoundInGit, condition.Reason)
+
+ select {
+ case event := <-recorder.Events:
+ assert.Contains(t, event, EventReasonBranchStale)
+ default:
+ t.Fatal("expected BranchStale event")
+ }
+}
+
+func TestChecker_ClearsStaleMarkWhenBranchReappears(t *testing.T) {
+ codebase := newCodebase()
+
+ branch := newBranch("app-feature", "feature", codebaseApi.CodebaseBranchGitStatusBranchCreated)
+ branch.Labels = map[string]string{codebaseApi.StaleLabel: "true"}
+ branch.Status.Conditions = []metav1.Condition{{
+ Type: codebaseApi.ConditionStale,
+ Status: metav1.ConditionTrue,
+ Reason: codebaseApi.ReasonBranchNotFoundInGit,
+ LastTransitionTime: metav1.Now(),
+ }}
+
+ gitServer, secret := newGitServerWithSecret()
+
+ k8sClient := fake.NewClientBuilder().
+ WithScheme(newScheme(t)).
+ WithObjects(codebase, branch, gitServer, secret).
+ WithStatusSubresource(branch).
+ Build()
+
+ gitClient := gitmocks.NewMockGit(t)
+ gitClient.On("ListRemoteBranches", mock.Anything, mock.Anything).Return([]string{"main", "feature"}, nil)
+
+ recorder := record.NewFakeRecorder(10)
+
+ newChecker(t, k8sClient, gitClient, recorder).sweep(context.Background())
+
+ updated := getBranch(t, k8sClient, "app-feature")
+ assert.False(t, meta.IsStatusConditionTrue(updated.Status.Conditions, codebaseApi.ConditionStale))
+ assert.NotContains(t, updated.Labels, codebaseApi.StaleLabel)
+
+ select {
+ case event := <-recorder.Events:
+ assert.Contains(t, event, EventReasonBranchStaleResolved)
+ default:
+ t.Fatal("expected BranchStaleResolved event")
+ }
+}
+
+func TestChecker_DoesNotMarkOnGitError(t *testing.T) {
+ codebase := newCodebase()
+ branch := newBranch("app-feature", "feature", codebaseApi.CodebaseBranchGitStatusBranchCreated)
+ gitServer, secret := newGitServerWithSecret()
+
+ k8sClient := fake.NewClientBuilder().
+ WithScheme(newScheme(t)).
+ WithObjects(codebase, branch, gitServer, secret).
+ WithStatusSubresource(branch).
+ Build()
+
+ gitClient := gitmocks.NewMockGit(t)
+ gitClient.On("ListRemoteBranches", mock.Anything, mock.Anything).Return(nil, assert.AnError)
+
+ newChecker(t, k8sClient, gitClient, record.NewFakeRecorder(10)).sweep(context.Background())
+
+ updated := getBranch(t, k8sClient, "app-feature")
+ assert.Empty(t, updated.Status.Conditions)
+ assert.NotContains(t, updated.Labels, codebaseApi.StaleLabel)
+}
+
+func TestChecker_SkipsDefaultAndUnpushedBranches(t *testing.T) {
+ codebase := newCodebase()
+ defaultBranch := newBranch("app-main", "main", codebaseApi.CodebaseBranchGitStatusBranchCreated)
+ unpushedBranch := newBranch("app-new", "new", "")
+ gitServer, secret := newGitServerWithSecret()
+
+ k8sClient := fake.NewClientBuilder().
+ WithScheme(newScheme(t)).
+ WithObjects(codebase, defaultBranch, unpushedBranch, gitServer, secret).
+ WithStatusSubresource(defaultBranch, unpushedBranch).
+ Build()
+
+ // Remote listing is empty: both branches are missing in git,
+ // but neither is eligible for marking.
+ gitClient := gitmocks.NewMockGit(t)
+ gitClient.On("ListRemoteBranches", mock.Anything, mock.Anything).Return([]string{}, nil)
+
+ newChecker(t, k8sClient, gitClient, record.NewFakeRecorder(10)).sweep(context.Background())
+
+ for _, name := range []string{"app-main", "app-new"} {
+ updated := getBranch(t, k8sClient, name)
+ assert.Empty(t, updated.Status.Conditions, name)
+ assert.NotContains(t, updated.Labels, codebaseApi.StaleLabel, name)
+ }
+}
+
+func TestMarkAction_IdempotentWhenAlreadyStale(t *testing.T) {
+ branch := newBranch("app-feature", "feature", codebaseApi.CodebaseBranchGitStatusBranchCreated)
+ branch.Labels = map[string]string{codebaseApi.StaleLabel: "true"}
+ branch.Status.Conditions = []metav1.Condition{{
+ Type: codebaseApi.ConditionStale,
+ Status: metav1.ConditionTrue,
+ Reason: codebaseApi.ReasonBranchNotFoundInGit,
+ Message: "Branch was not found in the git repository",
+ LastTransitionTime: metav1.Now(),
+ }}
+
+ k8sClient := fake.NewClientBuilder().
+ WithScheme(newScheme(t)).
+ WithObjects(branch).
+ WithStatusSubresource(branch).
+ Build()
+
+ recorder := record.NewFakeRecorder(10)
+ action := NewMarkAction(k8sClient, recorder)
+
+ require.NoError(t, action.Apply(context.Background(), branch, Verdict{ExistsInGit: false}))
+
+ select {
+ case event := <-recorder.Events:
+ t.Fatalf("expected no events for already-stale branch, got %s", event)
+ default:
+ }
+}
diff --git a/controllers/codebasebranch/stalecheck/cleanup_action.go b/controllers/codebasebranch/stalecheck/cleanup_action.go
new file mode 100644
index 00000000..2daf28c2
--- /dev/null
+++ b/controllers/codebasebranch/stalecheck/cleanup_action.go
@@ -0,0 +1,60 @@
+package stalecheck
+
+import (
+ "context"
+ "fmt"
+
+ corev1 "k8s.io/api/core/v1"
+ "k8s.io/client-go/tools/record"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+
+ codebaseApi "github.com/epam/edp-codebase-operator/v2/api/v1"
+ "github.com/epam/edp-codebase-operator/v2/pkg/codebasebranch"
+)
+
+// CleanupAction implements the "auto" cleanup strategy: stale branches that do not
+// participate in any deployment are deleted (the owned CodebaseImageStream is
+// garbage-collected via its owner reference); branches referenced by a CDPipeline
+// or Stage fall back to being marked, with the retaining resource recorded in the
+// Stale condition message.
+type CleanupAction struct {
+ client client.Client
+ recorder record.EventRecorder
+ mark *MarkAction
+}
+
+func NewCleanupAction(k8sClient client.Client, recorder record.EventRecorder, mark *MarkAction) *CleanupAction {
+ return &CleanupAction{client: k8sClient, recorder: recorder, mark: mark}
+}
+
+func (a *CleanupAction) Apply(ctx context.Context, branch *codebaseApi.CodebaseBranch, verdict Verdict) error {
+ if verdict.ExistsInGit {
+ return a.mark.Apply(ctx, branch, verdict)
+ }
+
+ usage, err := codebasebranch.FindBranchUsage(ctx, a.client, branch)
+ if err != nil {
+ return fmt.Errorf("failed to check CodebaseBranch %s usage: %w", branch.Name, err)
+ }
+
+ if usage != "" {
+ verdict.RetainedBy = usage
+
+ return a.mark.Apply(ctx, branch, verdict)
+ }
+
+ if a.recorder != nil {
+ a.recorder.Eventf(branch, corev1.EventTypeNormal, EventReasonStaleBranchDeleted,
+ "Branch %s was not found in the git repository and is not used by any deployment, deleting", branch.Spec.BranchName)
+ }
+
+ if err := a.client.Delete(ctx, branch); err != nil {
+ return fmt.Errorf("failed to delete stale CodebaseBranch %s: %w", branch.Name, err)
+ }
+
+ ctrl.LoggerFrom(ctx).Info("Deleted stale codebase branch",
+ "codebasebranch", branch.Name, "branch", branch.Spec.BranchName)
+
+ return nil
+}
diff --git a/controllers/codebasebranch/stalecheck/cleanup_action_test.go b/controllers/codebasebranch/stalecheck/cleanup_action_test.go
new file mode 100644
index 00000000..163703ac
--- /dev/null
+++ b/controllers/codebasebranch/stalecheck/cleanup_action_test.go
@@ -0,0 +1,109 @@
+package stalecheck
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "k8s.io/apimachinery/pkg/api/errors"
+ "k8s.io/apimachinery/pkg/api/meta"
+ "k8s.io/client-go/tools/record"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+ pipelineApi "github.com/epam/edp-cd-pipeline-operator/v2/api/v1"
+
+ codebaseApi "github.com/epam/edp-codebase-operator/v2/api/v1"
+)
+
+func TestCleanupAction_DeletesUnusedStaleBranch(t *testing.T) {
+ branch := newBranch("app-feature", "feature", codebaseApi.CodebaseBranchGitStatusBranchCreated)
+
+ scheme := newScheme(t)
+ require.NoError(t, pipelineApi.AddToScheme(scheme))
+
+ k8sClient := fake.NewClientBuilder().
+ WithScheme(scheme).
+ WithObjects(branch).
+ WithStatusSubresource(branch).
+ Build()
+
+ recorder := record.NewFakeRecorder(10)
+ action := NewCleanupAction(k8sClient, recorder, NewMarkAction(k8sClient, recorder))
+
+ require.NoError(t, action.Apply(context.Background(), branch, Verdict{ExistsInGit: false}))
+
+ err := k8sClient.Get(context.Background(),
+ client.ObjectKey{Namespace: testNamespace, Name: "app-feature"}, &codebaseApi.CodebaseBranch{})
+ assert.True(t, errors.IsNotFound(err), "stale branch must be deleted")
+
+ select {
+ case event := <-recorder.Events:
+ assert.Contains(t, event, EventReasonStaleBranchDeleted)
+ default:
+ t.Fatal("expected StaleBranchDeleted event")
+ }
+}
+
+func TestCleanupAction_RetainsBranchUsedByCDPipeline(t *testing.T) {
+ branch := newBranch("app-feature", "feature", codebaseApi.CodebaseBranchGitStatusBranchCreated)
+
+ pipeline := &pipelineApi.CDPipeline{}
+ pipeline.Name = "demo"
+ pipeline.Namespace = testNamespace
+ pipeline.Spec.InputDockerStreams = []string{"app-feature"}
+ pipeline.Spec.DeploymentType = "container"
+
+ scheme := newScheme(t)
+ require.NoError(t, pipelineApi.AddToScheme(scheme))
+
+ k8sClient := fake.NewClientBuilder().
+ WithScheme(scheme).
+ WithObjects(branch, pipeline).
+ WithStatusSubresource(branch).
+ Build()
+
+ recorder := record.NewFakeRecorder(10)
+ action := NewCleanupAction(k8sClient, recorder, NewMarkAction(k8sClient, recorder))
+
+ require.NoError(t, action.Apply(context.Background(), branch, Verdict{ExistsInGit: false}))
+
+ retained := getBranch(t, k8sClient, "app-feature")
+ assert.True(t, meta.IsStatusConditionTrue(retained.Status.Conditions, codebaseApi.ConditionStale))
+ assert.Equal(t, "true", retained.Labels[codebaseApi.StaleLabel])
+
+ condition := meta.FindStatusCondition(retained.Status.Conditions, codebaseApi.ConditionStale)
+ require.NotNil(t, condition)
+ assert.Contains(t, condition.Message, "retained because it is used by CDPipeline demo")
+
+ select {
+ case event := <-recorder.Events:
+ assert.Contains(t, event, EventReasonStaleBranchRetained)
+ default:
+ t.Fatal("expected StaleBranchRetained event")
+ }
+}
+
+func TestCleanupAction_ClearsMarkWhenBranchExists(t *testing.T) {
+ branch := newBranch("app-feature", "feature", codebaseApi.CodebaseBranchGitStatusBranchCreated)
+ branch.Labels = map[string]string{codebaseApi.StaleLabel: "true"}
+
+ scheme := newScheme(t)
+ require.NoError(t, pipelineApi.AddToScheme(scheme))
+
+ k8sClient := fake.NewClientBuilder().
+ WithScheme(scheme).
+ WithObjects(branch).
+ WithStatusSubresource(branch).
+ Build()
+
+ recorder := record.NewFakeRecorder(10)
+ action := NewCleanupAction(k8sClient, recorder, NewMarkAction(k8sClient, recorder))
+
+ require.NoError(t, action.Apply(context.Background(), branch, Verdict{ExistsInGit: true}))
+
+ updated := getBranch(t, k8sClient, "app-feature")
+ assert.NotContains(t, updated.Labels, codebaseApi.StaleLabel)
+ assert.False(t, meta.IsStatusConditionTrue(updated.Status.Conditions, codebaseApi.ConditionStale))
+}
diff --git a/deploy-templates/README.md b/deploy-templates/README.md
index a76e4336..008756eb 100644
--- a/deploy-templates/README.md
+++ b/deploy-templates/README.md
@@ -23,6 +23,7 @@ A Helm chart for KubeRocketCI Codebase Operator
|-----|------|---------|-------------|
| affinity | object | `{}` | |
| annotations | object | `{}` | |
+| branchStaleCheckInterval | string | `"24h"` | How often the operator verifies that codebase branches still exist in git, marking missing ones with the Stale condition and the app.edp.epam.com/stale label. Accepts Go duration strings (e.g. 24h, 30m); "0" disables the check. |
| enableWebhooks | bool | `true` | Enable webhook and cert-manager certificate resources. Webhooks require cert-manager to be installed in the cluster. |
| envs[0].name | string | `"RECONCILATION_PERIOD"` | |
| envs[0].value | string | `"360"` | |
diff --git a/deploy-templates/crds/v2.edp.epam.com_codebasebranches.yaml b/deploy-templates/crds/v2.edp.epam.com_codebasebranches.yaml
index e410a038..68368ac0 100644
--- a/deploy-templates/crds/v2.edp.epam.com_codebasebranches.yaml
+++ b/deploy-templates/crds/v2.edp.epam.com_codebasebranches.yaml
@@ -37,6 +37,10 @@ spec:
jsonPath: .spec.branchName
name: Branch
type: string
+ - description: Branch is missing in git
+ jsonPath: .status.conditions[?(@.type=="Stale")].status
+ name: Stale
+ type: string
name: v1
schema:
openAPIV3Schema:
@@ -109,6 +113,70 @@ spec:
build:
nullable: true
type: string
+ conditions:
+ description: |-
+ Conditions represent the latest available observations of an object's state.
+ Each condition type is an independent observation (e.g. Stale); new types can be
+ added over time without schema changes.
+ 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
+ nullable: true
+ type: array
+ x-kubernetes-list-map-keys:
+ - type
+ x-kubernetes-list-type: map
detailedMessage:
description: |-
Detailed information regarding action result
diff --git a/deploy-templates/templates/deployment.yaml b/deploy-templates/templates/deployment.yaml
index 1633e1de..08a903d6 100644
--- a/deploy-templates/templates/deployment.yaml
+++ b/deploy-templates/templates/deployment.yaml
@@ -73,6 +73,8 @@ spec:
value: "{{ .Values.telemetryEnabled }}"
- name: ENABLE_WEBHOOKS
value: {{ .Values.enableWebhooks | quote }}
+ - name: BRANCH_STALE_CHECK_INTERVAL
+ value: {{ .Values.branchStaleCheckInterval | quote }}
{{ toYaml .Values.envs | indent 12 }}
resources:
{{ toYaml .Values.resources | indent 12 }}
diff --git a/deploy-templates/values.yaml b/deploy-templates/values.yaml
index f2092f6a..100eeec0 100644
--- a/deploy-templates/values.yaml
+++ b/deploy-templates/values.yaml
@@ -83,3 +83,8 @@ telemetryEnabled: true
# -- Enable webhook and cert-manager certificate resources.
# Webhooks require cert-manager to be installed in the cluster.
enableWebhooks: true
+
+# -- How often the operator verifies that codebase branches still exist in git,
+# marking missing ones with the Stale condition and the app.edp.epam.com/stale label.
+# Accepts Go duration strings (e.g. 24h, 30m); "0" disables the check.
+branchStaleCheckInterval: 24h
diff --git a/docs/api.md b/docs/api.md
index 848e5831..8f17829c 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -460,6 +460,15 @@ CodebaseBranchStatus defines the observed state of CodebaseBranch.
| Name | +Type | +Description | +Required | +
|---|---|---|---|
| lastTransitionTime | +string | +
+ 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 + |
+ true | +
| message | +string | +
+ message is a human readable message indicating details about the transition.
+This may be an empty string. + |
+ true | +
| reason | +string | +
+ 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. + |
+ true | +
| status | +enum | +
+ status of the condition, one of True, False, Unknown. + + Enum: True, False, Unknown + |
+ true | +
| type | +string | +
+ type of condition in CamelCase or in foo.example.com/CamelCase. + |
+ true | +
| observedGeneration | +integer | +
+ 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 + |
+ false | +