Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add support for handling plain+v0 bundle types #242

Merged
Merged
Show file tree
Hide file tree
Changes from 6 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
2 changes: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,10 @@ kind-cluster-cleanup: $(KIND) ## Delete the kind cluster

kind-load-test-artifacts: $(KIND) ## Load the e2e testdata container images into a kind cluster
$(CONTAINER_RUNTIME) build $(TESTDATA_DIR)/bundles/registry-v1/prometheus-operator.v0.47.0 -t localhost/testdata/bundles/registry-v1/prometheus-operator:v0.47.0
$(CONTAINER_RUNTIME) build $(TESTDATA_DIR)/bundles/plain-v0/plain.v0.1.0 -t localhost/testdata/bundles/plain-v0/plain:v0.1.0
$(CONTAINER_RUNTIME) build $(TESTDATA_DIR)/catalogs -f $(TESTDATA_DIR)/catalogs/test-catalog.Dockerfile -t localhost/testdata/catalogs/test-catalog:e2e
$(KIND) load docker-image localhost/testdata/bundles/registry-v1/prometheus-operator:v0.47.0 --name $(KIND_CLUSTER_NAME)
$(KIND) load docker-image localhost/testdata/bundles/plain-v0/plain:v0.1.0 --name $(KIND_CLUSTER_NAME)
$(KIND) load docker-image localhost/testdata/catalogs/test-catalog:e2e --name $(KIND_CLUSTER_NAME)

##@ Build
Expand Down
35 changes: 31 additions & 4 deletions internal/controllers/operator_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,19 @@ func (r *OperatorReconciler) reconcile(ctx context.Context, op *operatorsv1alpha
op.Status.ResolvedBundleResource = bundleImage
setResolvedStatusConditionSuccess(&op.Status.Conditions, fmt.Sprintf("resolved to %q", bundleImage), op.GetGeneration())

mediaType, err := bundleEntity.MediaType()
if err != nil {
setInstalledStatusConditionFailed(&op.Status.Conditions, err.Error(), op.GetGeneration())
return ctrl.Result{}, err
}
bundleProvisioner, err := mapBundleMediaTypeToBundleProvisioner(mediaType)
if err != nil {
setInstalledStatusConditionFailed(&op.Status.Conditions, err.Error(), op.GetGeneration())
return ctrl.Result{}, err
}
// Ensure a BundleDeployment exists with its bundle source from the bundle
// image we just looked up in the solution.
dep := r.generateExpectedBundleDeployment(*op, bundleImage)
dep := r.generateExpectedBundleDeployment(*op, bundleImage, bundleProvisioner)
if err := r.ensureBundleDeployment(ctx, dep); err != nil {
// originally Reason: operatorsv1alpha1.ReasonInstallationFailed
op.Status.InstalledBundleResource = ""
Expand Down Expand Up @@ -244,12 +254,13 @@ func (r *OperatorReconciler) getBundleEntityFromSolution(solution *solver.Soluti
return nil, fmt.Errorf("entity for package %q not found in solution", packageName)
}

func (r *OperatorReconciler) generateExpectedBundleDeployment(o operatorsv1alpha1.Operator, bundlePath string) *unstructured.Unstructured {
func (r *OperatorReconciler) generateExpectedBundleDeployment(o operatorsv1alpha1.Operator, bundlePath string, bundleProvisioner string) *unstructured.Unstructured {
// We use unstructured here to avoid problems of serializing default values when sending patches to the apiserver.
Copy link
Member

Choose a reason for hiding this comment

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

This isn't new code for this PR but ... why not use SSA?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Looks like it does here:

return r.Client.Patch(ctx, desiredBundleDeployment, client.Apply, client.ForceOwnership, client.FieldOwner("operator-controller"))
Although I could be misunderstanding this patch call as doing SSA and it isn't. This generateExpectedBundleDeployment is generating an unstructured.Unstructured to use for that patch request. I'd be curious to see if it is worth it to create applyconfigurations for the rukpak APIs to improve the creation of the patch information here

Copy link
Member

Choose a reason for hiding this comment

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

I'd be curious to see if it is worth it to create applyconfigurations for the rukpak APIs to improve the creation of the patch information here

We updated the generators upstream so you should get those free with newer codegen tools. I do think the apply you linked will be SSA - and the defaults worries from this block should be obviated.

Copy link
Member

Choose a reason for hiding this comment

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

Watching this closely :) kubernetes-sigs/controller-tools#818

// If you use a typed object, any default values from that struct get serialized into the JSON patch, which could
// cause unrelated fields to be patched back to the default value even though that isn't the intention. Using an
// unstructured ensures that the patch contains only what is specified. Using unstructured like this is basically
// identical to "kubectl apply -f"

bd := &unstructured.Unstructured{Object: map[string]interface{}{
"apiVersion": rukpakv1alpha1.GroupVersion.String(),
"kind": rukpakv1alpha1.BundleDeploymentKind,
Expand All @@ -261,8 +272,7 @@ func (r *OperatorReconciler) generateExpectedBundleDeployment(o operatorsv1alpha
"provisionerClassName": "core-rukpak-io-plain",
"template": map[string]interface{}{
"spec": map[string]interface{}{
// TODO: Don't assume registry provisioner
"provisionerClassName": "core-rukpak-io-registry",
"provisionerClassName": bundleProvisioner,
"source": map[string]interface{}{
// TODO: Don't assume image type
"type": string(rukpakv1alpha1.SourceTypeImage),
Expand Down Expand Up @@ -363,6 +373,23 @@ func isBundleDepStale(bd *rukpakv1alpha1.BundleDeployment) bool {
return bd != nil && bd.Status.ObservedGeneration != bd.GetGeneration()
}

// mapBundleMediaTypeToBundleProvisioner maps an olm.bundle.mediatype property to a
// rukpak bundle provisioner class name that is capable of unpacking the bundle type
func mapBundleMediaTypeToBundleProvisioner(mediaType string) (string, error) {
joelanford marked this conversation as resolved.
Show resolved Hide resolved
switch mediaType {
case entity.MediaTypePlain:
return "core-rukpak-io-plain", nil
// To ensure compatibility with bundles created with OLMv0 where the
// olm.bundle.mediatype property doesn't exist, we assume that if the
// property is empty (i.e doesn't exist) that the bundle is one created
// with OLMv0 and therefore should use the registry provisioner
case entity.MediaTypeRegistry, "":
return "core-rukpak-io-registry", nil
everettraven marked this conversation as resolved.
Show resolved Hide resolved
default:
return "", fmt.Errorf("unknown bundle mediatype: %s", mediaType)
}
}

// setResolvedStatusConditionSuccess sets the resolved status condition to success.
func setResolvedStatusConditionSuccess(conditions *[]metav1.Condition, message string, generation int64) {
apimeta.SetStatusCondition(conditions, metav1.Condition{
Expand Down
166 changes: 141 additions & 25 deletions internal/controllers/operator_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -904,58 +904,160 @@ var _ = Describe("Operator Controller Test", func() {
Expect(cond.Message).To(Equal("installation has not been attempted as resolution failed"))
})
})
When("an invalid semver is provided that bypasses the regex validation", func() {
var (
pkgName string
fakeClient client.Client
)
When("the operator specifies a package with a plain+v0 bundle", func() {
var pkgName string
var pkgVer string
var pkgChan string
BeforeEach(func() {
opKey = types.NamespacedName{Name: fmt.Sprintf("operator-validation-test-%s", rand.String(8))}

By("injecting creating a client with the bad operator CR")
pkgName = fmt.Sprintf("exists-%s", rand.String(6))
By("initializing cluster state")
pkgName = "plain"
pkgVer = "0.1.0"
pkgChan = "beta"
operator = &operatorsv1alpha1.Operator{
ObjectMeta: metav1.ObjectMeta{Name: opKey.Name},
Spec: operatorsv1alpha1.OperatorSpec{
PackageName: pkgName,
Version: "1.2.3-123abc_def", // bad semver that matches the regex on the CR validation
Version: pkgVer,
Channel: pkgChan,
},
}
err := cl.Create(ctx, operator)
Expect(err).NotTo(HaveOccurred())
})
It("sets resolution success status", func() {
By("running reconcile")
res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: opKey})
Expect(res).To(Equal(ctrl.Result{}))
Expect(err).NotTo(HaveOccurred())
By("fetching updated operator after reconcile")
Expect(cl.Get(ctx, opKey, operator)).NotTo(HaveOccurred())

// this bypasses client/server-side CR validation and allows us to test the reconciler's validation
fakeClient = fake.NewClientBuilder().WithScheme(sch).WithObjects(operator).Build()
By("Checking the status fields")
Expect(operator.Status.ResolvedBundleResource).To(Equal("quay.io/operatorhub/plain@sha256:plain"))
Expect(operator.Status.InstalledBundleResource).To(Equal(""))

By("changing the reconciler client to the fake client")
reconciler.Client = fakeClient
})
By("checking the expected conditions")
cond := apimeta.FindStatusCondition(operator.Status.Conditions, operatorsv1alpha1.TypeResolved)
Expect(cond).NotTo(BeNil())
Expect(cond.Status).To(Equal(metav1.ConditionTrue))
Expect(cond.Reason).To(Equal(operatorsv1alpha1.ReasonSuccess))
Expect(cond.Message).To(Equal("resolved to \"quay.io/operatorhub/plain@sha256:plain\""))
cond = apimeta.FindStatusCondition(operator.Status.Conditions, operatorsv1alpha1.TypeInstalled)
Expect(cond).NotTo(BeNil())
Expect(cond.Status).To(Equal(metav1.ConditionUnknown))
Expect(cond.Reason).To(Equal(operatorsv1alpha1.ReasonInstallationStatusUnknown))
Expect(cond.Message).To(Equal("bundledeployment status is unknown"))

It("should add an invalid spec condition and *not* re-enqueue for reconciliation", func() {
By("fetching the bundled deployment")
bd := &rukpakv1alpha1.BundleDeployment{}
Expect(cl.Get(ctx, types.NamespacedName{Name: opKey.Name}, bd)).NotTo(HaveOccurred())
Expect(bd.Spec.ProvisionerClassName).To(Equal("core-rukpak-io-plain"))
Expect(bd.Spec.Template.Spec.ProvisionerClassName).To(Equal("core-rukpak-io-plain"))
Expect(bd.Spec.Template.Spec.Source.Type).To(Equal(rukpakv1alpha1.SourceTypeImage))
Expect(bd.Spec.Template.Spec.Source.Image).NotTo(BeNil())
Expect(bd.Spec.Template.Spec.Source.Image.Ref).To(Equal("quay.io/operatorhub/plain@sha256:plain"))
})
})
When("the operator specifies a package with a bad bundle mediatype", func() {
var pkgName string
var pkgVer string
var pkgChan string
BeforeEach(func() {
By("initializing cluster state")
pkgName = "badmedia"
pkgVer = "0.1.0"
pkgChan = "beta"
operator = &operatorsv1alpha1.Operator{
ObjectMeta: metav1.ObjectMeta{Name: opKey.Name},
Spec: operatorsv1alpha1.OperatorSpec{
PackageName: pkgName,
Version: pkgVer,
Channel: pkgChan,
},
}
err := cl.Create(ctx, operator)
Expect(err).NotTo(HaveOccurred())
})
It("sets resolution success status", func() {
By("running reconcile")
res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: opKey})
Expect(res).To(Equal(ctrl.Result{}))
Expect(err).ToNot(HaveOccurred())
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(Equal("unknown bundle mediatype: badmedia+v1"))

By("fetching updated operator after reconcile")
Expect(fakeClient.Get(ctx, opKey, operator)).NotTo(HaveOccurred())
Expect(cl.Get(ctx, opKey, operator)).NotTo(HaveOccurred())

By("Checking the status fields")
Expect(operator.Status.ResolvedBundleResource).To(Equal(""))
Expect(operator.Status.ResolvedBundleResource).To(Equal("quay.io/operatorhub/badmedia@sha256:badmedia"))
Expect(operator.Status.InstalledBundleResource).To(Equal(""))

By("checking the expected conditions")
cond := apimeta.FindStatusCondition(operator.Status.Conditions, operatorsv1alpha1.TypeResolved)
Expect(cond).NotTo(BeNil())
Expect(cond.Status).To(Equal(metav1.ConditionUnknown))
Expect(cond.Reason).To(Equal(operatorsv1alpha1.ReasonResolutionUnknown))
Expect(cond.Message).To(Equal("validation has not been attempted as spec is invalid"))
Expect(cond.Status).To(Equal(metav1.ConditionTrue))
Expect(cond.Reason).To(Equal(operatorsv1alpha1.ReasonSuccess))
Expect(cond.Message).To(Equal("resolved to \"quay.io/operatorhub/badmedia@sha256:badmedia\""))
cond = apimeta.FindStatusCondition(operator.Status.Conditions, operatorsv1alpha1.TypeInstalled)
Expect(cond).NotTo(BeNil())
Expect(cond.Status).To(Equal(metav1.ConditionUnknown))
Expect(cond.Reason).To(Equal(operatorsv1alpha1.ReasonInstallationStatusUnknown))
Expect(cond.Message).To(Equal("installation has not been attempted as spec is invalid"))
Expect(cond.Status).To(Equal(metav1.ConditionFalse))
Expect(cond.Reason).To(Equal(operatorsv1alpha1.ReasonInstallationFailed))
Expect(cond.Message).To(Equal("unknown bundle mediatype: badmedia+v1"))
})
})
})
When("an invalid semver is provided that bypasses the regex validation", func() {
Copy link
Member

Choose a reason for hiding this comment

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

This may have been missed in the merge. I moved this in #257.

var (
operator *operatorsv1alpha1.Operator
opKey types.NamespacedName
pkgName string
fakeClient client.Client
)
BeforeEach(func() {
opKey = types.NamespacedName{Name: fmt.Sprintf("operator-validation-test-%s", rand.String(8))}

By("injecting creating a client with the bad operator CR")
pkgName = fmt.Sprintf("exists-%s", rand.String(6))
operator = &operatorsv1alpha1.Operator{
ObjectMeta: metav1.ObjectMeta{Name: opKey.Name},
Spec: operatorsv1alpha1.OperatorSpec{
PackageName: pkgName,
Version: "1.2.3-123abc_def", // bad semver that matches the regex on the CR validation
},
}

// this bypasses client/server-side CR validation and allows us to test the reconciler's validation
fakeClient = fake.NewClientBuilder().WithScheme(sch).WithObjects(operator).Build()

By("changing the reconciler client to the fake client")
reconciler.Client = fakeClient
})

It("should add an invalid spec condition and *not* re-enqueue for reconciliation", func() {
By("running reconcile")
res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: opKey})
Expect(res).To(Equal(ctrl.Result{}))
Expect(err).ToNot(HaveOccurred())

By("fetching updated operator after reconcile")
Expect(fakeClient.Get(ctx, opKey, operator)).NotTo(HaveOccurred())

By("Checking the status fields")
Expect(operator.Status.ResolvedBundleResource).To(Equal(""))
Expect(operator.Status.InstalledBundleResource).To(Equal(""))

By("checking the expected conditions")
cond := apimeta.FindStatusCondition(operator.Status.Conditions, operatorsv1alpha1.TypeResolved)
Expect(cond).NotTo(BeNil())
Expect(cond.Status).To(Equal(metav1.ConditionUnknown))
Expect(cond.Reason).To(Equal(operatorsv1alpha1.ReasonResolutionUnknown))
Expect(cond.Message).To(Equal("validation has not been attempted as spec is invalid"))
cond = apimeta.FindStatusCondition(operator.Status.Conditions, operatorsv1alpha1.TypeInstalled)
Expect(cond).NotTo(BeNil())
Expect(cond.Status).To(Equal(metav1.ConditionUnknown))
Expect(cond.Reason).To(Equal(operatorsv1alpha1.ReasonInstallationStatusUnknown))
Expect(cond.Message).To(Equal("installation has not been attempted as spec is invalid"))
})
})
})

Expand Down Expand Up @@ -1000,4 +1102,18 @@ var testEntitySource = input.NewCacheQuerier(map[deppy.Identifier]input.Entity{
"olm.package": `{"packageName":"badimage","version":"0.1.0"}`,
"olm.gvk": `[]`,
}),
"operatorhub/plain/0.1.0": *input.NewEntity("operatorhub/plain/0.1.0", map[string]string{
"olm.bundle.path": `"quay.io/operatorhub/plain@sha256:plain"`,
"olm.channel": `{"channelName":"beta","priority":0}`,
"olm.package": `{"packageName":"plain","version":"0.1.0"}`,
"olm.gvk": `[]`,
"olm.bundle.mediatype": `"plain+v0"`,
}),
"operatorhub/badmedia/0.1.0": *input.NewEntity("operatorhub/badmedia/0.1.0", map[string]string{
"olm.bundle.path": `"quay.io/operatorhub/badmedia@sha256:badmedia"`,
"olm.channel": `{"channelName":"beta","priority":0}`,
"olm.package": `{"packageName":"badmedia","version":"0.1.0"}`,
"olm.gvk": `[]`,
"olm.bundle.mediatype": `"badmedia+v1"`,
}),
})
5 changes: 5 additions & 0 deletions internal/resolution/entitysources/catalogdsource.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (

"github.com/operator-framework/deppy/pkg/deppy"
"github.com/operator-framework/deppy/pkg/deppy/input"
"github.com/operator-framework/operator-controller/internal/resolution/variable_sources/entity"
"github.com/operator-framework/operator-registry/alpha/property"
"sigs.k8s.io/controller-runtime/pkg/client"

Expand Down Expand Up @@ -80,12 +81,16 @@ func getEntities(ctx context.Context, client client.Client) (input.EntityList, e
for _, bundle := range bundleMetadatas.Items {
props := map[string]string{}

// TODO: We should make sure all properties are forwarded
// through and avoid a lossy translation from FBC --> entity
for _, prop := range bundle.Spec.Properties {
switch prop.Type {
case property.TypePackage:
// this is already a json marshalled object, so it doesn't need to be marshalled
// like the other ones
props[property.TypePackage] = string(prop.Value)
case entity.PropertyBundleMediaType:
props[entity.PropertyBundleMediaType] = string(prop.Value)
everettraven marked this conversation as resolved.
Show resolved Hide resolved
}
}

Expand Down
35 changes: 35 additions & 0 deletions internal/resolution/variable_sources/entity/bundle_entity.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,19 @@ import (

const PropertyBundlePath = "olm.bundle.path"

// TODO: Is this the right place for these?
// ----
const PropertyBundleMediaType = "olm.bundle.mediatype"

type MediaType string

const (
MediaTypePlain = "plain+v0"
MediaTypeRegistry = "registry+v1"
)
everettraven marked this conversation as resolved.
Show resolved Hide resolved

// ----

type ChannelProperties struct {
property.Channel
Replaces string `json:"replaces,omitempty"`
Expand Down Expand Up @@ -58,6 +71,7 @@ type BundleEntity struct {
channelProperties *ChannelProperties
semVersion *semver.Version
bundlePath string
mediaType string
mu sync.RWMutex
}

Expand Down Expand Up @@ -124,6 +138,27 @@ func (b *BundleEntity) BundlePath() (string, error) {
return b.bundlePath, nil
}

func (b *BundleEntity) MediaType() (string, error) {
if err := b.loadMediaType(); err != nil {
return "", err
}

return b.mediaType, nil
}

func (b *BundleEntity) loadMediaType() error {
b.mu.Lock()
defer b.mu.Unlock()
if b.mediaType == "" {
mediaType, err := loadFromEntity[string](b.Entity, PropertyBundleMediaType, optional)
if err != nil {
return fmt.Errorf("error determining bundle mediatype for entity '%s': %w", b.ID, err)
}
b.mediaType = mediaType
}
return nil
}

func (b *BundleEntity) loadPackage() error {
b.mu.Lock()
defer b.mu.Unlock()
Expand Down
Loading