Skip to content
Closed
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
1 change: 1 addition & 0 deletions cmd/operator-controller/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
48 changes: 48 additions & 0 deletions docs/draft/howto/daemonset-support.md
Original file line number Diff line number Diff line change
@@ -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: <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
```
1 change: 1 addition & 0 deletions helm/experimental.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ options:
- BoxcutterRuntime
- BundleReleaseSupport
- DeploymentConfig
- RegistryV1DaemonSetSupport
- SingleOwnNamespaceInstallSupport
- WebhookProviderCertManager
disabled:
Expand Down
1 change: 1 addition & 0 deletions helm/olmv1/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ options:
- BoxcutterRuntime
- BundleReleaseSupport
- DeploymentConfig
- RegistryV1DaemonSetSupport
- SingleOwnNamespaceInstallSupport
- WebhookProviderOpenshiftServiceCA
podDisruptionBudget:
Expand Down
19 changes: 19 additions & 0 deletions internal/operator-controller/applier/boxcutter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
4 changes: 4 additions & 0 deletions internal/operator-controller/applier/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions internal/operator-controller/applier/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down
16 changes: 16 additions & 0 deletions internal/operator-controller/features/features.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down
32 changes: 32 additions & 0 deletions internal/operator-controller/features/features_test.go
Original file line number Diff line number Diff line change
@@ -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))
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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())
})
}
Loading
Loading