Skip to content
Merged
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
45 changes: 43 additions & 2 deletions api/v1/clusterextensionrevision_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,28 @@ const (
// ClusterExtensionRevisionSpec defines the desired state of ClusterExtensionRevision.
type ClusterExtensionRevisionSpec struct {
// Specifies the lifecycle state of the ClusterExtensionRevision.
//
// +kubebuilder:default="Active"
// +kubebuilder:validation:Enum=Active;Paused;Archived
// +kubebuilder:validation:XValidation:rule="oldSelf == 'Active' || oldSelf == 'Paused' || oldSelf == 'Archived' && oldSelf == self", message="can not un-archive"
LifecycleState ClusterExtensionRevisionLifecycleState `json:"lifecycleState,omitempty"`
// Revision number orders changes over time, must always be previous revision +1.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't get what we're trying to say with "Revision number orders changes over time". Maybe just "Revision number changes over time"? Maybe "Revision provides historical ordering of revisions, must always be previous +1"?

//
// +kubebuilder:validation:Required
// +kubebuilder:validation:XValidation:rule="self == oldSelf", message="revision is immutable"
Revision int64 `json:"revision"`
// Phases are groups of objects that will be applied at the same time.
// All objects in the a phase will have to pass their probes in order to progress to the next phase.
//
// +kubebuilder:validation:Required
// +kubebuilder:validation:XValidation:rule="self == oldSelf || oldSelf.size() == 0", message="phases is immutable"
// +patchMergeKey=name
// +patchStrategy=merge
// +listType=map
// +listMapKey=name
Phases []ClusterExtensionRevisionPhase `json:"phases"`
// Previous references previous revisions that objects can be adopted from.
//
// +kubebuilder:validation:XValidation:rule="self == oldSelf", message="previous is immutable"
Previous []ClusterExtensionRevisionPrevious `json:"previous,omitempty"`
}
Expand All @@ -73,18 +85,47 @@ const (
ClusterExtensionRevisionLifecycleStateArchived ClusterExtensionRevisionLifecycleState = "Archived"
)

// ClusterExtensionRevisionPhase are groups of objects that will be applied at the same time.
// All objects in the a phase will have to pass their probes in order to progress to the next phase.
type ClusterExtensionRevisionPhase struct {
Name string `json:"name"`
// Name identifies this phase.
//
// +kubebuilder:validation:MaxLength=63
// +kubebuilder:validation:Pattern=`^[a-z]([-a-z0-9]*[a-z0-9])?$`
Name string `json:"name"`
// Objects are a list of all the objects within this phase.
Objects []ClusterExtensionRevisionObject `json:"objects"`
Slices []string `json:"slices,omitempty"`
}

// ClusterExtensionRevisionObject contains an object and settings for it.
type ClusterExtensionRevisionObject struct {
// +kubebuilder:validation:EmbeddedResource
// +kubebuilder:pruning:PreserveUnknownFields
Object unstructured.Unstructured `json:"object"`
// CollisionProtection controls whether OLM can adopt and modify objects
// already existing on the cluster or even owned by another controller.
//
// +kubebuilder:default="Prevent"
CollisionProtection CollisionProtection `json:"collisionProtection,omitempty"`
}

// CollisionProtection specifies if and how ownership collisions are prevented.
type CollisionProtection string

const (
// CollisionProtectionPrevent prevents owner collisions entirely
// by only allowing to work with objects itself has created.
CollisionProtectionPrevent CollisionProtection = "Prevent"
// CollisionProtectionIfNoController allows to patch and override
// objects already present if they are not owned by another controller.
CollisionProtectionIfNoController CollisionProtection = "IfNoController"
// CollisionProtectionNone allows to patch and override objects
// already present and owned by other controllers.
// Be careful! This setting may cause multiple controllers to fight over a resource,
// causing load on the API server and etcd.
CollisionProtectionNone CollisionProtection = "None"
)

type ClusterExtensionRevisionPrevious struct {
// +kubebuilder:validation:Required
Name string `json:"name"`
Expand Down
5 changes: 0 additions & 5 deletions api/v1/zz_generated.deepcopy.go

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

46 changes: 37 additions & 9 deletions cmd/operator-controller/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -489,22 +489,50 @@ func getCertificateProvider() render.CertificateProvider {
func setupBoxcutter(mgr manager.Manager, ceReconciler *controllers.ClusterExtensionReconciler, preflights []applier.Preflight) error {
certProvider := getCertificateProvider()

coreClient, err := corev1client.NewForConfig(mgr.GetConfig())
if err != nil {
return fmt.Errorf("unable to create core client: %w", err)
}
cfgGetter, err := helmclient.NewActionConfigGetter(mgr.GetConfig(), mgr.GetRESTMapper(),
helmclient.StorageDriverMapper(action.ChunkedStorageDriverMapper(coreClient, mgr.GetAPIReader(), cfg.systemNamespace)),
helmclient.ClientNamespaceMapper(func(obj client.Object) (string, error) {
ext := obj.(*ocv1.ClusterExtension)
return ext.Spec.Namespace, nil
}),
)
if err != nil {
return fmt.Errorf("unable to create helm action config getter: %w", err)
}

acg, err := action.NewWrappedActionClientGetter(cfgGetter,
helmclient.WithFailureRollbacks(false),
)
if err != nil {
return fmt.Errorf("unable to create helm action client getter: %w", err)
}

// TODO: add support for preflight checks

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this TODO still valid?

// TODO: better scheme handling - which types do we want to support?
_ = apiextensionsv1.AddToScheme(mgr.GetScheme())
ceReconciler.Applier = &applier.Boxcutter{
Client: mgr.GetClient(),
rg := &applier.SimpleRevisionGenerator{
Scheme: mgr.GetScheme(),
RevisionGenerator: &applier.SimpleRevisionGenerator{
Scheme: mgr.GetScheme(),
BundleRenderer: &applier.RegistryV1BundleRenderer{
BundleRenderer: registryv1.Renderer,
CertificateProvider: certProvider,
},
BundleRenderer: &applier.RegistryV1BundleRenderer{
BundleRenderer: registryv1.Renderer,
CertificateProvider: certProvider,
},
Preflights: preflights,
}
ceReconciler.Applier = &applier.Boxcutter{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
RevisionGenerator: rg,
Preflights: preflights,
}
ceReconciler.RevisionStatesGetter = &controllers.BoxcutterRevisionStatesGetter{Reader: mgr.GetClient()}
ceReconciler.StorageMigrator = &applier.BoxcutterStorageMigrator{
Client: mgr.GetClient(),
ActionClientGetter: acg,
RevisionGenerator: rg,
}

// Boxcutter
const (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,17 @@ spec:
objects:
items:
properties:
collisionProtection:
default: Prevent
description: CollisionProtection specifies if and how
ownership collisions are prevented.
type: string
object:
type: object
x-kubernetes-embedded-resource: true
x-kubernetes-preserve-unknown-fields: true
required:
- collisionProtection
- object
type: object
type: array
Expand Down
141 changes: 129 additions & 12 deletions internal/operator-controller/applier/boxcutter.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,25 @@ import (
"io/fs"
"maps"
"slices"
"strings"

"github.com/davecgh/go-spew/spew"
"helm.sh/helm/v3/pkg/release"
"helm.sh/helm/v3/pkg/storage/driver"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/apiutil"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/yaml"

helmclient "github.com/operator-framework/helm-operator-plugins/pkg/client"

ocv1 "github.com/operator-framework/operator-controller/api/v1"
"github.com/operator-framework/operator-controller/internal/operator-controller/controllers"
"github.com/operator-framework/operator-controller/internal/operator-controller/labels"
"github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/bundle/source"
"github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render"
)
Expand All @@ -33,14 +40,58 @@ const (

type ClusterExtensionRevisionGenerator interface {
GenerateRevision(bundleFS fs.FS, ext *ocv1.ClusterExtension, objectLabels, revisionAnnotations map[string]string) (*ocv1.ClusterExtensionRevision, error)
GenerateRevisionFromHelmRelease(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we be mixing concerns here? Maybe we could use generics in the interface to define the input type? (e.g. helmRelease vs bundleFS)?

helmRelease *release.Release, ext *ocv1.ClusterExtension,
objectLabels map[string]string,
) (*ocv1.ClusterExtensionRevision, error)
}

type SimpleRevisionGenerator struct {
Scheme *runtime.Scheme
BundleRenderer BundleRenderer
}

func (r *SimpleRevisionGenerator) GenerateRevision(bundleFS fs.FS, ext *ocv1.ClusterExtension, objectLabels, revisionAnnotations map[string]string) (*ocv1.ClusterExtensionRevision, error) {
func (r *SimpleRevisionGenerator) GenerateRevisionFromHelmRelease(
helmRelease *release.Release, ext *ocv1.ClusterExtension,
objectLabels map[string]string,
) (*ocv1.ClusterExtensionRevision, error) {
docs := splitManifestDocuments(helmRelease.Manifest)
objs := make([]ocv1.ClusterExtensionRevisionObject, 0, len(docs))
for _, doc := range docs {
obj := unstructured.Unstructured{}
if err := yaml.Unmarshal([]byte(doc), &obj); err != nil {
return nil, err
}

labels := maps.Clone(obj.GetLabels())
if labels == nil {
labels = map[string]string{}
}
maps.Copy(labels, objectLabels)
obj.SetLabels(labels)
obj.SetOwnerReferences(nil) // reset OwnerReferences for migration.

objs = append(objs, ocv1.ClusterExtensionRevisionObject{
Object: obj,
CollisionProtection: ocv1.CollisionProtectionNone, // allow to adopt objects from previous release
})
}

rev := r.buildClusterExtensionRevision(objs, ext, map[string]string{

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could make this a revision generator and then just use composition for the bundle and helm release generators....wdyt?

labels.BundleNameKey: helmRelease.Labels[labels.BundleNameKey],
labels.PackageNameKey: helmRelease.Labels[labels.PackageNameKey],
labels.BundleVersionKey: helmRelease.Labels[labels.BundleVersionKey],
labels.BundleReferenceKey: helmRelease.Labels[labels.BundleReferenceKey],
})
rev.Name = fmt.Sprintf("%s-1", ext.Name)
rev.Spec.Revision = 1
return rev, nil
}

func (r *SimpleRevisionGenerator) GenerateRevision(
bundleFS fs.FS, ext *ocv1.ClusterExtension,
objectLabels, revisionAnnotations map[string]string,
) (*ocv1.ClusterExtensionRevision, error) {
// extract plain manifests
plain, err := r.BundleRenderer.Render(bundleFS, ext)
if err != nil {
Expand All @@ -50,14 +101,12 @@ func (r *SimpleRevisionGenerator) GenerateRevision(bundleFS fs.FS, ext *ocv1.Clu
// objectLabels
objs := make([]ocv1.ClusterExtensionRevisionObject, 0, len(plain))
for _, obj := range plain {
if len(obj.GetLabels()) > 0 {
labels := maps.Clone(obj.GetLabels())
if labels == nil {
labels = map[string]string{}
}
maps.Copy(labels, objectLabels)
obj.SetLabels(labels)
labels := maps.Clone(obj.GetLabels())
if labels == nil {
labels = map[string]string{}
}
maps.Copy(labels, objectLabels)
obj.SetLabels(labels)

gvk, err := apiutil.GVKForObject(obj, r.Scheme)
if err != nil {
Expand All @@ -80,18 +129,73 @@ func (r *SimpleRevisionGenerator) GenerateRevision(bundleFS fs.FS, ext *ocv1.Clu
revisionAnnotations = map[string]string{}
}

// Build desired revision
return r.buildClusterExtensionRevision(objs, ext, revisionAnnotations), nil
}

func (r *SimpleRevisionGenerator) buildClusterExtensionRevision(
objects []ocv1.ClusterExtensionRevisionObject,
ext *ocv1.ClusterExtension,
annotations map[string]string,
) *ocv1.ClusterExtensionRevision {
return &ocv1.ClusterExtensionRevision{
ObjectMeta: metav1.ObjectMeta{
Annotations: revisionAnnotations,
Annotations: annotations,
Labels: map[string]string{
controllers.ClusterExtensionRevisionOwnerLabel: ext.Name,
},
},
Spec: ocv1.ClusterExtensionRevisionSpec{
Phases: PhaseSort(objs),
Phases: PhaseSort(objects),
},
}, nil
}
}

type BoxcutterStorageMigrator struct {
ActionClientGetter helmclient.ActionClientGetter
RevisionGenerator ClusterExtensionRevisionGenerator
Client boxcutterStorageMigratorClient
}

type boxcutterStorageMigratorClient interface {
List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error
Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error
}

func (m *BoxcutterStorageMigrator) Migrate(ctx context.Context, ext *ocv1.ClusterExtension, objectLabels map[string]string) error {
existingRevisionList := ocv1.ClusterExtensionRevisionList{}
if err := m.Client.List(ctx, &existingRevisionList, client.MatchingLabels{
controllers.ClusterExtensionRevisionOwnerLabel: ext.Name,
}); err != nil {
return fmt.Errorf("listing ClusterExtensionRevisions before attempting migration: %w", err)
}
if len(existingRevisionList.Items) != 0 {
// No migration needed.
return nil
}

ac, err := m.ActionClientGetter.ActionClientFor(ctx, ext)
if err != nil {
return err
}

helmRelease, err := ac.Get(ext.GetName())
if errors.Is(err, driver.ErrReleaseNotFound) {
// no Helm Release -> no prior installation.
return nil
}
if err != nil {
return err
}

rev, err := m.RevisionGenerator.GenerateRevisionFromHelmRelease(helmRelease, ext, objectLabels)
if err != nil {
return err
}

if err := m.Client.Create(ctx, rev); err != nil {
return err
}
return nil
}

type Boxcutter struct {
Expand Down Expand Up @@ -288,3 +392,16 @@ func (r *RegistryV1BundleRenderer) Render(bundleFS fs.FS, ext *ocv1.ClusterExten
}
return r.BundleRenderer.Render(reg, ext.Spec.Namespace, render.WithTargetNamespaces(watchNamespace), render.WithCertificateProvider(r.CertificateProvider))
}

func splitManifestDocuments(file string) []string {
//nolint:prealloc
var docs []string
for _, manifest := range strings.Split(file, "\n") {
manifest = strings.TrimSpace(manifest)
if len(manifest) == 0 {
continue
}
docs = append(docs, manifest)
}
return docs
}
Loading
Loading