Skip to content
Merged
18 changes: 17 additions & 1 deletion api/v1alpha1/rollout_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,26 @@ type RolloutSpec struct {

// VersionHistoryLimit defines the maximum number of entries to keep in the deployment history
// +kubebuilder:validation:Minimum=1
// +kubebuilder:default=5
// +kubebuilder:default=10
// +optional
VersionHistoryLimit *int32 `json:"versionHistoryLimit,omitempty"`

// AvailableReleasesRetentionDays defines how many days of available releases to keep based on creation timestamp
// When history is full, releases older than this retention period may be removed.
// Defaults to 7 days if not specified.
// +kubebuilder:validation:Minimum=1
// +kubebuilder:default=7
// +optional
AvailableReleasesRetentionDays *int32 `json:"availableReleasesRetentionDays,omitempty"`

// AvailableReleasesMinCount defines the minimum number of available releases to always keep
// When history is full, at least this many releases will be retained regardless of other criteria.
// Defaults to 30 if not specified.
// +kubebuilder:validation:Minimum=1
// +kubebuilder:default=30
// +optional
AvailableReleasesMinCount *int32 `json:"availableReleasesMinCount,omitempty"`

// BakeTime specifies how long to wait after bake starts before marking as successful
// If no errors happen within the bake time, the rollout is baked successfully.
// If not specified, no bake time is enforced.
Expand Down
10 changes: 10 additions & 0 deletions api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 19 additions & 1 deletion config/crd/bases/kuberik.com_rollouts.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,24 @@ spec:
spec:
description: RolloutSpec defines the desired state of Rollout.
properties:
availableReleasesMinCount:
default: 30
description: |-
AvailableReleasesMinCount defines the minimum number of available releases to always keep
When history is full, at least this many releases will be retained regardless of other criteria.
Defaults to 30 if not specified.
format: int32
minimum: 1
type: integer
availableReleasesRetentionDays:
default: 7
description: |-
AvailableReleasesRetentionDays defines how many days of available releases to keep based on creation timestamp
When history is full, releases older than this retention period may be removed.
Defaults to 7 days if not specified.
format: int32
minimum: 1
type: integer
bakeTime:
description: |-
BakeTime specifies how long to wait after bake starts before marking as successful
Expand Down Expand Up @@ -198,7 +216,7 @@ spec:
type: object
x-kubernetes-map-type: atomic
versionHistoryLimit:
default: 5
default: 10
description: VersionHistoryLimit defines the maximum number of entries
to keep in the deployment history
format: int32
Expand Down
116 changes: 115 additions & 1 deletion internal/controller/rollout_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@

// Reconcile is part of the main kubernetes reconciliation loop which aims to
// move the current state of the cluster closer to the desired state.
func (r *RolloutReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {

Check failure on line 105 in internal/controller/rollout_controller.go

View workflow job for this annotation

GitHub Actions / Run on Ubuntu

cyclomatic complexity 65 of func `(*RolloutReconciler).Reconcile` is high (> 30) (gocyclo)

Check failure on line 105 in internal/controller/rollout_controller.go

View workflow job for this annotation

GitHub Actions / Run on Ubuntu

cyclomatic complexity 65 of func `(*RolloutReconciler).Reconcile` is high (> 30) (gocyclo)
log := logf.FromContext(ctx)

rollout := rolloutv1alpha1.Rollout{}
Expand Down Expand Up @@ -195,7 +195,7 @@
// Check if user has requested to unblock failed deployment via annotation
unblockRequested := false
if rollout.Annotations != nil {
if unblock, exists := rollout.Annotations["rollout.kuberik.com/unblock-failed"]; exists && unblock == "true" {

Check failure on line 198 in internal/controller/rollout_controller.go

View workflow job for this annotation

GitHub Actions / Run on Ubuntu

string `true` has 4 occurrences, make it a constant (goconst)

Check failure on line 198 in internal/controller/rollout_controller.go

View workflow job for this annotation

GitHub Actions / Run on Ubuntu

string `true` has 4 occurrences, make it a constant (goconst)
unblockRequested = true
log.Info("User requested to unblock failed deployment via annotation")
}
Expand Down Expand Up @@ -480,7 +480,7 @@
}

// parseOCIManifest extracts all metadata from OCI image manifest including version, revision, artifact type, source, title, and description.
func (r *RolloutReconciler) parseOCIManifest(ctx context.Context, imageRef string, imagePolicy *imagev1beta2.ImagePolicy) (version, revision, artifactType, source, title, description *string, created *metav1.Time, err error) {

Check failure on line 483 in internal/controller/rollout_controller.go

View workflow job for this annotation

GitHub Actions / Run on Ubuntu

cyclomatic complexity 48 of func `(*RolloutReconciler).parseOCIManifest` is high (> 30) (gocyclo)

Check failure on line 483 in internal/controller/rollout_controller.go

View workflow job for this annotation

GitHub Actions / Run on Ubuntu

cyclomatic complexity 48 of func `(*RolloutReconciler).parseOCIManifest` is high (> 30) (gocyclo)
log := logf.FromContext(ctx)

// Get authentication keychain from ImageRepository
Expand Down Expand Up @@ -719,7 +719,7 @@
}

// evaluateGates lists and evaluates gates, updates rollout status, and returns filtered candidates and gatesPassing.
func (r *RolloutReconciler) evaluateGates(ctx context.Context, namespace string, rollout *rolloutv1alpha1.Rollout, releaseCandidates []rolloutv1alpha1.VersionInfo) ([]rolloutv1alpha1.VersionInfo, bool, error) {

Check failure on line 722 in internal/controller/rollout_controller.go

View workflow job for this annotation

GitHub Actions / Run on Ubuntu

cyclomatic complexity 32 of func `(*RolloutReconciler).evaluateGates` is high (> 30) (gocyclo)

Check failure on line 722 in internal/controller/rollout_controller.go

View workflow job for this annotation

GitHub Actions / Run on Ubuntu

cyclomatic complexity 32 of func `(*RolloutReconciler).evaluateGates` is high (> 30) (gocyclo)
// Check for gate bypass annotation
bypassVersion := ""
if rollout.Annotations != nil {
Expand Down Expand Up @@ -1071,7 +1071,7 @@
}

// deployRelease finds and patches Flux resources with the wanted version.
func (r *RolloutReconciler) deployRelease(ctx context.Context, rollout *rolloutv1alpha1.Rollout, wantedRelease string) error {

Check failure on line 1074 in internal/controller/rollout_controller.go

View workflow job for this annotation

GitHub Actions / Run on Ubuntu

cyclomatic complexity 46 of func `(*RolloutReconciler).deployRelease` is high (> 30) (gocyclo)

Check failure on line 1074 in internal/controller/rollout_controller.go

View workflow job for this annotation

GitHub Actions / Run on Ubuntu

cyclomatic complexity 46 of func `(*RolloutReconciler).deployRelease` is high (> 30) (gocyclo)
log := logf.FromContext(ctx)

// Check if this deployment was done with gate bypass
Expand Down Expand Up @@ -1210,12 +1210,16 @@
BakeEndTime: nil, // Will be set when bake completes (succeeds, fails, or times out)
}}, rollout.Status.History...)
// Limit history size if specified
versionHistoryLimit := int32(5) // default value
versionHistoryLimit := int32(10) // default value
if rollout.Spec.VersionHistoryLimit != nil {
versionHistoryLimit = *rollout.Spec.VersionHistoryLimit
}
if int32(len(rollout.Status.History)) > versionHistoryLimit {
rollout.Status.History = rollout.Status.History[:versionHistoryLimit]
// If history is full, remove available versions based on retention criteria
if len(rollout.Status.History) > 0 {
r.cleanupOldAvailableReleases(rollout, log)
}
}

// Update the condition message to reflect if gates were bypassed
Expand Down Expand Up @@ -1317,6 +1321,116 @@
return nil
}

// cleanupOldAvailableReleases removes available releases based on multiple retention criteria.
// This is called when history is full to prevent AvailableReleases from growing unbounded.
// It keeps the maximum number of releases that satisfy at least one of:
// 1. Everything up to and including the history entry that's furthest down in AvailableReleases
// 2. All releases created within the retention period (configurable via AvailableReleasesRetentionDays, default: 7 days)
// 3. At least a minimum number of releases (configurable via AvailableReleasesMinCount, default: 30)
// We keep the maximum number that satisfies any of these criteria.
func (r *RolloutReconciler) cleanupOldAvailableReleases(rollout *rolloutv1alpha1.Rollout, log logr.Logger) {
if len(rollout.Status.History) == 0 || len(rollout.Status.AvailableReleases) == 0 {
return
}

// Get configurable retention values with defaults
retentionDays := int32(7) // default value
if rollout.Spec.AvailableReleasesRetentionDays != nil {
retentionDays = *rollout.Spec.AvailableReleasesRetentionDays
}

minReleases := int32(30) // default value
if rollout.Spec.AvailableReleasesMinCount != nil {
minReleases = *rollout.Spec.AvailableReleasesMinCount
}

cutoffTime := r.now().Add(-time.Duration(retentionDays) * 24 * time.Hour)

updatedReleases := CalculateAvailableReleasesToKeep(
rollout.Status.AvailableReleases,
rollout.Status.History,
cutoffTime,
int(minReleases),
)

if len(updatedReleases) < len(rollout.Status.AvailableReleases) {
removedCount := len(rollout.Status.AvailableReleases) - len(updatedReleases)
rollout.Status.AvailableReleases = updatedReleases
log.Info("Cleaned up old available releases",
"removed", removedCount,
"remaining", len(updatedReleases))
}
}

// CalculateAvailableReleasesToKeep determines which available releases should be retained based on multiple criteria.
// It returns a slice of VersionInfo that should be kept.
// releases: the current list of available releases (sorted oldest to newest)
// history: the current deployment history
// cutoffTime: releases older than this may be removed (unless kept by other criteria)
// minReleases: keep at least this many newest releases
func CalculateAvailableReleasesToKeep(
releases []rolloutv1alpha1.VersionInfo,
history []rolloutv1alpha1.DeploymentHistoryEntry,
cutoffTime time.Time,
minReleases int,
) []rolloutv1alpha1.VersionInfo {
if len(releases) == 0 {
return nil
}

// Criterion 1: Keep everything from the lowest history entry index to the end
minHistoryIndex := len(releases)
for _, historyEntry := range history {
targetTag := historyEntry.Version.Tag
for i := 0; i < len(releases); i++ {
if releases[i].Tag == targetTag {
if i < minHistoryIndex {
minHistoryIndex = i
}
break
}
}
}
criterion1KeepFromEnd := 0
if minHistoryIndex < len(releases) {
criterion1KeepFromEnd = len(releases) - minHistoryIndex
}

// Criterion 2: Keep releases within the retention period (newer than cutoffTime)
retentionTimeIndex := 0
for i := len(releases) - 1; i >= 0; i-- {
if releases[i].Created != nil && releases[i].Created.Time.Before(cutoffTime) {
// This release is too old, so we keep everything after it (i+1 onwards)
retentionTimeIndex = i + 1
break
}
}
criterion2KeepFromEnd := len(releases) - retentionTimeIndex

// Criterion 3: Keep at least a minimum number of releases
criterion3KeepFromEnd := minReleases
if criterion3KeepFromEnd > len(releases) {
criterion3KeepFromEnd = len(releases)
}

// Keep the maximum number of releases that satisfy any of the criteria
keepFromEnd := criterion1KeepFromEnd
if criterion2KeepFromEnd > keepFromEnd {
keepFromEnd = criterion2KeepFromEnd
}
if criterion3KeepFromEnd > keepFromEnd {
keepFromEnd = criterion3KeepFromEnd
}

// Ensure we don't exceed the actual list length
if keepFromEnd >= len(releases) {
return releases
}

// Return the newest keepFromEnd releases
return releases[len(releases)-keepFromEnd:]
}

// patchOCIRepositories finds OCIRepositories with rollout annotation and patches their tag.
func (r *RolloutReconciler) patchOCIRepositories(ctx context.Context, rollout *rolloutv1alpha1.Rollout, wantedRelease string) error {
log := logf.FromContext(ctx)
Expand Down Expand Up @@ -1465,7 +1579,7 @@
return nil
}

func (r *RolloutReconciler) handleBakeTime(ctx context.Context, namespace string, rollout *rolloutv1alpha1.Rollout) (ctrl.Result, error) {

Check failure on line 1582 in internal/controller/rollout_controller.go

View workflow job for this annotation

GitHub Actions / Run on Ubuntu

cyclomatic complexity 41 of func `(*RolloutReconciler).handleBakeTime` is high (> 30) (gocyclo)

Check failure on line 1582 in internal/controller/rollout_controller.go

View workflow job for this annotation

GitHub Actions / Run on Ubuntu

cyclomatic complexity 41 of func `(*RolloutReconciler).handleBakeTime` is high (> 30) (gocyclo)
log := logf.FromContext(ctx)
now := r.now()

Expand Down
41 changes: 41 additions & 0 deletions internal/controller/rollout_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import (
"context"
"fmt"
"time"

"github.com/docker/cli/cli/config/configfile"
Expand Down Expand Up @@ -236,7 +237,7 @@
Expect(updatedRollout.Status.History[0].Version.Tag).To(Equal("1.0.0"))

By("Deploying second version")
imagePolicy.Status.LatestRef.Tag = "2.0.0"

Check failure on line 240 in internal/controller/rollout_controller_test.go

View workflow job for this annotation

GitHub Actions / Run on Ubuntu

string `2.0.0` has 5 occurrences, make it a constant (goconst)

Check failure on line 240 in internal/controller/rollout_controller_test.go

View workflow job for this annotation

GitHub Actions / Run on Ubuntu

string `2.0.0` has 5 occurrences, make it a constant (goconst)
Expect(k8sClient.Status().Update(ctx, imagePolicy)).To(Succeed())
_, err = controllerReconciler.Reconcile(ctx, reconcile.Request{
NamespacedName: typeNamespacedName,
Expand All @@ -256,7 +257,7 @@
Expect(updatedRollout.Status.History[1].Version.Tag).To(Equal("1.0.0"))

By("Deploying third version")
imagePolicy.Status.LatestRef.Tag = "3.0.0"

Check failure on line 260 in internal/controller/rollout_controller_test.go

View workflow job for this annotation

GitHub Actions / Run on Ubuntu

string `3.0.0` has 4 occurrences, make it a constant (goconst)

Check failure on line 260 in internal/controller/rollout_controller_test.go

View workflow job for this annotation

GitHub Actions / Run on Ubuntu

string `3.0.0` has 4 occurrences, make it a constant (goconst)
Expect(k8sClient.Status().Update(ctx, imagePolicy)).To(Succeed())
_, err = controllerReconciler.Reconcile(ctx, reconcile.Request{
NamespacedName: typeNamespacedName,
Expand Down Expand Up @@ -455,6 +456,46 @@
Expect(updatedRollout.Status.History[2].Version.Tag).To(Equal("0.2.0"))
})

It("should use default history limit of 10 when not specified", func() {
By("Ensuring VersionHistoryLimit uses default")
rollout := &rolloutv1alpha1.Rollout{}
err := k8sClient.Get(ctx, typeNamespacedName, rollout)
Expect(err).NotTo(HaveOccurred())
// Kubebuilder defaults may be applied, but we want to test the default behavior
// So we'll just proceed without setting it explicitly

By("Reconciling the resources")
controllerReconciler := &RolloutReconciler{
Client: k8sClient,
Scheme: k8sClient.Scheme(),
}

// Deploy 11 versions to exceed the default limit of 10
for i := 1; i <= 11; i++ {
version := fmt.Sprintf("0.%d.0", i)
imagePolicy.Status.LatestRef = &imagev1beta2.ImageRef{
Tag: version,
}
Expect(k8sClient.Status().Update(ctx, imagePolicy)).To(Succeed())
_, err = controllerReconciler.Reconcile(ctx, reconcile.Request{
NamespacedName: typeNamespacedName,
})
Expect(err).NotTo(HaveOccurred())
}

By("Verifying that history is limited to default of 10")
updatedRollout := &rolloutv1alpha1.Rollout{}
err = k8sClient.Get(ctx, typeNamespacedName, updatedRollout)
Expect(err).NotTo(HaveOccurred())

Expect(updatedRollout.Status.History).To(HaveLen(10), "History should be limited to default of 10 entries")
// Verify the most recent versions are present (0.11.0 through 0.2.0)
Expect(updatedRollout.Status.History[0].Version.Tag).To(Equal("0.11.0"))
Expect(updatedRollout.Status.History[9].Version.Tag).To(Equal("0.2.0"))
// Verify 0.1.0 was removed (it's the oldest)
Expect(updatedRollout.Status.History).To(Not(ContainElement(HaveField("Version.Tag", "0.1.0"))))
})

It("should respect the wanted version override", func() {
By("Setting available releases")
rollout.Status.AvailableReleases = []rolloutv1alpha1.VersionInfo{
Expand Down Expand Up @@ -3139,7 +3180,7 @@
imagePolicy.Status.LatestRef.Tag = "2.0.0"
Expect(k8sClient.Status().Update(ctx, &imagePolicy)).To(Succeed())

imagePolicy.Status.LatestRef.Tag = "1.0.0"

Check failure on line 3183 in internal/controller/rollout_controller_test.go

View workflow job for this annotation

GitHub Actions / Run on Ubuntu

string `1.0.0` has 4 occurrences, make it a constant (goconst)

Check failure on line 3183 in internal/controller/rollout_controller_test.go

View workflow job for this annotation

GitHub Actions / Run on Ubuntu

string `1.0.0` has 4 occurrences, make it a constant (goconst)
Expect(k8sClient.Status().Update(ctx, &imagePolicy)).To(Succeed())

imagePolicy.Status.LatestRef.Tag = "3.0.0"
Expand Down
Loading
Loading