Skip to content

Commit

Permalink
revisit deletion handling, update website (#180)
Browse files Browse the repository at this point in the history
  • Loading branch information
cbarbian-sap authored Dec 9, 2024
1 parent 9a01b9e commit 88c5286
Show file tree
Hide file tree
Showing 11 changed files with 168 additions and 53 deletions.
37 changes: 31 additions & 6 deletions pkg/component/component.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"time"

"github.com/sap/component-operator-runtime/internal/walk"
"github.com/sap/component-operator-runtime/pkg/reconciler"
)

// Instantiate given Component type T; panics unless T is a pointer type.
Expand Down Expand Up @@ -97,6 +98,17 @@ func assertTimeoutConfiguration[T Component](component T) (TimeoutConfiguration,
return nil, false
}

// Check if given component or its spec implements PolicyConfiguration (and return it).
func assertPolicyConfiguration[T Component](component T) (PolicyConfiguration, bool) {
if policyConfiguration, ok := Component(component).(PolicyConfiguration); ok {
return policyConfiguration, true
}
if policyConfiguration, ok := getSpec(component).(PolicyConfiguration); ok {
return policyConfiguration, true
}
return nil, false
}

// Calculate digest of given component, honoring annotations, spec, and references.
func calculateComponentDigest[T Component](component T) string {
digestData := make(map[string]any)
Expand Down Expand Up @@ -181,12 +193,6 @@ func (s *RetrySpec) GetRetryInterval() time.Duration {
return time.Duration(0)
}

// Check if state is Ready.
func (s *Status) IsReady() bool {
// caveat: this operates only on the status, so it does not check that observedGeneration == generation
return s.State == StateReady
}

// Implement the TimeoutConfiguration interface.
func (s *TimeoutSpec) GetTimeout() time.Duration {
if s.Timeout != nil {
Expand All @@ -195,6 +201,25 @@ func (s *TimeoutSpec) GetTimeout() time.Duration {
return time.Duration(0)
}

// Implement the PolicyConfiguration interface.
func (s *PolicySpec) GetAdoptionPolicy() reconciler.AdoptionPolicy {
return s.AdoptionPolicy
}

func (s *PolicySpec) GetUpdatePolicy() reconciler.UpdatePolicy {
return s.UpdatePolicy
}

func (s *PolicySpec) GetDeletePolicy() reconciler.DeletePolicy {
return s.DeletePolicy
}

// Check if state is Ready.
func (s *Status) IsReady() bool {
// caveat: this operates only on the status, so it does not check that observedGeneration == generation
return s.State == StateReady
}

// Get condition (and return nil if not existing).
// Caveat: the returned pointer might become invalid if further appends happen to the Conditions slice in the status object.
func (s *Status) getCondition(condType ConditionType) *Condition {
Expand Down
56 changes: 42 additions & 14 deletions pkg/component/reconciler.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ import (
// TODO: emitting events to deployment target may fail if corresponding rbac privileges are missing; either this should be pre-discovered or we
// should stop emitting events to remote targets at all; howerver pre-discovering is difficult (may vary from object to object); one option could
// be to send events only if we are cluster-admin
// TODO: allow to override namespace auto-creation and policies on a per-component level
// (e.g. through annotations or another interface that components could optionally implement)
// TODO: allow to override namespace auto-creation and reconcile policy on a per-component level
// that is: consider adding them to the PolicyConfiguration interface?
// TODO: allow to override namespace auto-creation on a per-object level
// TODO: allow some timeout feature, such that component will go into error state if not ready within the given timeout
// (e.g. through a TimeoutConfiguration interface that components could optionally implement)
Expand Down Expand Up @@ -101,6 +101,10 @@ type ReconcilerOptions struct {
// If unspecified, UpdatePolicyReplace is assumed.
// Can be overridden by annotation on object level.
UpdatePolicy *reconciler.UpdatePolicy
// How to perform deletion of dependent objects.
// If unspecified, DeletePolicyDelete is assumed.
// Can be overridden by annotation on object level.
DeletePolicy *reconciler.DeletePolicy
// SchemeBuilder allows to define additional schemes to be made available in the
// target client.
SchemeBuilder types.SchemeBuilder
Expand Down Expand Up @@ -133,6 +137,8 @@ type Reconciler[T Component] struct {
// resourceGenerator must be an implementation of the manifests.Generator interface.
func NewReconciler[T Component](name string, resourceGenerator manifests.Generator, options ReconcilerOptions) *Reconciler[T] {
// TOOD: validate options
// TODO: currently, the defaulting of CreateMissingNamespaces and *Policy here is identical to the defaulting in the underlying reconciler.Reconciler;
// under the assumption that these attributes are not used here, we could skip the defaulting here, and let it happen in the underlying implementation only
if options.CreateMissingNamespaces == nil {
options.CreateMissingNamespaces = ref(true)
}
Expand All @@ -142,6 +148,9 @@ func NewReconciler[T Component](name string, resourceGenerator manifests.Generat
if options.UpdatePolicy == nil {
options.UpdatePolicy = ref(reconciler.UpdatePolicyReplace)
}
if options.DeletePolicy == nil {
options.DeletePolicy = ref(reconciler.DeletePolicyDelete)
}

return &Reconciler[T]{
name: name,
Expand Down Expand Up @@ -330,18 +339,8 @@ func (r *Reconciler[T]) Reconcile(ctx context.Context, req ctrl.Request) (result
if err != nil {
return ctrl.Result{}, errors.Wrap(err, "error getting client for component")
}
target := newReconcileTarget[T](r.name, r.id, targetClient, r.resourceGenerator, reconciler.ReconcilerOptions{
CreateMissingNamespaces: r.options.CreateMissingNamespaces,
AdoptionPolicy: r.options.AdoptionPolicy,
UpdatePolicy: r.options.UpdatePolicy,
StatusAnalyzer: r.statusAnalyzer,
Metrics: reconciler.ReconcilerMetrics{
ReadCounter: metrics.Operations.WithLabelValues(r.controllerName, "read"),
CreateCounter: metrics.Operations.WithLabelValues(r.controllerName, "create"),
UpdateCounter: metrics.Operations.WithLabelValues(r.controllerName, "update"),
DeleteCounter: metrics.Operations.WithLabelValues(r.controllerName, "delete"),
},
})
targetOptions := r.getOptionsForComponent(component)
target := newReconcileTarget[T](r.name, r.id, targetClient, r.resourceGenerator, targetOptions)
// TODO: enhance ctx with tailored logger and event recorder
// TODO: enhance ctx with the local client
hookCtx = NewContext(ctx).WithReconcilerName(r.name).WithClient(targetClient)
Expand Down Expand Up @@ -636,3 +635,32 @@ func (r *Reconciler[T]) getClientForComponent(component T) (cluster.Client, erro
}
return clnt, nil
}

func (r *Reconciler[T]) getOptionsForComponent(component T) reconciler.ReconcilerOptions {
options := reconciler.ReconcilerOptions{
CreateMissingNamespaces: r.options.CreateMissingNamespaces,
AdoptionPolicy: r.options.AdoptionPolicy,
UpdatePolicy: r.options.UpdatePolicy,
DeletePolicy: r.options.DeletePolicy,
StatusAnalyzer: r.statusAnalyzer,
Metrics: reconciler.ReconcilerMetrics{
ReadCounter: metrics.Operations.WithLabelValues(r.controllerName, "read"),
CreateCounter: metrics.Operations.WithLabelValues(r.controllerName, "create"),
UpdateCounter: metrics.Operations.WithLabelValues(r.controllerName, "update"),
DeleteCounter: metrics.Operations.WithLabelValues(r.controllerName, "delete"),
},
}
if policyConfiguration, ok := assertPolicyConfiguration(component); ok {
// TODO: check the values returned by the PolicyConfiguration
if adoptionPolicy := policyConfiguration.GetAdoptionPolicy(); adoptionPolicy != "" {
options.AdoptionPolicy = &adoptionPolicy
}
if updatePolicy := policyConfiguration.GetUpdatePolicy(); updatePolicy != "" {
options.UpdatePolicy = &updatePolicy
}
if deletePolicy := policyConfiguration.GetDeletePolicy(); deletePolicy != "" {
options.DeletePolicy = &deletePolicy
}
}
return options
}
29 changes: 29 additions & 0 deletions pkg/component/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,20 @@ type TimeoutConfiguration interface {
GetTimeout() time.Duration
}

// The PolicyConfiguration interface is meant to be implemented by compoments (or their spec) which offer
// tweaking policies affecting the dependents handling.
type PolicyConfiguration interface {
// Get adoption policy.
// Must return a valid AdoptionPolicy, or the empty string (then the reconciler/framework default applies).
GetAdoptionPolicy() reconciler.AdoptionPolicy
// Get update policy.
// Must return a valid UpdatePolicy, or the empty string (then the reconciler/framework default applies).
GetUpdatePolicy() reconciler.UpdatePolicy
// Get delete policy.
// Must return a valid DeletePolicy, or the empty string (then the reconciler/framework default applies).
GetDeletePolicy() reconciler.DeletePolicy
}

// +kubebuilder:object:generate=true

// Legacy placement spec. Components may include this into their spec.
Expand Down Expand Up @@ -167,6 +181,21 @@ var _ TimeoutConfiguration = &TimeoutSpec{}

// +kubebuilder:object:generate=true

// PolicySpec defines some of the policies tuning the reconciliation of the compooment's dependent objects.
// Components providing PolicyConfiguration may include this into their spec.
type PolicySpec struct {
// +kubebuilder:validation:Enum=Never;IfUnowned;Always
AdoptionPolicy reconciler.AdoptionPolicy `json:"adoptionPolicy,omitempty"`
// +kubebuilder:validation:Enum=Recreate;Replace;SsaMerge;SsaOverride
UpdatePolicy reconciler.UpdatePolicy `json:"updatePolicy,omitempty"`
// +kubebuilder:validation:Enum=Delete;Orphan
DeletePolicy reconciler.DeletePolicy `json:"deletePolicy,omitempty"`
}

var _ PolicyConfiguration = &PolicySpec{}

// +kubebuilder:object:generate=true

// Component Status. Components must include this into their status.
type Status struct {
ObservedGeneration int64 `json:"observedGeneration"`
Expand Down
15 changes: 15 additions & 0 deletions pkg/component/zz_generated.deepcopy.go

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

30 changes: 23 additions & 7 deletions pkg/reconciler/reconciler.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ type ReconcilerOptions struct {
// If unspecified, UpdatePolicyReplace is assumed.
// Can be overridden by annotation on object level.
UpdatePolicy *UpdatePolicy
// How to perform deletion of dependent objects.
// If unspecified, DeletePolicyDelete is assumed.
// Can be overridden by annotation on object level.
DeletePolicy *DeletePolicy
// How to analyze the state of the dependent objects.
// If unspecified, an optimized kstatus based implementation is used.
StatusAnalyzer status.StatusAnalyzer
Expand Down Expand Up @@ -147,6 +151,9 @@ func NewReconciler(name string, clnt cluster.Client, options ReconcilerOptions)
if options.UpdatePolicy == nil {
options.UpdatePolicy = ref(UpdatePolicyReplace)
}
if options.DeletePolicy == nil {
options.DeletePolicy = ref(DeletePolicyDelete)
}
if options.StatusAnalyzer == nil {
options.StatusAnalyzer = status.NewStatusAnalyzer(name)
}
Expand All @@ -160,7 +167,7 @@ func NewReconciler(name string, clnt cluster.Client, options ReconcilerOptions)
adoptionPolicy: *options.AdoptionPolicy,
reconcilePolicy: ReconcilePolicyOnObjectChange,
updatePolicy: *options.UpdatePolicy,
deletePolicy: DeletePolicyDelete,
deletePolicy: *options.DeletePolicy,
labelKeyOwnerId: name + "/" + types.LabelKeySuffixOwnerId,
annotationKeyOwnerId: name + "/" + types.AnnotationKeySuffixOwnerId,
annotationKeyDigest: name + "/" + types.AnnotationKeySuffixDigest,
Expand Down Expand Up @@ -196,15 +203,14 @@ func NewReconciler(name string, clnt cluster.Client, options ReconcilerOptions)
// will re-claim (and therefore potentially drop) fields owned by certain field managers, such as kubectl and helm
// - if the effective update policy is UpdatePolicyRecreate, the object will be deleted and recreated.
//
// Redundant objects will be removed; that means, in the regular case, a http DELETE request will be sent to the Kubernetes API; if the object specifies
// its delete policy as DeletePolicyOrphan, no physcial deletion will be performed, and the object will be left around in the cluster; however it will be no
// longer be part of the inventory.
//
// Objects will be applied and deleted in waves, according to their apply/delete order. Objects which specify a purge order will be deleted from the cluster at the
// end of the wave specified as purge order; other than redundant objects, a purged object will remain as Completed in the inventory;
// and it might be re-applied/re-purged in case it runs out of sync. Within a wave, objects are processed following a certain internal order;
// in particular, instances of types which are part of the wave are processed only if all other objects in that wave have a ready state.
//
// Redundant objects will be removed; that means, a http DELETE request will be sent to the Kubernetes API; note that an effective Orphan deletion
// policy will not prevent deletion here; the deletion policy will only be honored when the component as whole gets deleted.
//
// This method will change the passed inventory (add or remove elements, change elements). If Apply() returns true, then all objects are successfully reconciled;
// otherwise, if it returns false, the caller should recall it timely, until it returns true. In any case, the passed inventory should match the state of the
// inventory after the previous invocation of Apply(); usually, the caller saves the inventory after calling Apply(), and loads it before calling Apply().
Expand Down Expand Up @@ -727,7 +733,12 @@ func (r *Reconciler) Apply(ctx context.Context, inventory *[]*InventoryItem, obj
return false, errors.Wrapf(err, "error reading object %s", item)
}

orphan := item.DeletePolicy == DeletePolicyOrphan
// note: objects becoming obsolete during an apply are no longer honoring deletion policy (orphan)
// TODO: not sure if there is a case where someone would like to orphan such resources while applying;
// if so, then we probably should introduce a third deletion policy, OrphanApply or similar ...
// in any case, the following code should be revisited; cleaned up or adjusted
// orphan := item.DeletePolicy == DeletePolicyOrphan
orphan := false

switch item.Phase {
case PhaseScheduledForDeletion:
Expand Down Expand Up @@ -788,6 +799,7 @@ func (r *Reconciler) Apply(ctx context.Context, inventory *[]*InventoryItem, obj
// objects having a certain delete order will only start if all objects with lower delete order are gone. Within a wave, objects are
// deleted following a certain internal ordering; in particular, if there are instances of types which are part of the wave, then these
// instances will be deleted first; only if all such instances are gone, the remaining objects of the wave will be deleted.
// Objects which have an effective Orphan deletion policy will not be touched (remain in the cluster), but will no longer appear in the inventory.
//
// This method will change the passed inventory (remove elements, change elements). If Delete() returns true, then all objects are gone; otherwise,
// if it returns false, the caller should recall it timely, until it returns true. In any case, the passed inventory should match the state of the
Expand Down Expand Up @@ -873,8 +885,12 @@ func (r *Reconciler) Delete(ctx context.Context, inventory *[]*InventoryItem) (b

// Check if the object set defined by inventory is ready for deletion; that means: check if the inventory contains
// types (as custom resource definition or from an api service), while there exist instances of these types in the cluster,
// which are not contained in the inventory.
// which are not contained in the inventory. There is one exception of this rule: if all objects in the inventory have their
// delete policy set to DeletePolicyOrphan, then the deletion of the component is immediately allowed.
func (r *Reconciler) IsDeletionAllowed(ctx context.Context, inventory *[]*InventoryItem) (bool, string, error) {
if slices.All(*inventory, func(item *InventoryItem) bool { return item.DeletePolicy == DeletePolicyOrphan }) {
return true, "", nil
}
for _, item := range *inventory {
switch {
case isCrd(item):
Expand Down
2 changes: 1 addition & 1 deletion website/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ enableMissingTranslationPlaceholders = true
enableRobotsTXT = true

# Will give values to .Lastmod etc.
enableGitInfo = true
enableGitInfo = false

# Comment out to enable taxonomies in Docsy
# disableKinds = ["taxonomy", "taxonomyTerm"]
Expand Down
16 changes: 3 additions & 13 deletions website/content/en/docs/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,13 @@ weight: 40
type: "docs"
---

This repository provides a framework supporting the development of opinionated Kubernetes operators
This repository provides a framework supporting the development of Kubernetes operators
managing the lifecycle of arbitrary deployment components of Kubernetes clusters, with a special focus
on such components that are or contain Kubernetes operators themselves.

It can therefore serve as a starting point to develop [SAP Kyma module operators](https://github.com/kyma-project/template-operator),
but can also be used independently of Kyma.

Regarding its mission statement, this project can be compared with the [Operator Lifecycle Manager (OLM)](https://olm.operatorframework.io/).
However, other than OLM, which follows a generic modelling approach, component-operator-runtime encourages the development of opinionated,
concretely modeled, component-specific operators. This makes the resulting logic much more explicit, and also allows to react better on
specific lifecycle needs of the managed component.

Of course, components might equally be managed by using generic Kustomization or Helm chart deployers (such as provided by [ArgoCD](https://argoproj.github.io/) or [FluxCD](https://fluxcd.io/flux/)).
However, these tools have certain weaknesses when it is about to deploy other operators, i.e. components which extend the Kubernetes API,
e.g. by adding custom resource definitions, aggregated API servers, according controllers, or admission webhooks.
For example these generic solutions tend to produce race conditions or dead locks upon first installation or deletion of the managed components.
This is where component-operator-runtime tries to act in a smarter and more robust way.
but can also be used independently of Kyma. While being perfectly suited to develop opiniated operators like Kyma module operators, it can be
equally used to cover more generic use cases. A prominent example for such a generic operator is the [SAP component operator](https://github.com/sap/component-operator) which can be compared to flux's [kustomize controller](https://github.com/fluxcd/kustomize-controller) and [helm controller](https://github.com/fluxcd/helm-controller).

If you want to report bugs, or request new features or enhancements, please [open an issue](https://github.com/sap/component-operator-runtime/issues)
or [raise a pull request](https://github.com/sap/component-operator-runtime/pulls).
Loading

0 comments on commit 88c5286

Please sign in to comment.