Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/object-controller/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ func newManager(cfg *config, restConfig *rest.Config) (manager.Manager, error) {
}
if err := (&controllers.ClusterObjectDeploymentReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
}).SetupWithManager(mgr); err != nil {
return nil, fmt.Errorf("setting up ClusterObjectDeployment controller: %w", err)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,35 @@
package controllers

import (
"cmp"
"context"
"errors"
"fmt"
"slices"

"k8s.io/apimachinery/pkg/api/equality"
apimeta "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
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/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/predicate"

ocv1 "github.com/operator-framework/operator-controller/api/v1"
"github.com/operator-framework/operator-controller/internal/shared/labels"
hashutil "github.com/operator-framework/operator-controller/internal/shared/util/hash"
)

// ClusterObjectDeploymentReconciler reconciles ClusterObjectDeployment objects by
// managing their owned ClusterObjectSet revisions.
//
// This is a skeleton: it does not yet implement any reconciliation behavior.
type ClusterObjectDeploymentReconciler struct {
Client client.Client
Scheme *runtime.Scheme
}

//+kubebuilder:rbac:groups=olm.operatorframework.io,resources=clusterobjectdeployments,verbs=get;list;watch;update;patch;create;delete
Expand All @@ -33,18 +43,99 @@ func (c *ClusterObjectDeploymentReconciler) Reconcile(ctx context.Context, req c
l := log.FromContext(ctx).WithName("cluster-object-deployment")
ctx = log.IntoContext(ctx, l)

existingDeployment := &ocv1.ClusterObjectDeployment{}
if err := c.Client.Get(ctx, req.NamespacedName, existingDeployment); err != nil {
existing := &ocv1.ClusterObjectDeployment{}
if err := c.Client.Get(ctx, req.NamespacedName, existing); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
if !existing.DeletionTimestamp.IsZero() {
// Owned ClusterObjectSets are removed by Kubernetes cascading deletion via
// their controller owner reference; nothing to do here.
return ctrl.Result{}, nil
}

l.Info("reconcile starting")
defer l.Info("reconcile ending")

// No reconciliation behavior yet. This skeleton establishes the controller,
// its watches, and its RBAC so that behavior can be layered on in follow-ups.
reconciled := existing.DeepCopy()
reconcileErr := c.reconcile(ctx, reconciled)

if !equality.Semantic.DeepEqual(existing.Status, reconciled.Status) {
if err := c.Client.Status().Update(ctx, reconciled); err != nil {
reconcileErr = errors.Join(reconcileErr, fmt.Errorf("updating status: %w", err))
}
}

return ctrl.Result{}, reconcileErr
}

// reconcile creates a new ClusterObjectSet revision when the template has changed
// (or no active revision exists) and reflects the latest revision's rollout
// progress in the deployment's Progressing condition. Requeues are driven by the
// controller's watch on owned ClusterObjectSets.
func (c *ClusterObjectDeploymentReconciler) reconcile(ctx context.Context, cod *ocv1.ClusterObjectDeployment) error {
revisions, err := c.listRevisions(ctx, cod.Name)
if err != nil {
return err
}

hash := templateHash(cod.Spec.Template)

latest := latestActiveRevision(revisions)
if latest == nil || latest.Labels[labels.TemplateHashKey] != hash {
if err := c.createRevision(ctx, cod, revisions, hash); err != nil {
setProgressing(cod, metav1.ConditionTrue, ocv1.ClusterObjectDeploymentReasonNewClusterObjectSetProgressing, "waiting for new revision to be created")
return err
}
Comment thread
perdasilva marked this conversation as resolved.
setProgressing(cod, metav1.ConditionTrue, ocv1.ClusterObjectDeploymentReasonNewClusterObjectSetProgressing, "waiting for new revision to be created")
return nil
}

if latest.Status.CompletedAt.IsZero() {
setProgressing(cod, metav1.ConditionTrue, ocv1.ClusterObjectDeploymentReasonNewClusterObjectSetProgressing, "latest revision is progressing")
} else {
setProgressing(cod, metav1.ConditionFalse, ocv1.ClusterObjectDeploymentReasonNewClusterObjectSetProgressed, "latest revision has progressed")
}
return nil
}

// listRevisions returns all ClusterObjectSets in the deployment's group, sorted
// ascending by revision number.
func (c *ClusterObjectDeploymentReconciler) listRevisions(ctx context.Context, group string) ([]ocv1.ClusterObjectSet, error) {
var list ocv1.ClusterObjectSetList
if err := c.Client.List(ctx, &list, client.MatchingFields{clusterObjectSetGroupIndex: group}); err != nil {
return nil, fmt.Errorf("listing revisions for group %q: %w", group, err)
}
slices.SortFunc(list.Items, func(a, b ocv1.ClusterObjectSet) int {
return cmp.Compare(a.Spec.Revision, b.Spec.Revision)
})
return list.Items, nil
}

// createRevision builds and creates the next ClusterObjectSet revision owned by
// the deployment. Any create error, including AlreadyExists, is returned.
//
// TODO: revisit AlreadyExists handling under the adoption use-case.
func (c *ClusterObjectDeploymentReconciler) createRevision(ctx context.Context, cod *ocv1.ClusterObjectDeployment, revisions []ocv1.ClusterObjectSet, hash string) error {
revision := nextRevisionNumber(revisions)
cos := buildClusterObjectSet(cod, revision, hash)
if err := controllerutil.SetControllerReference(cod, cos, c.Scheme); err != nil {
return fmt.Errorf("setting controller reference: %w", err)
}
if err := c.Client.Create(ctx, cos); err != nil {
return fmt.Errorf("creating revision %d: %w", revision, err)
}
return nil
}

return ctrl.Result{}, nil
// setProgressing sets the deployment's Progressing condition.
func setProgressing(cod *ocv1.ClusterObjectDeployment, status metav1.ConditionStatus, reason, message string) {
apimeta.SetStatusCondition(&cod.Status.Conditions, metav1.Condition{
Type: ocv1.ClusterObjectDeploymentTypeProgressing,
Status: status,
Reason: reason,
Message: message,
ObservedGeneration: cod.Generation,
})
}

func (c *ClusterObjectDeploymentReconciler) SetupWithManager(mgr ctrl.Manager) error {
Expand Down Expand Up @@ -80,3 +171,67 @@ func mapClusterObjectSetToClusterObjectDeployment(_ context.Context, obj client.
}
return []ctrl.Request{{NamespacedName: types.NamespacedName{Name: cos.Spec.Group}}}
}

// templateHash returns a stable digest of the deployment template. Any change to
// the template (including changes to fields that are mutable on a ClusterObjectSet)
// produces a different digest, which is how the controller decides a new revision
// is required. The shared DeepHashObject helper base36-encodes a sha224 of the
// template's JSON, which is label-safe and short enough to use as a label value
// without truncation.
func templateHash(template ocv1.ClusterObjectDeploymentTemplate) string {
return hashutil.DeepHashObject(template)
}

// nextRevisionNumber returns the revision number for a new revision: one greater
// than the highest revision across all revisions in the group (including archived
// ones, so revision numbers are never reused).
func nextRevisionNumber(revisions []ocv1.ClusterObjectSet) int64 {
var max int64
for i := range revisions {
if revisions[i].Spec.Revision > max {
max = revisions[i].Spec.Revision
}
}
return max + 1
}

// latestActiveRevision returns the highest-revision ClusterObjectSet that is
// neither archived nor being deleted, or nil if there is none.
func latestActiveRevision(revisions []ocv1.ClusterObjectSet) *ocv1.ClusterObjectSet {
var latest *ocv1.ClusterObjectSet
for i := range revisions {
rev := &revisions[i]
if !rev.DeletionTimestamp.IsZero() || rev.Spec.LifecycleState == ocv1.ClusterObjectSetLifecycleStateArchived {
continue
}
if latest == nil || rev.Spec.Revision > latest.Spec.Revision {
latest = rev
}
}
return latest
}

// buildClusterObjectSet constructs the ClusterObjectSet for a revision from the
// deployment template. It does not set the owner reference; the caller does.
func buildClusterObjectSet(cod *ocv1.ClusterObjectDeployment, revision int64, hash string) *ocv1.ClusterObjectSet {
return &ocv1.ClusterObjectSet{
ObjectMeta: metav1.ObjectMeta{
Name: fmt.Sprintf("%s-%d", cod.Name, revision),
Labels: map[string]string{
// TemplateHashKey records which template produced this revision so the
// controller can detect template changes. Ownership is expressed through
// the controller owner reference and the revision's spec.group, so no
// owner-kind/owner-name labels are set.
labels.TemplateHashKey: hash,
},
},
Spec: ocv1.ClusterObjectSetSpec{
Group: cod.Name,
Revision: revision,
LifecycleState: ocv1.ClusterObjectSetLifecycleStateActive,
Phases: cod.Spec.Template.Spec.Phases,
ProgressionProbes: cod.Spec.Template.Spec.ProgressionProbes,
CollisionProtection: cod.Spec.Template.Spec.CollisionProtection,
},
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,116 @@ package controllers

import (
"testing"
"time"

"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"

ocv1 "github.com/operator-framework/operator-controller/api/v1"
"github.com/operator-framework/operator-controller/internal/shared/labels"
)

func testTemplate() ocv1.ClusterObjectDeploymentTemplate {
return ocv1.ClusterObjectDeploymentTemplate{
Spec: ocv1.ClusterObjectDeploymentTemplateSpec{
CollisionProtection: ocv1.CollisionProtectionNone,
Phases: []ocv1.ClusterObjectSetPhase{
{
Name: "resources",
Objects: []ocv1.ClusterObjectSetObject{
{Object: unstructured.Unstructured{Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": map[string]interface{}{"name": "cm", "namespace": "ns"},
}}},
},
},
},
},
}
}

func Test_templateHash_isStableAndSensitive(t *testing.T) {
tmpl := testTemplate()

h1 := templateHash(tmpl)
require.NotEmpty(t, h1)
require.LessOrEqual(t, len(h1), 63, "digest must fit within the label-value length limit")
require.Regexp(t, "^[0-9a-z]+$", h1, "digest must be label-value safe")

h2 := templateHash(tmpl)
require.Equal(t, h1, h2, "same template must hash the same")

// A change to a phase object changes the hash.
changed := testTemplate()
changed.Spec.Phases[0].Objects[0].Object.Object["data"] = map[string]interface{}{"k": "v"}
require.NotEqual(t, h1, templateHash(changed))

// A change to ONLY progressionProbes (a mutable COS field) still changes the hash.
probesChanged := testTemplate()
probesChanged.Spec.ProgressionProbes = []ocv1.ProgressionProbe{{
Selector: ocv1.ObjectSelector{Type: ocv1.SelectorTypeGroupKind, GroupKind: metav1.GroupKind{Kind: "ConfigMap"}},
Assertions: []ocv1.Assertion{{Type: ocv1.ProbeTypeFieldValue}},
}}
require.NotEqual(t, h1, templateHash(probesChanged), "a progressionProbes-only change must create a new revision")
}

func Test_nextRevisionNumber(t *testing.T) {
require.Equal(t, int64(1), nextRevisionNumber(nil))
revs := []ocv1.ClusterObjectSet{
{Spec: ocv1.ClusterObjectSetSpec{Revision: 1}},
{Spec: ocv1.ClusterObjectSetSpec{Revision: 3, LifecycleState: ocv1.ClusterObjectSetLifecycleStateArchived}},
}
require.Equal(t, int64(4), nextRevisionNumber(revs), "must count archived revisions so numbers never repeat")
}

func Test_latestActiveRevision(t *testing.T) {
require.Nil(t, latestActiveRevision(nil))

// Highest-revision active revision wins.
revs := []ocv1.ClusterObjectSet{
{ObjectMeta: metav1.ObjectMeta{Name: "cod-1"}, Spec: ocv1.ClusterObjectSetSpec{Revision: 1, LifecycleState: ocv1.ClusterObjectSetLifecycleStateActive}},
{ObjectMeta: metav1.ObjectMeta{Name: "cod-2"}, Spec: ocv1.ClusterObjectSetSpec{Revision: 2, LifecycleState: ocv1.ClusterObjectSetLifecycleStateActive}},
}
require.Equal(t, "cod-2", latestActiveRevision(revs).Name)

// All archived -> nil.
archived := []ocv1.ClusterObjectSet{
{Spec: ocv1.ClusterObjectSetSpec{Revision: 1, LifecycleState: ocv1.ClusterObjectSetLifecycleStateArchived}},
}
require.Nil(t, latestActiveRevision(archived))

// A higher-revision revision being deleted is skipped.
deleting := metav1.NewTime(time.Now())
mixed := []ocv1.ClusterObjectSet{
{ObjectMeta: metav1.ObjectMeta{Name: "cod-1"}, Spec: ocv1.ClusterObjectSetSpec{Revision: 1, LifecycleState: ocv1.ClusterObjectSetLifecycleStateActive}},
{ObjectMeta: metav1.ObjectMeta{Name: "cod-2", DeletionTimestamp: &deleting, Finalizers: []string{"x"}}, Spec: ocv1.ClusterObjectSetSpec{Revision: 2, LifecycleState: ocv1.ClusterObjectSetLifecycleStateActive}},
}
require.Equal(t, "cod-1", latestActiveRevision(mixed).Name)
}

func Test_buildClusterObjectSet(t *testing.T) {
cod := &ocv1.ClusterObjectDeployment{
ObjectMeta: metav1.ObjectMeta{Name: "cod"},
Spec: ocv1.ClusterObjectDeploymentSpec{Template: testTemplate()},
}

cos := buildClusterObjectSet(cod, 1, "deadbeef")

require.Equal(t, "cod-1", cos.Name)
require.Equal(t, "deadbeef", cos.Labels[labels.TemplateHashKey])
require.NotContains(t, cos.Labels, labels.OwnerNameKey)
require.Equal(t, "cod", cos.Spec.Group)
require.Equal(t, int64(1), cos.Spec.Revision)
require.Equal(t, ocv1.ClusterObjectSetLifecycleStateActive, cos.Spec.LifecycleState)
require.Equal(t, cod.Spec.Template.Spec.Phases, cos.Spec.Phases)
require.Equal(t, ocv1.CollisionProtectionNone, cos.Spec.CollisionProtection, "template collisionProtection must be copied through")
}

func Test_mapClusterObjectSetToClusterObjectDeployment(t *testing.T) {
for _, tc := range []struct {
name string
Expand Down
Loading
Loading