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/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) + }) + } +} 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 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))