From 30edf51013fc506ca3eaa08fa67d8a0a4c648cff Mon Sep 17 00:00:00 2001 From: Cesar Celis Date: Thu, 22 Oct 2026 17:48:51 -0400 Subject: [PATCH 1/3] Add RegistryV1DaemonSetSupport feature gate Allow registry+v1 bundles to ship a DaemonSet in manifests/, for operators that need an agent on every node, such as a CSI node plugin. The gate is alpha and disabled by default, and it has no effect unless BoxcutterRuntime is also enabled, since that runtime provides the DaemonSet progression probe. Validators take no render options, so CheckObjectSupport now leaves DaemonSet to the BundleAdditionalResourcesGenerator, which accepts it only when the renderer runs with WithDaemonSetSupport. With the gate disabled, a bundle with a DaemonSet is still rejected, now at render time. With it enabled, the DaemonSet is rendered into the install namespace. Signed-off-by: Cesar Celis --- cmd/operator-controller/main.go | 1 + docs/draft/howto/daemonset-support.md | 48 +++++++++++++++++++ helm/experimental.yaml | 1 + helm/olmv1/values.yaml | 1 + .../operator-controller/applier/provider.go | 4 ++ .../applier/provider_test.go | 24 ++++++++++ .../operator-controller/features/features.go | 16 +++++++ .../features/features_test.go | 32 +++++++++++++ .../registryv1/generators/generators.go | 3 ++ .../registryv1/generators/generators_test.go | 32 +++++++++++++ .../render/registryv1/registryv1_test.go | 35 ++++++++++++++ .../render/registryv1/validators/validator.go | 4 ++ .../registryv1/validators/validator_test.go | 9 ++++ .../rukpak/render/render.go | 10 ++++ manifests/experimental-e2e.yaml | 1 + manifests/experimental.yaml | 1 + manifests/standard-e2e.yaml | 1 + manifests/standard.yaml | 1 + 18 files changed, 224 insertions(+) create mode 100644 docs/draft/howto/daemonset-support.md create mode 100644 internal/operator-controller/features/features_test.go diff --git a/cmd/operator-controller/main.go b/cmd/operator-controller/main.go index 0192d06579..cbf2aafcdb 100644 --- a/cmd/operator-controller/main.go +++ b/cmd/operator-controller/main.go @@ -494,6 +494,7 @@ func run() error { IsSingleOwnNamespaceEnabled: features.OperatorControllerFeatureGate.Enabled(features.SingleOwnNamespaceInstallSupport), IsDeploymentConfigEnabled: features.OperatorControllerFeatureGate.Enabled(features.DeploymentConfig), IsNamespaceManagementEnabled: features.OperatorControllerFeatureGate.Enabled(features.BoxcutterRuntime), + IsDaemonSetSupportEnabled: features.DaemonSetSupportEnabled(features.OperatorControllerFeatureGate), } var cerCfg reconcilerConfigurator if features.OperatorControllerFeatureGate.Enabled(features.BoxcutterRuntime) { diff --git a/docs/draft/howto/daemonset-support.md b/docs/draft/howto/daemonset-support.md new file mode 100644 index 0000000000..3da862a432 --- /dev/null +++ b/docs/draft/howto/daemonset-support.md @@ -0,0 +1,48 @@ +## Description + +!!! note +This feature is still in *alpha*. The `RegistryV1DaemonSetSupport` feature-gate is disabled by default and must be enabled to make use of it. +It has no effect unless the `BoxcutterRuntime` feature-gate is also enabled. +See the instructions below on how to enable it. + +--- + +Some operators need an agent on every node, for example a CSI driver's node plugin. Without this feature-gate, +OLMv1 rejects any registry+v1 bundle that includes a `DaemonSet` in its `manifests/` directory: + +``` +bundle contains unsupported resource: Name: , Kind: DaemonSet +``` + +With the feature-gate enabled, a `DaemonSet` in `manifests/` is installed into the `ClusterExtension`'s install +namespace, like any other namespaced object in the bundle. + +The `DaemonSet` takes part in rollout progression: the +revision is not considered rolled out until `status.updatedNumberScheduled` and `status.numberAvailable` both equal +`status.desiredNumberScheduled` for the latest observed generation. + +!!! note +As with the `Deployment` progression probe, a `DaemonSet` whose status reports zero scheduled pods does not pass +the probe, because the API server omits zero-valued `updatedNumberScheduled` and `numberAvailable` fields. + +## Enabling the Feature-Gate + +!!! tip + +This guide assumes OLMv1 is already installed. If that is not the case, +you can follow the [getting started](../../getting-started/olmv1_getting_started.md) guide to install OLMv1. + +--- + +Patch the `operator-controller` `Deployment` adding `--feature-gates=RegistryV1DaemonSetSupport=true` to the +controller container arguments. `BoxcutterRuntime` must be enabled as well: + +```terminal title="Enable RegistryV1DaemonSetSupport feature-gate" +kubectl patch deployment -n olmv1-system operator-controller-controller-manager --type='json' -p='[{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value": "--feature-gates=RegistryV1DaemonSetSupport=true"}]' +``` + +Wait for `Deployment` rollout: + +```terminal title="Wait for Deployment rollout" +kubectl rollout status -n olmv1-system deployment/operator-controller-controller-manager +``` diff --git a/helm/experimental.yaml b/helm/experimental.yaml index 156430d0b5..f03bd029c5 100644 --- a/helm/experimental.yaml +++ b/helm/experimental.yaml @@ -16,6 +16,7 @@ options: - BoxcutterRuntime - BundleReleaseSupport - DeploymentConfig + - RegistryV1DaemonSetSupport - SingleOwnNamespaceInstallSupport - WebhookProviderCertManager disabled: diff --git a/helm/olmv1/values.yaml b/helm/olmv1/values.yaml index bc3e709403..dd82e7c1c5 100644 --- a/helm/olmv1/values.yaml +++ b/helm/olmv1/values.yaml @@ -26,6 +26,7 @@ options: - BoxcutterRuntime - BundleReleaseSupport - DeploymentConfig + - RegistryV1DaemonSetSupport - SingleOwnNamespaceInstallSupport - WebhookProviderOpenshiftServiceCA podDisruptionBudget: diff --git a/internal/operator-controller/applier/provider.go b/internal/operator-controller/applier/provider.go index d4db0a8570..819d1d0724 100644 --- a/internal/operator-controller/applier/provider.go +++ b/internal/operator-controller/applier/provider.go @@ -35,6 +35,7 @@ type RegistryV1ManifestProvider struct { IsSingleOwnNamespaceEnabled bool IsDeploymentConfigEnabled bool IsNamespaceManagementEnabled bool + IsDaemonSetSupportEnabled bool } func (r *RegistryV1ManifestProvider) Get(bundleFS fs.FS, ext *ocv1.ClusterExtension) ([]client.Object, error) { @@ -75,6 +76,9 @@ func (r *RegistryV1ManifestProvider) Get(bundleFS fs.FS, ext *ocv1.ClusterExtens opts := []render.Option{ render.WithCertificateProvider(r.CertificateProvider), } + if r.IsDaemonSetSupportEnabled { + opts = append(opts, render.WithDaemonSetSupport()) + } // When the user set spec.namespace, render into that caller-managed (already-existing) // namespace and do not emit a Namespace object. Otherwise the renderer resolves the diff --git a/internal/operator-controller/applier/provider_test.go b/internal/operator-controller/applier/provider_test.go index d1b26faf54..9540d51b26 100644 --- a/internal/operator-controller/applier/provider_test.go +++ b/internal/operator-controller/applier/provider_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" + appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -256,6 +257,29 @@ func Test_RegistryV1ManifestProvider_BoxcutterRuntimeGate(t *testing.T) { }) } +func Test_RegistryV1ManifestProvider_DaemonSetSupportGate(t *testing.T) { + bundleFS := bundlefs.Builder().WithPackageName("test"). + WithCSV(bundlecsv.Builder().WithInstallModeSupportFor(v1alpha1.InstallModeTypeAllNamespaces).Build()). + WithBundleResource("daemonset.yaml", &appsv1.DaemonSet{ + TypeMeta: metav1.TypeMeta{APIVersion: appsv1.SchemeGroupVersion.String(), Kind: "DaemonSet"}, + ObjectMeta: metav1.ObjectMeta{Name: "node-plugin"}, + }).Build() + ext := &ocv1.ClusterExtension{Spec: ocv1.ClusterExtensionSpec{Namespace: "install-namespace"}} + + t.Run("rejects a DaemonSet when the RegistryV1DaemonSetSupport feature gate is disabled", func(t *testing.T) { + provider := applier.RegistryV1ManifestProvider{BundleRenderer: registryv1.Renderer} + _, err := provider.Get(bundleFS, ext) + require.ErrorContains(t, err, "bundle contains unsupported resource: Name: node-plugin, Kind: DaemonSet") + }) + + t.Run("renders a DaemonSet when the RegistryV1DaemonSetSupport feature gate is enabled", func(t *testing.T) { + provider := applier.RegistryV1ManifestProvider{BundleRenderer: registryv1.Renderer, IsDaemonSetSupportEnabled: true} + objs, err := provider.Get(bundleFS, ext) + require.NoError(t, err) + require.Contains(t, collectKinds(objs), "DaemonSet") + }) +} + // newAllNamespacesBundleFS returns a minimal registry+v1 bundle FS that supports the // AllNamespaces install mode and includes a single Service resource named "test-service". func newAllNamespacesBundleFS(t *testing.T) fs.FS { diff --git a/internal/operator-controller/features/features.go b/internal/operator-controller/features/features.go index 610295dd9c..872a5c5fbf 100644 --- a/internal/operator-controller/features/features.go +++ b/internal/operator-controller/features/features.go @@ -17,6 +17,7 @@ const ( BoxcutterRuntime featuregate.Feature = "BoxcutterRuntime" DeploymentConfig featuregate.Feature = "DeploymentConfig" BundleReleaseSupport featuregate.Feature = "BundleReleaseSupport" + RegistryV1DaemonSetSupport featuregate.Feature = "RegistryV1DaemonSetSupport" ) var operatorControllerFeatureGates = map[featuregate.Feature]featuregate.FeatureSpec{ @@ -76,6 +77,15 @@ var operatorControllerFeatureGates = map[featuregate.Feature]featuregate.Feature PreRelease: featuregate.Alpha, LockToDefault: false, }, + + // RegistryV1DaemonSetSupport enables installing registry+v1 bundles + // that include a DaemonSet in manifests/, e.g. a CSI node plugin. + // It has no effect unless BoxcutterRuntime is also enabled. + RegistryV1DaemonSetSupport: { + Default: false, + PreRelease: featuregate.Alpha, + LockToDefault: false, + }, } var OperatorControllerFeatureGate featuregate.MutableFeatureGate = featuregate.NewFeatureGate() @@ -84,6 +94,12 @@ func init() { utilruntime.Must(OperatorControllerFeatureGate.Add(operatorControllerFeatureGates)) } +// DaemonSetSupportEnabled reports whether registry+v1 bundles may include a DaemonSet. +// It requires BoxcutterRuntime, which provides the DaemonSet progression probe. +func DaemonSetSupportEnabled(fg featuregate.FeatureGate) bool { + return fg.Enabled(RegistryV1DaemonSetSupport) && fg.Enabled(BoxcutterRuntime) +} + // LogFeatureGateStates logs the state of all known feature gates. func LogFeatureGateStates(log logr.Logger, fg featuregate.FeatureGate) { featuregatesutil.LogFeatureGateStates(log, "feature gate status", fg, operatorControllerFeatureGates) diff --git a/internal/operator-controller/features/features_test.go b/internal/operator-controller/features/features_test.go new file mode 100644 index 0000000000..892a0e4f80 --- /dev/null +++ b/internal/operator-controller/features/features_test.go @@ -0,0 +1,32 @@ +package features + +import ( + "testing" + + "github.com/stretchr/testify/require" + "k8s.io/component-base/featuregate" +) + +func TestDaemonSetSupportEnabled(t *testing.T) { + for _, tc := range []struct { + name string + daemonSet bool + boxcutter bool + want bool + }{ + {name: "both gates enabled", daemonSet: true, boxcutter: true, want: true}, + {name: "rejected without BoxcutterRuntime", daemonSet: true, boxcutter: false, want: false}, + {name: "rejected without RegistryV1DaemonSetSupport", daemonSet: false, boxcutter: true, want: false}, + {name: "both gates disabled", daemonSet: false, boxcutter: false, want: false}, + } { + t.Run(tc.name, func(t *testing.T) { + fg := featuregate.NewFeatureGate() + require.NoError(t, fg.Add(operatorControllerFeatureGates)) + require.NoError(t, fg.SetFromMap(map[string]bool{ + string(RegistryV1DaemonSetSupport): tc.daemonSet, + string(BoxcutterRuntime): tc.boxcutter, + })) + require.Equal(t, tc.want, DaemonSetSupportEnabled(fg)) + }) + } +} diff --git a/internal/operator-controller/rukpak/render/registryv1/generators/generators.go b/internal/operator-controller/rukpak/render/registryv1/generators/generators.go index c830fe0229..c748130b36 100644 --- a/internal/operator-controller/rukpak/render/registryv1/generators/generators.go +++ b/internal/operator-controller/rukpak/render/registryv1/generators/generators.go @@ -319,6 +319,9 @@ func BundleAdditionalResourcesGenerator(rv1 *bundle.RegistryV1, opts render.Opti objs := make([]client.Object, 0, len(rv1.Others)) for _, res := range rv1.Others { supported, namespaced := registrybundle.IsSupported(res.GetKind()) + if res.GetKind() == "DaemonSet" && opts.AllowDaemonSets { + supported, namespaced = true, true + } if !supported { return nil, fmt.Errorf("bundle contains unsupported resource: Name: %v, Kind: %v", res.GetName(), res.GetKind()) } diff --git a/internal/operator-controller/rukpak/render/registryv1/generators/generators_test.go b/internal/operator-controller/rukpak/render/registryv1/generators/generators_test.go index 80c4e3c9d5..736e8e1a22 100644 --- a/internal/operator-controller/rukpak/render/registryv1/generators/generators_test.go +++ b/internal/operator-controller/rukpak/render/registryv1/generators/generators_test.go @@ -1514,6 +1514,38 @@ func Test_BundleAdditionalResourcesGenerator_Succeeds(t *testing.T) { require.Len(t, objs, 2) } +func Test_BundleAdditionalResourcesGenerator_DaemonSet(t *testing.T) { + bundle := &bundle.RegistryV1{ + Others: []unstructured.Unstructured{ + *ToUnstructuredT(t, + &appsv1.DaemonSet{ + TypeMeta: metav1.TypeMeta{ + Kind: "DaemonSet", + APIVersion: appsv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "node-plugin", + }, + }, + ), + }, + } + + t.Run("rejected when not allowed", func(t *testing.T) { + objs, err := generators.BundleAdditionalResourcesGenerator(bundle, render.Options{InstallNamespace: "install-namespace"}) + require.Nil(t, objs) + require.ErrorContains(t, err, "bundle contains unsupported resource: Name: node-plugin, Kind: DaemonSet") + }) + + t.Run("rendered into the install namespace when allowed", func(t *testing.T) { + objs, err := generators.BundleAdditionalResourcesGenerator(bundle, render.Options{InstallNamespace: "install-namespace", AllowDaemonSets: true}) + require.NoError(t, err) + require.Len(t, objs, 1) + require.Equal(t, "node-plugin", objs[0].GetName()) + require.Equal(t, "install-namespace", objs[0].GetNamespace()) + }) +} + func Test_BundleAdditionalResourcesGenerator_FailsOnNil(t *testing.T) { objs, err := generators.BundleAdditionalResourcesGenerator(nil, render.Options{}) require.Nil(t, objs) diff --git a/internal/operator-controller/rukpak/render/registryv1/registryv1_test.go b/internal/operator-controller/rukpak/render/registryv1/registryv1_test.go index d3019d0ccf..41d81ea25a 100644 --- a/internal/operator-controller/rukpak/render/registryv1/registryv1_test.go +++ b/internal/operator-controller/rukpak/render/registryv1/registryv1_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -158,3 +159,37 @@ func Test_Renderer_Failure_UnsupportedKind(t *testing.T) { require.Contains(t, err.Error(), "unsupported resource") require.Empty(t, objs) } + +func Test_Renderer_DaemonSet(t *testing.T) { + someBundle := bundle.RegistryV1{ + PackageName: "my-package", + CSV: csv.Builder(). + WithName("test-bundle"). + WithInstallModeSupportFor(v1alpha1.InstallModeTypeAllNamespaces).Build(), + Others: []unstructured.Unstructured{ + *ToUnstructuredT(t, &appsv1.DaemonSet{ + TypeMeta: metav1.TypeMeta{ + Kind: "DaemonSet", + APIVersion: appsv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "node-plugin", + }, + }), + }, + } + + t.Run("rejected without WithDaemonSetSupport", func(t *testing.T) { + objs, err := registryv1.Renderer.Render(someBundle, render.WithSelfManagedInstallNamespace("install-namespace")) + require.ErrorContains(t, err, "unsupported resource") + require.Empty(t, objs) + }) + + t.Run("rendered with WithDaemonSetSupport", func(t *testing.T) { + objs, err := registryv1.Renderer.Render(someBundle, render.WithSelfManagedInstallNamespace("install-namespace"), render.WithDaemonSetSupport()) + require.NoError(t, err) + require.Len(t, objs, 1) + require.Equal(t, "DaemonSet", objs[0].GetObjectKind().GroupVersionKind().Kind) + require.Equal(t, "install-namespace", objs[0].GetNamespace()) + }) +} diff --git a/internal/operator-controller/rukpak/render/registryv1/validators/validator.go b/internal/operator-controller/rukpak/render/registryv1/validators/validator.go index 4071a98daf..c271db785b 100644 --- a/internal/operator-controller/rukpak/render/registryv1/validators/validator.go +++ b/internal/operator-controller/rukpak/render/registryv1/validators/validator.go @@ -356,6 +356,10 @@ func CheckObjectSupport(rv1 *bundle.RegistryV1) []error { var errs []error for _, obj := range rv1.Others { kind := obj.GetObjectKind().GroupVersionKind().Kind + // DaemonSet support is feature-gated at render time, in BundleAdditionalResourcesGenerator. + if kind == "DaemonSet" { + continue + } if ok, _ := regv1bundle.IsSupported(kind); !ok { errs = append(errs, fmt.Errorf("unsupported resource %q with kind %q", obj.GetName(), kind)) } diff --git a/internal/operator-controller/rukpak/render/registryv1/validators/validator_test.go b/internal/operator-controller/rukpak/render/registryv1/validators/validator_test.go index c8df31e880..0c69d5028b 100644 --- a/internal/operator-controller/rukpak/render/registryv1/validators/validator_test.go +++ b/internal/operator-controller/rukpak/render/registryv1/validators/validator_test.go @@ -1245,6 +1245,15 @@ func Test_CheckObjectSupport(t *testing.T) { errors.New(`unsupported resource "my-job" with kind "Job"`), }, }, + { + name: "leaves DaemonSet to the render-time feature gate", + bundle: &bundle.RegistryV1{ + Others: []unstructured.Unstructured{ + newUnstructuredObject(schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "DaemonSet"}, "my-daemonset"), + }, + }, + expectedErrs: nil, + }, } { t.Run(tc.name, func(t *testing.T) { errs := validators.CheckObjectSupport(tc.bundle) diff --git a/internal/operator-controller/rukpak/render/render.go b/internal/operator-controller/rukpak/render/render.go index 1d532a2c6c..86e5cccb81 100644 --- a/internal/operator-controller/rukpak/render/render.go +++ b/internal/operator-controller/rukpak/render/render.go @@ -77,6 +77,9 @@ type Options struct { // annotation during Render setup and only consulted when SelfManagedInstallNamespace is false. InstallNamespaceLabels map[string]string InstallNamespaceAnnotations map[string]string + + // AllowDaemonSets permits DaemonSet objects in the bundle's manifests/. + AllowDaemonSets bool } func (o *Options) apply(opts ...Option) *Options { @@ -146,6 +149,13 @@ func WithDeploymentConfig(deploymentConfig *config.DeploymentConfig) Option { } } +// WithDaemonSetSupport permits DaemonSet objects in the bundle's manifests/. +func WithDaemonSetSupport() Option { + return func(o *Options) { + o.AllowDaemonSets = true + } +} + type BundleRenderer struct { BundleValidator BundleValidator ResourceGenerators []ResourceGenerator diff --git a/manifests/experimental-e2e.yaml b/manifests/experimental-e2e.yaml index 06a4464d2b..9ea7812dce 100644 --- a/manifests/experimental-e2e.yaml +++ b/manifests/experimental-e2e.yaml @@ -3718,6 +3718,7 @@ spec: - --feature-gates=BoxcutterRuntime=true - --feature-gates=BundleReleaseSupport=true - --feature-gates=DeploymentConfig=true + - --feature-gates=RegistryV1DaemonSetSupport=true - --feature-gates=SingleOwnNamespaceInstallSupport=true - --feature-gates=WebhookProviderCertManager=true - --feature-gates=WebhookProviderOpenshiftServiceCA=false diff --git a/manifests/experimental.yaml b/manifests/experimental.yaml index 1bd3d1e382..59147e1d8e 100644 --- a/manifests/experimental.yaml +++ b/manifests/experimental.yaml @@ -3614,6 +3614,7 @@ spec: - --feature-gates=BoxcutterRuntime=true - --feature-gates=BundleReleaseSupport=true - --feature-gates=DeploymentConfig=true + - --feature-gates=RegistryV1DaemonSetSupport=true - --feature-gates=SingleOwnNamespaceInstallSupport=true - --feature-gates=WebhookProviderCertManager=true - --feature-gates=WebhookProviderOpenshiftServiceCA=false diff --git a/manifests/standard-e2e.yaml b/manifests/standard-e2e.yaml index 28dca6563d..cd00563d66 100644 --- a/manifests/standard-e2e.yaml +++ b/manifests/standard-e2e.yaml @@ -1887,6 +1887,7 @@ spec: - --feature-gates=BoxcutterRuntime=false - --feature-gates=BundleReleaseSupport=false - --feature-gates=DeploymentConfig=false + - --feature-gates=RegistryV1DaemonSetSupport=false - --feature-gates=SingleOwnNamespaceInstallSupport=false - --feature-gates=WebhookProviderOpenshiftServiceCA=false - --tls-cert=/var/certs/tls.crt diff --git a/manifests/standard.yaml b/manifests/standard.yaml index 71c7677772..8f57af69f5 100644 --- a/manifests/standard.yaml +++ b/manifests/standard.yaml @@ -1793,6 +1793,7 @@ spec: - --feature-gates=BoxcutterRuntime=false - --feature-gates=BundleReleaseSupport=false - --feature-gates=DeploymentConfig=false + - --feature-gates=RegistryV1DaemonSetSupport=false - --feature-gates=SingleOwnNamespaceInstallSupport=false - --feature-gates=WebhookProviderOpenshiftServiceCA=false - --tls-cert=/var/certs/tls.crt From 5e54227148d5fcd7c48df7061f3ba4e33467b54e Mon Sep 17 00:00:00 2001 From: Cesar Celis Date: Thu, 22 Oct 2026 17:48:51 -0400 Subject: [PATCH 2/3] Add DaemonSet progression probe Treat a DaemonSet as rolled out when status.updatedNumberScheduled and status.numberAvailable both equal status.desiredNumberScheduled. The object-controller already wraps every progression probe in an observed generation check, so a stale status reports Unknown. Signed-off-by: Cesar Celis --- .../operator-controller/applier/boxcutter.go | 19 +++++ .../boxcutter_daemonset_internal_test.go | 71 +++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 internal/operator-controller/applier/boxcutter_daemonset_internal_test.go diff --git a/internal/operator-controller/applier/boxcutter.go b/internal/operator-controller/applier/boxcutter.go index 0232ed3da7..becc0736d9 100644 --- a/internal/operator-controller/applier/boxcutter.go +++ b/internal/operator-controller/applier/boxcutter.go @@ -734,6 +734,25 @@ var ( Kind: "Deployment", }), ).WithAssertions(replicasUpdatedAssertion, availableConditionAssertion), + // DaemonSet probe; every scheduled pod is updated and available + ocv1ac.ProgressionProbe().WithSelector( + ocv1ac.ObjectSelector().WithType(ocv1.SelectorTypeGroupKind). + WithGroupKind(metav1.GroupKind{ + Group: appsv1.GroupName, + Kind: "DaemonSet", + }), + ).WithAssertions( + ocv1ac.Assertion(). + WithType(ocv1.ProbeTypeFieldsEqual). + WithFieldsEqual(ocv1ac.FieldsEqualProbe(). + WithFieldA("status.updatedNumberScheduled"). + WithFieldB("status.desiredNumberScheduled")), + ocv1ac.Assertion(). + WithType(ocv1.ProbeTypeFieldsEqual). + WithFieldsEqual(ocv1ac.FieldsEqualProbe(). + WithFieldA("status.numberAvailable"). + WithFieldB("status.desiredNumberScheduled")), + ), } // readyConditionAssertion checks that the Type: "Ready" Condition is "True" diff --git a/internal/operator-controller/applier/boxcutter_daemonset_internal_test.go b/internal/operator-controller/applier/boxcutter_daemonset_internal_test.go new file mode 100644 index 0000000000..6e9c1da997 --- /dev/null +++ b/internal/operator-controller/applier/boxcutter_daemonset_internal_test.go @@ -0,0 +1,71 @@ +package applier + +import ( + "testing" + + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "pkg.package-operator.run/boxcutter/probing" + + ocv1 "github.com/operator-framework/operator-controller/api/v1" +) + +// daemonSetProber builds the DaemonSet progression probe the way the object-controller does: +// the assertions joined with And, wrapped in an ObservedGenerationProbe. +func daemonSetProber(t *testing.T) probing.Prober { + t.Helper() + for _, p := range defaultProgressionProbes { + if p.Selector.GroupKind == nil || p.Selector.GroupKind.Kind != "DaemonSet" { + continue + } + require.Equal(t, appsv1.GroupName, p.Selector.GroupKind.Group) + and := probing.And{} + for _, a := range p.Assertions { + require.Equal(t, ocv1.ProbeTypeFieldsEqual, *a.Type) + and = append(and, &probing.FieldsEqualProbe{FieldA: *a.FieldsEqual.FieldA, FieldB: *a.FieldsEqual.FieldB}) + } + return &probing.ObservedGenerationProbe{Prober: and} + } + t.Fatal("no DaemonSet progression probe in defaultProgressionProbes") + return nil +} + +func TestDaemonSetProgressionProbe(t *testing.T) { + prober := daemonSetProber(t) + for _, tc := range []struct { + name string + status appsv1.DaemonSetStatus + want probing.Status + }{ + { + name: "all scheduled pods updated and available", + status: appsv1.DaemonSetStatus{ObservedGeneration: 2, DesiredNumberScheduled: 3, UpdatedNumberScheduled: 3, NumberAvailable: 3}, + want: probing.StatusTrue, + }, + { + name: "rollout in progress", + status: appsv1.DaemonSetStatus{ObservedGeneration: 2, DesiredNumberScheduled: 3, UpdatedNumberScheduled: 1, NumberAvailable: 3}, + want: probing.StatusFalse, + }, + { + name: "pods not yet available", + status: appsv1.DaemonSetStatus{ObservedGeneration: 2, DesiredNumberScheduled: 3, UpdatedNumberScheduled: 3, NumberAvailable: 2}, + want: probing.StatusFalse, + }, + { + name: "status from an older generation", + status: appsv1.DaemonSetStatus{ObservedGeneration: 1, DesiredNumberScheduled: 3, UpdatedNumberScheduled: 3, NumberAvailable: 3}, + want: probing.StatusUnknown, + }, + } { + t.Run(tc.name, func(t *testing.T) { + ds := &appsv1.DaemonSet{ + TypeMeta: metav1.TypeMeta{Kind: "DaemonSet", APIVersion: "apps/v1"}, + ObjectMeta: metav1.ObjectMeta{Name: "node-plugin", Generation: 2}, + Status: tc.status, + } + require.Equal(t, tc.want, prober.Probe(ds).Status) + }) + } +} From a30622f9261426998ed53ad196c4ddb00b0a70f3 Mon Sep 17 00:00:00 2001 From: Cesar Celis Date: Thu, 22 Oct 2026 17:48:51 -0400 Subject: [PATCH 3/3] Add e2e scenarios for DaemonSet support Cover the three cases of the RegistryV1DaemonSetSupport gate: a bundle with a DaemonSet installs and rolls out, a DaemonSet that never becomes available exceeds the progress deadline, and the bundle is rejected when the gate is disabled. Scenarios tagged @=false now run only when that feature gate is disabled. Signed-off-by: Cesar Celis --- test/e2e/features/install.feature | 82 +++++++++++++++++++++++++++++++ test/e2e/steps/hooks.go | 8 +++ test/e2e/steps/steps.go | 4 ++ test/internal/catalog/bundle.go | 48 ++++++++++++++++++ 4 files changed, 142 insertions(+) diff --git a/test/e2e/features/install.feature b/test/e2e/features/install.feature index 3a9ce02224..c607904a42 100644 --- a/test/e2e/features/install.feature +++ b/test/e2e/features/install.feature @@ -592,3 +592,85 @@ Feature: Install ClusterExtension """ And ClusterExtension is rolled out And ClusterExtension is available + + @BoxcutterRuntime + @RegistryV1DaemonSetSupport + Scenario: Install a bundle that ships a DaemonSet + Given a catalog "test" with packages: + | package | version | channel | replaces | contents | + | test | 1.2.0 | beta | | CRD, Deployment, DaemonSet | + When ClusterExtension is applied + """ + apiVersion: olm.operatorframework.io/v1 + kind: ClusterExtension + metadata: + name: ${NAME} + spec: + namespace: ${TEST_NAMESPACE} + source: + sourceType: Catalog + catalog: + packageName: ${PACKAGE:test} + selector: + matchLabels: + "olm.operatorframework.io/metadata.name": ${CATALOG:test} + """ + Then ClusterExtension is rolled out + And ClusterExtension is available + And bundle "${PACKAGE:test}.1.2.0" is installed in version "1.2.0" + And resource "deployment/test-operator-${SCENARIO_ID}" is installed + And resource "daemonset/test-daemonset-${SCENARIO_ID}" is installed + + @RegistryV1DaemonSetSupport=false + Scenario: Report that a bundle with a DaemonSet cannot be installed when DaemonSet support is disabled + Given a catalog "test" with packages: + | package | version | channel | replaces | contents | + | test | 1.2.0 | beta | | CRD, Deployment, DaemonSet | + When ClusterExtension is applied + """ + apiVersion: olm.operatorframework.io/v1 + kind: ClusterExtension + metadata: + name: ${NAME} + spec: + namespace: ${TEST_NAMESPACE} + source: + sourceType: Catalog + catalog: + packageName: ${PACKAGE:test} + selector: + matchLabels: + "olm.operatorframework.io/metadata.name": ${CATALOG:test} + """ + Then ClusterExtension reports Progressing as True with Reason Retrying and Message includes: + """ + bundle contains unsupported resource: Name: test-daemonset-${SCENARIO_ID}, Kind: DaemonSet + """ + + @BoxcutterRuntime + @RegistryV1DaemonSetSupport + Scenario: Report ClusterExtension as not progressing if a DaemonSet does not become available within given timeout + Given a catalog "test" with packages: + | package | version | channel | replaces | contents | + | test | 1.2.0 | beta | | CRD, Deployment, BadDaemonSet | + And min value for ClusterExtension .spec.progressDeadlineMinutes is set to 1 + And min value for ClusterObjectSet .spec.progressDeadlineMinutes is set to 1 + When ClusterExtension is applied + """ + apiVersion: olm.operatorframework.io/v1 + kind: ClusterExtension + metadata: + name: ${NAME} + spec: + namespace: ${TEST_NAMESPACE} + progressDeadlineMinutes: 1 + source: + sourceType: Catalog + catalog: + packageName: ${PACKAGE:test} + selector: + matchLabels: + "olm.operatorframework.io/metadata.name": ${CATALOG:test} + """ + Then ClusterObjectSet "${NAME}-1" reports Ready as False with Reason ProgressDeadlineExceeded + And ClusterExtension reports Progressing as False with Reason ProgressDeadlineExceeded diff --git a/test/e2e/steps/hooks.go b/test/e2e/steps/hooks.go index fb5980c726..0c764dd0a5 100644 --- a/test/e2e/steps/hooks.go +++ b/test/e2e/steps/hooks.go @@ -205,6 +205,14 @@ func BeforeSuite() { func CheckFeatureTags(ctx context.Context, sc *godog.Scenario) (context.Context, error) { for _, tag := range sc.Tags { + // A tag of the form @=false runs the scenario only when that feature gate is disabled. + if gate, ok := strings.CutSuffix(tag.Name[1:], "=false"); ok { + if enabled, found := featureGates[featuregate.Feature(gate)]; found && enabled { + logger.Info(fmt.Sprintf("Skipping scenario %q because feature gate %q is enabled", sc.Name, gate)) + return ctx, godog.ErrSkip + } + continue + } if enabled, found := featureGates[featuregate.Feature(tag.Name[1:])]; found && !enabled { logger.Info(fmt.Sprintf("Skipping scenario %q because feature gate %q is disabled", sc.Name, tag.Name[1:])) return ctx, godog.ErrSkip diff --git a/test/e2e/steps/steps.go b/test/e2e/steps/steps.go index 9c3073cb43..f6c2ade16b 100644 --- a/test/e2e/steps/steps.go +++ b/test/e2e/steps/steps.go @@ -2055,6 +2055,10 @@ func parseContents(contents string) ([]catalog.BundleOption, error) { opts = append(opts, catalog.WithDeployment()) case part == "ConfigMap": opts = append(opts, catalog.WithConfigMap()) + case part == "DaemonSet": + opts = append(opts, catalog.WithDaemonSet()) + case part == "BadDaemonSet": + opts = append(opts, catalog.WithBadDaemonSetImage()) case strings.HasPrefix(part, "Property(") && strings.HasSuffix(part, ")"): // Property(type=value) inner := part[len("Property(") : len(part)-1] diff --git a/test/internal/catalog/bundle.go b/test/internal/catalog/bundle.go index c1eac1ffe7..cfdae12760 100644 --- a/test/internal/catalog/bundle.go +++ b/test/internal/catalog/bundle.go @@ -35,6 +35,8 @@ type bundleConfig struct { hasCRD bool hasDeployment bool hasConfigMap bool + hasDaemonSet bool + badDaemonSetImage bool badContainerImage bool properties []bundleProperty installModes []v1alpha1.InstallModeType // if nil, defaults to AllNamespaces + SingleNamespace @@ -66,6 +68,16 @@ func WithConfigMap() BundleOption { return func(c *bundleConfig) { c.hasConfigMap = true } } +// WithDaemonSet includes a DaemonSet in the bundle. +func WithDaemonSet() BundleOption { + return func(c *bundleConfig) { c.hasDaemonSet = true } +} + +// WithBadDaemonSetImage includes a DaemonSet whose image cannot be pulled, so it never becomes available. +func WithBadDaemonSetImage() BundleOption { + return func(c *bundleConfig) { c.hasDaemonSet, c.badDaemonSetImage = true, true } +} + // WithInstallMode sets the supported install modes for the bundle's CSV. // If not called, defaults to AllNamespaces + SingleNamespace. // Mode names: AllNamespaces, SingleNamespace, OwnNamespace, MultiNamespace. @@ -296,6 +308,42 @@ func buildBundle(scenarioID, packageName, version string, opts []BundleOption) ( }) } + // Add DaemonSet if requested + if cfg.hasDaemonSet { + dsName := fmt.Sprintf("test-daemonset-%s", scenarioID) + dsLabels := map[string]string{"app": dsName} + dsImage := containerImage + if cfg.badDaemonSetImage { + dsImage = "wrong/image" + } + fsBuilder = fsBuilder.WithBundleResource("daemonset.yaml", &appsv1.DaemonSet{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "apps/v1", + Kind: "DaemonSet", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: dsName, + }, + Spec: appsv1.DaemonSetSpec{ + Selector: &metav1.LabelSelector{MatchLabels: dsLabels}, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: dsLabels}, + Spec: corev1.PodSpec{ + TerminationGracePeriodSeconds: ptr.To(int64(0)), + Tolerations: []corev1.Toleration{{Operator: corev1.TolerationOpExists}}, + Containers: []corev1.Container{ + { + Name: "node-agent", + Image: dsImage, + Command: []string{"sleep", "3600"}, + }, + }, + }, + }, + }, + }) + } + // Add CRD as a bundle resource if cfg.hasCRD { fsBuilder = fsBuilder.WithBundleResource("crd.yaml", buildCRD(crdName, crdGroup, crdKind, crdPlural, scenarioID, cfg.largeCRDFieldCount))