component-operator-runtime
This repository provides a framework supporting the development of opinionated 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,
but can also be used independently of Kyma.
Regarding its mission statement, this project can be compared with the Operator Lifecycle Manager (OLM).
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 or FluxCD).
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.
If you want to report bugs, or request new features or enhancements, please open an issue
or raise a pull request.
1 - Getting Started
How to get started
In this short tutorial you will learn how to scaffold a Kubernetes component operator using component-operator-runtime,
and how to start with the implementation of the operator.
First of all, you have to download the component-operator-runtime scaffolding tool from the releases page. In the following we assume that the downloaded scaffold
executable
is somehwere in your path (for example, as /usr/local/bin/scaffold-component-operator).
Then, a git repository for the operator code is needed; in this example, we call it github.com/myorg/mycomponent-operator.
We assume that you have cloned the empty repository to your local desktop, and have changed the current directory
to the checked out repository.
We assume here that you are implementing a Kyma module operator, and that
the managed component shall be represented by a Kubernetes type called MyComponent
. Then run:
scaffold-component-operator \
--group-name operator.kyma-project.io \
--group-version v1alpha1 \
--kind MyComponent \
--operator-name mycomponent-operator.kyma-project.io \
--go-module github.com/myorg/mycomponent-operator \
--image mycomponent-operator:latest \
.
This will give you a syntactically correct Go module. In order to start the operator, you first have to apply the
custom resource definition into your development (e.g. kind) cluster:
kubectl apply -f crds/operator.kyma-project.io_mycomponents.yaml
Then, after copying or linking the cluster’s kubeconfig to ./tmp/kubeconfig
(no worries, it will not submitted to git because ./tmp
is excluded by .gitignore
), you can use the generated ./vscode/launch.json
to start the
operator against your cluster with your Visual Studio Code. Now you are ready to instantiate your component:
kubectl apply -f - <<END
apiVersion: operator.kyma-project.io/v1alpha1
kind: MyComponent
metadata:
namespace: default
name: test
END
Now, after having the skeleton generated, we have to breathe life into the controller.
The first step is to enhance the spec of the generated custom resource type MyComponentSpec
in api/v1alpha1/types.go
.
In principle, all the attributes parameterizing the deployment of the managed commponent should be modeled there.
Whenever you change the runtime type, you should invoke make generate
and make manifests
in order to
update the generated code artifacts and the custom resource definition; afterwards you should re-apply the
custom resource definition to the cluster.
The next step is to implement a meaningful resource generator (the scaffolding just puts a dummy implementation called DummyGenerator
into main.go
). Writing such a resource generator means to implement the interface
type Generator interface {
Generate(ctx context.Context, namespace string, name string, parameters types.Unstructurable) ([]client.Object, error)
}
When called by the framework, the namespace
and name
arguments of the Generate()
method will be assigned the component’s namespace and name or, if the component or its spec implements the PlacementConfiguration
interface, they will match the return values
of the respective GetDeploymentNamespace()
, GetDeploymentName()
methods. The parameter
argument will be set to the return value of the component’s GetSpec()
method.
In simplistic words, the spec of the component resource will be fed into the resource generator, which will return the
concrete manifests of the dependent objects, which will then be applied to the cluster.
In some cases, the best option is to implement your own resource generator from scratch. When doing so, the returned resources []client.Object
either have to be of type *unstructured.Unstructured
, or the according type must be known to the used scheme.
In many other cases however, it makes more sense to just reuse one of the generic generators shipped with this
repository.
2 - Concepts
Concepts
The framework provided in this repository aims to automate the lifecycle of an arbitrary component in a Kubernetes cluster.
Usually (but not necessarily) the managed component contains one or multiple other operators, including extension types, such as custom resource definitions.
Other than existing tools addressing this case, such as the Operator Lifecycle Manager (OLM),
this project proposes a more opinionated programming model. That is, the idea is to represent the managed component by an own custom resource type,
which (usually) will be instantiated only once in the cluster. We feel encouraged to go this way, as many community projects are following the pattern of providing dedicated lifecycle operators.
The component-operator-runtime framework plugs into the controller-runtime SDK by implementing controller-runtime’s Reconciler
interface.
2.1 - Components and Generators
Interfaces to be implemented by component operators
In the terminology of this project, a Kubernetes cluster component (sometimes called module) consists of a set of dependent objects that are to be
deployed consistently into the Kubernetes cluster. The continuous reconciliation of the declared state of these dependent objects is the task of an operator
implemented by component-operator-runtime. To achieve this, basically two interfaces have to be implemented by such an operator…
The Component Interface
In the programming model proposed by component-operator-runtime, the declared and observed state of the component is represented by a dedicated custom resource type. The corresponding runtime type has to fulfill the following interface:
package component
// Component is the central interface that component operators have to implement.
// Besides being a conroller-runtime client.Object, the implementing type has to expose accessor
// methods for the components's spec and status, GetSpec() and GetStatus().
type Component interface {
client.Object
// Return a read-only accessor to the component's spec.
// The returned value has to implement the types.Unstructurable interface.
GetSpec() types.Unstructurable
// Return a read-write (usually a pointer) accessor to the component's status,
// resp. to the corresponding substruct if the status extends component.Status.
GetStatus() *Status
}
Basically, two accessor methods have to be implemented here. First, GetSpec()
exposes the parameterization of the component.
The only requirement on the returned type is to implement the
package types
// Unstructurable represents objects which can be converted into a string-keyed map.
// All Kubernetes API types, as well as all JSON objects could be modelled as Unstructurable objects.
type Unstructurable interface {
ToUnstructured() map[string]any
}
interface. In most cases, the returned Unstructurable
object is the spec itself, or a deep copy of the spec. In general, the implementation is allowed to return arbitrary content, as long as the receiving generator is able to process it. In particular, it is not expected by the framework that changes applied to the returned Unstructurable
reflect in any way in the component; indeed, the framework will never modify the returned Unstructurable
.
Finally, GetStatus()
allows the framework to access (a part of) the custom resource type’s status, having the following type:
package component
// Component Status. Components must include this into their status.
type Status struct {
ObservedGeneration int64 `json:"observedGeneration"`
AppliedGeneration int64 `json:"appliedGeneration,omitempty"`
LastObservedAt *metav1.Time `json:"lastObservedAt,omitempty"`
LastAppliedAt *metav1.Time `json:"lastAppliedAt,omitempty"`
Conditions []Condition `json:"conditions,omitempty"`
State State `json:"state,omitempty"`
Inventory []*InventoryItem `json:"inventory,omitempty"`
}
Note that, other than with the GetSpec()
accessor, the framework will make changes to the returned Status
structure.
Thus, in almost all cases, the returned pointer should just reference the status of the component’s API type (or an according substructure of that status).
The component’s custom resource type is supposed to be namespaced, and by default, dependent objects will be created in that same namespace. To be more precise, the namespace
and name
parameters of the used generator’s Generate()
method will be set to the component’s metadata.namespace
and metadata.name
, respectively. Sometimes it might be desired to override these default, and to render the dependent objects with a different namespace or name. To allow this, the component (or its spec) can implement
package component
// The PlacementConfiguration interface is meant to be implemented by components (or their spec) which allow
// to explicitly specify target namespace and name of the deployment (otherwise this will be defaulted as
// the namespace and name of the component object itself).
type PlacementConfiguration interface {
// Return target namespace for the component deployment.
// If the returned value is not the empty string, then this is the value that will be passed
// to Generator.Generate() as namespace and, in addition, rendered namespaced resources with
// unspecified namespace will be placed in this namespace.
GetDeploymentNamespace() string
// Return target name for the component deployment.
// If the returned value is not the empty string, then this is the value that will be passed
// to Generator.Generator() as name.
GetDeploymentName() string
}
In addition, the component (or its spec) may implement
package component
// The ClientConfiguration interface is meant to be implemented by components (or their spec) which offer
// remote deployments.
type ClientConfiguration interface {
// Get kubeconfig content. Should return nil if default local client shall be used.
GetKubeConfig() []byte
}
in order to support remote deployments (that is, to make the deployment of the dependent objects use the specified kubeconfig), and
package component
// The ImpersonationConfiguration interface is meant to be implemented by components (or their spec) which offer
// impersonated deployments.
type ImpersonationConfiguration interface {
// Return impersonation user. Should return system:serviceaccount:<namespace>:<serviceaccount>
// if a service account is used for impersonation. Should return an empty string
// if user shall not be impersonated.
GetImpersonationUser() string
// Return impersonation groups. Should return nil if groups shall not be impersonated.
GetImpersonationGroups() []string
}
to use different user/groups for the deployment of dependent objects. Implementing both ClientConfiguration
and ImpersonationConfiguration
means that
the provided kubeconfig will be impersonated as specified.
Note that, as mentioned above, the interfaces PlacementConfiguration
, ClientConfiguration
and ImpersonationConfiguration
can be implemented by the component
itself as well as by its spec type. In the theoretical case that both is the case, the implementation on the component level takes higher precedence.
The Generator interface
While Component
(respectively the related custom resource type) models the desired and actual state of
the managed component, the Generator
interface is about implementing a recipe to render the Kubernetes manifests of the
dependent objects, according to the provided parameterization (spec) of the component:
package manifests
// Resource generator interface.
// When called from the reconciler, the arguments namespace and name will match the
// component's namespace and name or, if the component or its spec implement the
// PlacementConfiguration interface, the return values of the GetDeploymentNamespace(), GetDeploymentName()
// methods (if non-empty). The parameters argument will be assigned the return value
// of the component's GetSpec() method.
type Generator interface {
Generate(ctx context.Context, namespace string, name string, parameters types.Unstructurable) ([]client.Object, error)
}
In addition to namespace
, name
, parameters
, generators can retrieve additional contextual information, such as a
client for the deployment target by calling utils.ClientFromContext()
, and related functions.
Component controllers can of course implement their own generator. In many cases (for example if there exists a
Helm chart or kustomization for the component), one of the generators bundled with this repository can be used.
Generators may optionally implement
package types
// SchemeBuilder interface.
type SchemeBuilder interface {
AddToScheme(scheme *runtime.Scheme) error
}
in order to enhance the scheme used by the dependent objects deployer.
2.2 - Component Reconciler
Reconciliation logic for dependent objects
Dependent objects are - by definition - the resources returned by the Generate()
method of the used resource generator.
Whenever a component resource (that is, an instance of the component’s custom resource type) is created, udpated, or deleted,
the set of dependent object potentially changes, and the cluster state has to be synchronized with that new declared state.
This synchronization is the job of the reconciler provided by this framework.
Creating the reconciler instance
Typically, a component operator runs one reconciler which is instantiated by calling the following constructor:
package component
func NewReconciler[T Component](
name string,
resourceGenerator manifests.Generator
options ReconcilerOptions
) *Reconciler[T]
The passed type parameter T Component
is the concrete runtime type of the component’s custom resource type. Furthermore,
name
is supposed to be a unique name (typically a DNS name) identifying this component operator in the cluster; ìt will be used in annotations, labels, for leader election, …
resourceGenerator
is an implementation of the Generator
interface, describing how the dependent objects are rendered from the component’s spec.
options
can be used to tune the behavior of the reconciler:
package component
// ReconcilerOptions are creation options for a Reconciler.
type ReconcilerOptions struct {
// Whether namespaces are auto-created if missing.
// If unspecified, true is assumed.
CreateMissingNamespaces *bool
// How to react if a dependent object exists but has no or a different owner.
// If unspecified, AdoptionPolicyIfUnowned is assumed.
// Can be overridden by annotation on object level.
AdoptionPolicy *AdoptionPolicy
// How to perform updates to dependent objects.
// If unspecified, UpdatePolicyReplace is assumed.
// Can be overridden by annotation on object level.
UpdatePolicy *UpdatePolicy
// Schemebuilder allows to define additional schemes to be made available in the
// target client.
SchemeBuilder types.SchemeBuilder
}
The object returned by NewReconciler
implements controller-runtime’s Reconciler
interface, and can therefore be used as a drop-in
in kubebuilder managed projects. After creation, the reconciler has to be registered with the responsible controller-runtime manager instance by calling
package component
func (r *Reconciler[T]) SetupWithManager(mgr ctrl.Manager) error
The used manager mgr
has to fulfill a few requirements:
- its client must bypass informer caches for the following types:
- the type
T
itself - the type
CustomResourceDefinition
from the apiextensions.k8s.io/v1
group - the type
APIService
from the apiregistration.k8s.io/v1
group
- its scheme must recognize at least the following types:
- the types in the API group defined in this repository
- the core group (
v1
) - group
apiextensions.k8s.io/v1
- group
apiregistration.k8s.io/v1
.
Reconciler hooks
Component operators may register hooks to enhance the reconciler logic at certain points, by passing functions of type
package component
// HookFunc is the function signature that can be used to
// establish callbacks at certain points in the reconciliation logic.
// Hooks will be passed the current (potentially unsaved) state of the component.
// Post-hooks will only be called if the according operation (read, reconcile, delete)
// has been successful.
type HookFunc[T Component] func(ctx context.Context, client client.Client, component T) error
to the desired registration functions:
package component
// Register post-read hook with reconciler.
// This hook will be called after the reconciled component object has been retrieved from the Kubernetes API.
func (r *Reconciler[T]) WithPostReadHook(hook HookFunc[T]) *Reconciler[T]
// Register pre-reconcile hook with reconciler.
// This hook will be called if the reconciled component is not in deletion (has no deletionTimestamp set),
// right before the reconcilation of the dependent objects starts.
func (r *Reconciler[T]) WithPreReconcileHook(hook HookFunc[T]) *Reconciler[T]
// Register post-reconcile hook with reconciler.
// This hook will be called if the reconciled component is not in deletion (has no deletionTimestamp set),
// right after the reconcilation of the dependent objects happened, and was successful.
func (r *Reconciler[T]) WithPostReconcileHook(hook HookFunc[T]) *Reconciler[T]
// Register pre-delete hook with reconciler.
// This hook will be called if the reconciled component is in deletion (has a deletionTimestamp set),
// right before the deletion of the dependent objects starts.
func (r *Reconciler[T]) WithPreDeleteHook(hook HookFunc[T]) *Reconciler[T]
// Register post-delete hook with reconciler.
// This hook will be called if the reconciled component is in deletion (has a deletionTimestamp set),
// right after the deletion of the dependent objects happened, and was successful.
func (r *Reconciler[T]) WithPostDeleteHook(hook HookFunc[T]) *Reconciler[T]
Note that the client passed to the hook functions is the client of the manager that was used when calling SetupWithManager()
(that is, the return value of that manager’s GetClient()
method). In addition, reconcile and delete hooks (that is all except the
post-read hook) can retrieve a client for the deployment target by calling utils.ClientFromContext()
.
Tuning the retry behavior
By default, errors returned by the component’s generator or by a registered hook will make the reconciler go
into a backoff managed by controller-runtime (which usually is an exponential backoff, capped at 10 minutes).
However, if the error is or unwraps to a types.RetriableError
, then the retry delay specified at the error
will be used instead of the backoff. Implementations should use
pacakge types
func NewRetriableError(err error, retryAfter *time.Duration) RetriableError {
return RetriableError{err: err, retryAfter: retryAfter}
}
to wrap an error into a RetriableError
. It is allowed to pass retryAfter
as nil; in that case the retry delay
will be determined by calling the component’s GetRetryInterval()
method (if the component or its spec implements
the
package component
// The RetryConfiguration interface is meant to be implemented by components (or their spec) which offer
// tweaking the retry interval (by default, it would be the value of the requeue interval).
type RetryConfiguration interface {
// Get retry interval. Should be greater than 1 minute.
GetRetryInterval() time.Duration
}
interface), or otherwise will be set to the effective requeue interval (see below).
Tuning the requeue behavior
If a component was successfully reconciled, another reconciliation will be scheduled after 10 minutes, by default.
This default requeue interval may be overridden by the component by implementing the
package component
// The RequeueConfiguration interface is meant to be implemented by components (or their spec) which offer
// tweaking the requeue interval (by default, it would be 10 minutes).
type RequeueConfiguration interface {
// Get requeue interval. Should be greater than 1 minute.
GetRequeueInterval() time.Duration
}
interface.
Tuning the timeout behavior
If the dependent objects of a component do not reach a ready state after a certain time, the component state will switch from Processing
to Error
.
This timeout restarts counting whenever something changed in the component, or in the manifests of the dependent objects, and by default has the value
of the effective requeue interval, which in turn defaults to 10 minutes.
The timeout may be overridden by the component by implementing the
package component
// The TimeoutConfiguration interface is meant to be implemented by components (or their spec) which offer
// tweaking the processing timeout (by default, it would be the value of the requeue interval).
type TimeoutConfiguration interface {
// Get timeout. Should be greater than 1 minute.
GetTimeout() time.Duration
}
interface.
2.3 - Dependent Objects
Lifecycle of dependent objects
Dependent objects are - by definition - the resources returned by the Generate()
method of the used resource generator.
Under normal circumstances, the framework manages the lifecycle of the dependent objects in a sufficient way.
For example, it will create and delete objects in a meaningful order, trying to avoid corresponding transient or permanent errors.
A more remarkable feature of component-operator-runtime is that it will block deletion of dependent objects
as long as non-managed instances of managed extension types (such as custom resource definitions) exist.
To be more precise, assume for example, that the managed component contains some custom resource definition, plus the according operator.
Then, if the component resource gets deleted, none of the component’s dependent objects will be touched as long as there exist foreign
instances of the managed custom resource definition in the cluster.
In some special situations, it is desirable to have even more control on the lifecycle of the dependent objects.
To support such cases, the Generator
implementation can set the following annotations in the manifests of the dependents:
mycomponent-operator.mydomain.io/adoption-policy
: defines how the reconciler reacts if the object exists but has no or a different owner; can be one of:never
: fail if the object exists and has no or a different ownerif-unowned
(which is the default): adopt the object if it has no owner setalways
: adopt the object, even if it has a conflicting owner
mycomponent-operator.mydomain.io/reconcile-policy
: defines how the object is reconciled; can be one of:on-object-change
(which is the default): the object will be reconciled whenever its generated manifest changeson-object-or-component-change
: the object will be reconciled whenever its generated manifest changes, or whenever the responsible component object changes by generationonce
: the object will be reconciled once, but never be touched again
mycomponent-operator.mydomain.io/update-policy
: defines how the object (if existing) is updated; can be one of:default
(deprecated): equivalent to the annotation being unset (which means that the reconciler default will be used)replace
(which is the default): a regular update (i.e. PUT) call will be made to the Kubernetes API serverssa-merge
: use server side apply to update existing dependentsssa-override
: use server side apply to update existing dependents and, in addition, reclaim fields owned by certain field owners, such as kubectl or Helmrecreate
: if the object would be updated, it will be deleted and recreated instead
mycomponent-operator.mydomain.io/delete-policy
: defines what happens if the object is deleted; can be one of:default
(deprecated): equivalent to the annotation being unset (which means that the reconciler default will be used)delete
(which is the default): a delete call will be sent to the Kubernetes API serverorphan
: the object will not be deleted, and it will be no longer tracked
mycomponent-operator.mydomain.io/apply-order
: the wave in which this object will be reconciled; dependents will be reconciled wave by wave; that is, objects of the same wave will be deployed in a canonical order, and the reconciler will only proceed to the next wave if all objects of previous waves are ready; specified orders can be negative or positive numbers between -32768 and 32767, objects with no explicit order set are treated as if they would specify order 0mycomponent-operator.mydomain.io/purge-order
(optional): the wave by which this object will be purged; here, purged means that, while applying the dependents, the object will be deleted from the cluster at the end of the specified wave; the according record in status.Inventory
will be set to phase Completed
; setting purge orders is useful to spawn ad-hoc objects during the reconcilation, which are not permanently needed; so it’s comparable to Helm hooks, in a certain sensemycomponent-operator.mydomain.io/delete-order
(optional): the wave by which this object will be deleted; that is, if the dependent is no longer part of the component, or if the whole component is being deleted; dependents will be deleted wave by wave; that is, objects of the same wave will be deleted in a canonical order, and the reconciler will only proceed to the next wave if all objects of previous saves are gone; specified orders can be negative or positive numbers between -32768 and 32767, objects with no explicit order set are treated as if they would specify order 0; note that the delete order is completely independent of the apply ordermycomponent-operator.mydomain.io/status-hint
(optional): a comma-separated list of hints that may help the framework to properly identify the state of the annotated dependent object; currently, the following hints are possible:has-observed-generation
: tells the framework that the dependent object has a status.observedGeneration
field, even if it is not (yet) set by the responsible controller (some controllers are known to set the observed generation lazily, with the consequence that there is a period right after creation of the dependent object, where the field is missing in the dependent’s status)has-ready-condition
: tells the framework to count with a ready condition; if it is absent, the condition status will be considered as Unknown
conditions
: semicolon-separated list of additional conditions that must be present and have a True
status in order to make the overall status ready
Note that, in the above paragraph, mycomponent-operator.mydomain.io
has to be replaced with whatever was passed as name
when calling NewReconciler()
.
3 - Generators
Included resource generators
This section provides documentation about the generic resource generators shipped with component-operator-runtime
3.1 - Kustomize Generator
A resource generator for kustomizations
This generator allows to generate the manifests of the component’s resources from a given kustomization.
As a special case, one or more simple Kubernetes manifests (without a kustomization.yaml
) are supported as well.
In addition, all (or selected; see below) files in the kustomization directory can be templatized in a helm’ish way.
That means, they will be considered as a common template group (where all templates are associated with each other),
and the same template function set that is available on Helm can be used; so, all the sprig functions, and custom functions such as include
, tpl
, lookup
can be used. In addition:
- parameterless functions
namespace
and name
are defined, which return the corresponding arguments passed to Generate()
- a function
kubernetesVersion
is available, which returns the version information of the target cluster, as a version.Info structure.
In the generation step, first, all the go templates will be rendered, and the result of this pre-step will be passed to kustomize.
A kustomize generator can be instantiated by calling the following constructor:
package kustomize
func NewKustomizeGenerator(
fsys fs.FS,
kustomizationPath string,
templateSuffix string,
client client.Client
) (*KustomizeGenerator, error) {
Here:
fsys
must be an implementation of fs.FS
, such as embed.FS
; or it can be passed as nil; then, all file operations will be executed on the current OS filesystem.kustomizationPath
is the directory containing the (potentially templatized) kustomatization; if fsys
was provided, this has to be a relative path; otherwise, it will be interpreted with respect to the OS filesystem (as an absolute path, or relative to the current working directory of the controller).templateSuffx
is optional; if empty, all files under kustomizationPath
will be subject to go templating; otherwise, only files matching the specified suffix will be considered as templates.client
should be a client for the local cluster (i.e. the cluster where the component object exists).
As of now, the specified kustomization must not reference files or paths outside kustomizationPath
. Remote references are generally not supported.
3.2 - Helm Generator
A resource generator for Helm charts
Sometimes it is desired to write a component operator (using component-operator-runtime) for some cluster component, which already has a productive Helm chart. Then it can make sense to use the HelmGenerator
implementation of the Generator
interface included in this module:
package helm
func NewHelmGenerator(
fsys fs.FS,
chartPath string,
client client.Client,
) (*HelmGenerator, error)
Here:
fsys
must be an implementation of fs.FS
, such as embed.FS
; or it can be passed as nil; then, all file operations will be executed on the current OS filesystem.chartPath
is the directory containing the used Helm chart; if fsys
was provided, this has to be a relative path; otherwise, it will be interpreted with respect to the OS filesystem (as an absolute path, or relative to the current working directory of the controller).client
should be a client for the local cluster (i.e. the cluster where the component object exists).
It should be noted that HelmGenerator
does not use the Helm SDK; instead it tries to emulate the Helm behavior as good as possible.
A few differences and restrictions arise from this:
- Not all Helm template functions are supported. To be exact,
toToml
, fromYamlArray
, fromJsonArray
are not supported;
the functions toYaml
, fromYaml
, toJson
, fromJson
are supported, but will behave more strictly in error situtations. - Not all builtin variables are supported; the following restrictions apply:
- for the
.Release
builtin, only .Release.Namespace
, .Release.Name
, .Release.Service
, .Release.IsInstall
, .Release.IsUpgrade
are supported; note that - since this framework does not really distinguish between installations and upgrades - Release.IsInstall
is always set to true
, and Release.IsUpgrade
is always set to false
- for the
.Chart
builtin, only .Chart.Name
, .Chart.Version
, .Chart.Type
, .Chart.AppVersion
, .Chart.Dependencies
are supported - for the
.Capabilities
builtin, only .Capabilities.KubeVersion
and .Capabilities.APIVersions
are supported - the
.Template
builtin is fully supported - the
.Files
builtin is not supported at all.
- Regarding hooks,
pre-delete
and post-delete
hooks are not allowed; test and rollback hooks are ignored, and pre-install
, post-install
, pre-upgrade
, post-upgrade
hooks might be handled in a sligthly different way; hook weights will be handled in a compatible way; hook deletion policy hook-failed
is not allowed, but before-hook-creation
and hook-succeeded
should work as expected.
3.3 - Enhance Existing Generators
How to derive generators from existing generators
In some cases it is desirable to modify the behaviour of an existing generator by transforming the
input parameters passed to the generation step, or by transforming the object manifests returned by the
generation step. This can be achieved by wrapping an existing generator into a
package manifests
type TransformableGenerator interface {
Generator
WithParameterTransformer(transformer ParameterTransformer) TransformableGenerator
WithObjectTransformer(transformer ObjectTransformer) TransformableGenerator
}
object tby calling
package manifests
func NewGenerator(generator Generator) TransformableGenerator
The generator obtained this way can now be extended by calling its methods WithParameterTransformer()
and WithObjectTransformer()
. The actual modification logic happens by implementing the respective interfaces
package manifests
type ParameterTransformer interface {
TransformParameters(parameters types.Unstructurable) (types.Unstructurable, error)
}
type ObjectTransformer interface {
TransformObjects(objects []client.Object) ([]client.Object, error)
}
4 - Usage
How to use the component-operator-runtime framework
4.1 - Overview
General implementation principles (doing it the hard way)
This framework is based on the controller-runtime project.
Therefore one way of consuming it would be to bootstrap a kubebuilder
project, such as
kubebuilder init \
--domain kyma-project.io \
--repo github.com/myorg/mycomponent-operator \
--project-name=mycomponent-operator
kubebuilder create api \
--group operator \
--version v1alpha1 \
--kind MyComponent \
--resource \
--controller \
--make
First you will enhance the custom resource type, as generated by kubebuilder in the api/v1alpha1
folder:
// MyComponentSpec defines the desired state of MyComponent
type MyComponentSpec struct {
// Add fields here ...
}
// MyComponentStatus defines the observed state of MyComponent
type MyComponentStatus struct {
component.Status `json:",inline"`
}
In many cases, it makes sense to embed one or more of the following structs into the spec:
component.PlacementSpec
if instances should be allowed to specify target namespace and name different from the
component’s namespace and namecomponent.ClientSpec
if (remote) deployments via a specified kubeconfig shall be possiblecomponent.ImpersonationSpec
if you want to support impersonation of the deployment (e.g. via a service account).
Most likely you will add own attributes to the spec, allowing to parameterize the deployment of your component.
Including component.Status
into the status is mandatory, but you are free to add further fields if needed.
In order to make the custom resource type implement the Component
interface, add the following methods:
var _ component.Component = &MyComponent{}
func (s *MyComponentSpec) ToUnstructured() map[string]any {
result, err := runtime.DefaultUnstructuredConverter.ToUnstructured(s)
if err != nil {
panic(err)
}
return result
}
func (c *MyComponent) GetSpec() runtimetypes.Unstructurable {
return &c.Spec
}
func (c *MyComponent) GetStatus() *component.Status {
return &c.Status.Status
}
Now we are settled to replace the controller generated by kubebuilder with the component-operator-runtime reconciler in the scaffolded main.go
:
// Replace this by a real resource generator (e.g. HelmGenerator or KustomizeGenerator, or your own one).
resourceGenerator, err := manifests.NewDummyGenerator()
if err != nil {
setupLog.Error(err, "error initializing resource generator")
os.Exit(1)
}
if err := component.NewReconciler[*operatorv1alpha1.MyComponent](
"mycomponent-operator.kyma-project.io",
nil,
nil,
nil,
nil,
resourceGenerator,
).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "MyComponent")
os.Exit(1)
}
In addition, you have to add the apiextensions.k8s.io/v1
and apiregistration.k8s.io/v1
groups to the used scheme, such
that the kubebuilder generated init()
function looks like this:
func init() {
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
utilruntime.Must(apiextensionsv1.AddToScheme(scheme))
utilruntime.Must(apiregistrationv1.AddToScheme(scheme))
utilruntime.Must(operatorv1alpha1.AddToScheme(scheme))
}
Furthermore, pay attention to bypass informer caching in the client at least for the following types:
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
// ...
Client: client.Options{
Cache: &client.CacheOptions{
DisableFor: []client.Object{
&operatorv1alpha1.MyComponent{},
&apiextensionsv1.CustomResourceDefinition{},
&apiregistrationv1.APIService{},
},
},
},
// ...
})
Now the actual work starts, which means that you will tailor the custom resource type’s spec according to
the needs of the managed component, and implement a meaningful resource generator, replacing manifests.NewDummyGenerator()
.
4.2 - Scaffolder
Generate a component operator by the comonent-operator-runtime scaffolder
The recommended way to get started with the implementation of a new component operator is to use the included
scaffolding tool, which can be downloaded from the releases page.
After installing the scaffolder, a new project can be created like this:
scaffold \
--group-name operator.kyma-project.io \
--kind MyComponent \
--operator-name mycomponent-operator.kyma-project.io \
--go-module github.com/myorg/mycomponent-operator \
--image mycomponent-operator:latest \
<output-directory>
In this example, some options were left out, using the according default values; the full option list is as follows:
Usage: scaffold [options] [output directory]
[output directory]: Target directory for the generated scaffold; must exist
[options]:
--version Show version
--owner string Owner of this project, as written to the license header (default "SAP SE")
--spdx-license-headers Whether to write license headers in SPDX format
--group-name string API group name
--group-version string API group version (default "v1alpha1")
--kind string API kind for the component
--resource string API resource (plural) for the component; if empty, it will be the pluralized kind
--operator-name string Unique name for this operator, used e.g. for leader election and labels; should be a valid DNS hostname
--with-validating-webhook Whether to scaffold validating webhook
--with-mutating-webhook Whether to scaffold mutating webhook
--go-version string Go version to be used (default "1.21")
--go-module string Name of the Go module, as written to the go.mod file
--kubernetes-version string Kubernetes go-client version to be used (default "v0.28.1")
--controller-runtime-version string Controller-runtime version to be used (default "v0.16.0")
--controller-tools-version string Controller-tools version to be used (default "v0.13.0")
--code-generator-version string Code-generator version to be used (default "v0.28.1")
--admission-webhook-runtime-version string Admission-webhook-runtime version to be used (default "v0.1.0")
--envtest-kubernetes-version string Kubernetes version to be used by envtest (default "1.27.1")
--image string Name of the Docker/OCI image produced by this project (default "controller:latest")
--skip-post-processing Skip post-processing
After generating the scaffold, the next steps are:
- Enhance the spec type of the generated custom resource type (in
api/<group-version>/types.go
) according to the needs of
your component - Implement a meaningful resource generator and use it in
main.go
instead of manifests.NewDummyGenerator()
;
to do so you can either implement your own generator, or reuse one of the generic generators shipped with this
repository.