diff --git a/api/v1alpha1/seinode_types.go b/api/v1alpha1/seinode_types.go index f1f85682..da02469b 100644 --- a/api/v1alpha1/seinode_types.go +++ b/api/v1alpha1/seinode_types.go @@ -88,12 +88,10 @@ import ( // +kubebuilder:validation:XValidation:rule="!has(self.nodeConfig) || !has(self.validator) || !has(self.validator.genesisCeremony)",message="spec.nodeConfig is not supported on a genesis-ceremony validator: the founding validator set is assembled during the ceremony and written into config.toml at run time" // +kubebuilder:validation:XValidation:rule="!has(self.nodeConfig) || ((!has(self.fullNode) || !has(self.fullNode.snapshot) || !has(self.fullNode.snapshot.stateSync)) && (!has(self.validator) || !has(self.validator.snapshot) || !has(self.validator.snapshot.stateSync)) && (!has(self.replayer) || !has(self.replayer.snapshot.stateSync)))",message="spec.nodeConfig is not supported with a state-sync snapshot source: the trust height and hash are discovered from live witnesses and written into config.toml at run time" // +kubebuilder:validation:XValidation:rule="!has(self.nodeConfig) || !has(self.consensus) || !has(self.consensus.engine) || self.consensus.engine != 'Autobahn'",message="spec.nodeConfig is not supported under consensus engine Autobahn: the engine's config.toml keys are controller-derived" -// nodeConfig may be added to an existing node but never removed (spec 013, -// temporary for the arctic-1 migration, PLT-1410). The StatefulSet's +// nodeConfig is fixed for the node's lifetime. The StatefulSet's // podManagementPolicy follows it, and the API server refuses to change that -// field on an existing StatefulSet, so SyncStatefulSet recreates the -// StatefulSet when the field must change. -// +kubebuilder:validation:XValidation:rule="!has(oldSelf.nodeConfig) || has(self.nodeConfig)",message="spec.nodeConfig cannot be removed from an existing SeiNode: replace the node (dataVolume.import can carry its data over)" +// field on an existing StatefulSet. +// +kubebuilder:validation:XValidation:rule="has(self.nodeConfig) == has(oldSelf.nodeConfig)",message="spec.nodeConfig can be neither added to nor removed from an existing SeiNode: it is fixed at creation, so replace the node (dataVolume.import can carry its data over)" // dataResetGeneration is a request counter: each increase asks for one data // reset. It can only increase, so a git revert cannot lower it and rearm a wipe, // and it needs nodeConfig, because only that node's plans run the reset. diff --git a/config/crd/sei.io_seinodes.yaml b/config/crd/sei.io_seinodes.yaml index 93ce686e..538f1628 100644 --- a/config/crd/sei.io_seinodes.yaml +++ b/config/crd/sei.io_seinodes.yaml @@ -107,11 +107,9 @@ spec: The node shapes below reach config.toml through a task that writes configuration seid can only learn at run time, so they are rejected beside nodeConfig too. - nodeConfig may be added to an existing node but never removed (spec 013, - temporary for the arctic-1 migration, PLT-1410). The StatefulSet's + nodeConfig is fixed for the node's lifetime. The StatefulSet's podManagementPolicy follows it, and the API server refuses to change that - field on an existing StatefulSet, so SyncStatefulSet recreates the - StatefulSet when the field must change. + field on an existing StatefulSet. dataResetGeneration is a request counter: each increase asks for one data reset. It can only increase, so a git revert cannot lower it and rearm a wipe, and it needs nodeConfig, because only that node's plans run the reset. @@ -1504,9 +1502,10 @@ spec: the engine''s config.toml keys are controller-derived' rule: '!has(self.nodeConfig) || !has(self.consensus) || !has(self.consensus.engine) || self.consensus.engine != ''Autobahn''' - - message: 'spec.nodeConfig cannot be removed from an existing SeiNode: - replace the node (dataVolume.import can carry its data over)' - rule: '!has(oldSelf.nodeConfig) || has(self.nodeConfig)' + - message: 'spec.nodeConfig can be neither added to nor removed from an + existing SeiNode: it is fixed at creation, so replace the node (dataVolume.import + can carry its data over)' + rule: has(self.nodeConfig) == has(oldSelf.nodeConfig) - message: 'spec.dataResetGeneration needs spec.nodeConfig: only a node that reads its config from ConfigMaps runs the declarative data reset' rule: '!has(self.dataResetGeneration) || has(self.nodeConfig)' diff --git a/docs/specs/013-nodeconfig-switch/spec.md b/docs/specs/013-nodeconfig-switch/spec.md index 9a5d842e..f457d5fb 100644 --- a/docs/specs/013-nodeconfig-switch/spec.md +++ b/docs/specs/013-nodeconfig-switch/spec.md @@ -4,7 +4,9 @@ **Created**: 2026-10-08 -**Status**: Draft. Temporary: revert after the arctic-1 migration. +**Status**: Reverted. The code shipped in `21ae61d` (#608) and moved 46 arctic-1 nodes in place on +2026-10-08. The revert restores the create-only rule `has(self.nodeConfig) == has(oldSelf.nodeConfig)` +and removes the StatefulSet recreation. A later in-place move needs this change again. **Tracking**: PLT-1410 (atlantic-2 migration) diff --git a/docs/specs/README.md b/docs/specs/README.md index 8a980e2a..035dd2f1 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -25,4 +25,4 @@ not re-litigated later. See `001-configurable-node-resources/decisions.md`. | [010](010-maintenance-hold/spec.md) | Maintenance hold for ConfigMap-configured nodes | Draft | | [011](011-live-resize/spec.md) | Resize CPU, memory, and disk on a live ConfigMap-configured node | Draft | | [012](012-drift-roll-budget/spec.md) | Drift-roll budget: pace pod-template drift per namespace | Draft | -| [013](013-nodeconfig-switch/spec.md) | Switch a running SeiNode to nodeConfig (temporary, arctic-1 migration) | Draft | +| [013](013-nodeconfig-switch/spec.md) | Switch a running SeiNode to nodeConfig (temporary, arctic-1 migration) | Reverted | diff --git a/internal/controller/node/envtest/nodeconfig_switch_test.go b/internal/controller/node/envtest/nodeconfig_switch_test.go deleted file mode 100644 index d4c80f0a..00000000 --- a/internal/controller/node/envtest/nodeconfig_switch_test.go +++ /dev/null @@ -1,83 +0,0 @@ -//go:build envtest - -package envtest_test - -import ( - "slices" - "testing" - - . "github.com/onsi/gomega" - appsv1 "k8s.io/api/apps/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - "sigs.k8s.io/controller-runtime/pkg/client" - - seiv1alpha1 "github.com/sei-protocol/sei-k8s-controller/api/v1alpha1" - "github.com/sei-protocol/sei-k8s-controller/internal/noderesource" - "github.com/sei-protocol/sei-k8s-controller/internal/platform/platformtest" -) - -// Spec 013 Req 1.2 against a real API server: the API server refuses a -// podManagementPolicy change in place, so the switch deletes the StatefulSet -// with orphan propagation, waits while it terminates, and then applies the -// Parallel StatefulSet. envtest runs no garbage collector, so the test plays -// its part and releases the orphan finalizer. -func TestNodeConfig_SwitchRecreatesStatefulSet(t *testing.T) { - g := NewWithT(t) - ns := makeNamespace(t) - platform := platformtest.Config() - - node := nodeConfigNode(ns, "nc-live-switch") - node.Spec.NodeConfig = nil - g.Expect(testCli.Create(testCtx, node)).To(Succeed()) - - sts, err := noderesource.SyncStatefulSet(testCtx, testCli, testCli.Scheme(), node, platform) - g.Expect(err).NotTo(HaveOccurred()) - g.Expect(sts.Spec.PodManagementPolicy).To(Equal(appsv1.OrderedReadyPodManagement)) - ref := &seiv1alpha1.StatefulSetRef{Name: sts.Name, UID: sts.UID} - - inPlace := sts.DeepCopy() - inPlace.Spec.PodManagementPolicy = appsv1.ParallelPodManagement - err = testCli.Update(testCtx, inPlace) - g.Expect(apierrors.IsInvalid(err)).To(BeTrue(), "the API server must refuse the change in place; got %v", err) - - key := client.ObjectKeyFromObject(node) - g.Expect(updateNodeWithRetry(t, key, func(cur *seiv1alpha1.SeiNode) { - cur.Spec.NodeConfig = &seiv1alpha1.NodeConfig{ - ConfigRef: seiv1alpha1.ConfigFileRef{Name: "rpc-config-v1"}, - AppRef: seiv1alpha1.ConfigFileRef{Name: "rpc-app-v1"}, - } - })).To(Succeed()) - g.Expect(testCli.Get(testCtx, key, node)).To(Succeed()) - node.Status.StatefulSet = ref - - sts, err = noderesource.SyncStatefulSet(testCtx, testCli, testCli.Scheme(), node, platform) - g.Expect(err).NotTo(HaveOccurred()) - g.Expect(sts).To(BeNil()) - g.Expect(node.Status.StatefulSet).To(BeNil()) - - old := &appsv1.StatefulSet{} - g.Expect(testCli.Get(testCtx, client.ObjectKey{Namespace: ns, Name: ref.Name}, old)).To(Succeed()) - g.Expect(old.DeletionTimestamp).NotTo(BeNil()) - g.Expect(slices.Contains(old.Finalizers, "orphan")).To(BeTrue(), "the delete must orphan the pod; finalizers=%v", old.Finalizers) - - sts, err = noderesource.SyncStatefulSet(testCtx, testCli, testCli.Scheme(), node, platform) - g.Expect(err).NotTo(HaveOccurred()) - g.Expect(sts).To(BeNil(), "the sync must wait while the old StatefulSet terminates") - - old.Finalizers = nil - g.Expect(testCli.Update(testCtx, old)).To(Succeed()) - - g.Eventually(func() error { - sts, err = noderesource.SyncStatefulSet(testCtx, testCli, testCli.Scheme(), node, platform) - if err != nil { - return err - } - if sts == nil { - return apierrors.NewNotFound(appsv1.Resource("statefulsets"), ref.Name) - } - return nil - }).Should(Succeed()) - g.Expect(sts.UID).NotTo(Equal(ref.UID)) - g.Expect(sts.Spec.PodManagementPolicy).To(Equal(appsv1.ParallelPodManagement)) - g.Expect(sts.Spec.UpdateStrategy.Type).To(Equal(appsv1.RollingUpdateStatefulSetStrategyType)) -} diff --git a/internal/controller/node/envtest/nodeconfig_validation_test.go b/internal/controller/node/envtest/nodeconfig_validation_test.go index d6099fb2..4bc8b6c7 100644 --- a/internal/controller/node/envtest/nodeconfig_validation_test.go +++ b/internal/controller/node/envtest/nodeconfig_validation_test.go @@ -115,9 +115,10 @@ func TestNodeConfig_WithControllerManagedConfig_Rejected(t *testing.T) { } } -// Spec 013 Req 1.1: nodeConfig may be added to an existing node but never -// removed. Republishing under a new ConfigMap name stays allowed. -func TestNodeConfig_AddOnly(t *testing.T) { +// nodeConfig is fixed at creation in both directions: the StatefulSet's +// podManagementPolicy follows it, and that field cannot change on an existing +// StatefulSet. Republishing under a new ConfigMap name stays allowed. +func TestNodeConfig_CreateOnly(t *testing.T) { g := NewWithT(t) ns := makeNamespace(t) @@ -133,18 +134,20 @@ func TestNodeConfig_AddOnly(t *testing.T) { cur.Spec.NodeConfig = nil }) g.Expect(err).To(HaveOccurred(), "removing nodeConfig must be rejected") - g.Expect(err.Error()).To(ContainSubstring("cannot be removed")) + g.Expect(err.Error()).To(ContainSubstring("fixed at creation")) - without := nodeConfigNode(ns, "nc-switch") + without := nodeConfigNode(ns, "nc-never") without.Spec.NodeConfig = nil g.Expect(testCli.Create(testCtx, without)).To(Succeed()) - g.Expect(updateNodeWithRetry(t, client.ObjectKeyFromObject(without), func(cur *seiv1alpha1.SeiNode) { + err = updateNodeWithRetry(t, client.ObjectKeyFromObject(without), func(cur *seiv1alpha1.SeiNode) { cur.Spec.NodeConfig = &seiv1alpha1.NodeConfig{ ConfigRef: seiv1alpha1.ConfigFileRef{Name: "rpc-config-v1"}, AppRef: seiv1alpha1.ConfigFileRef{Name: "rpc-app-v1"}, } - })).To(Succeed(), "adding nodeConfig must be accepted") + }) + g.Expect(err).To(HaveOccurred(), "adding nodeConfig must be rejected") + g.Expect(err.Error()).To(ContainSubstring("fixed at creation")) } // These node shapes write config.toml at run time with values no ConfigMap diff --git a/internal/noderesource/noderesource.go b/internal/noderesource/noderesource.go index 64ca297d..e95a4f8e 100644 --- a/internal/noderesource/noderesource.go +++ b/internal/noderesource/noderesource.go @@ -718,9 +718,8 @@ func updateStrategy(node *seiv1alpha1.SeiNode) appsv1.StatefulSetUpdateStrategy // podManagementPolicy is Parallel for a RollingUpdate node. Under OrderedReady // the StatefulSet controller will not update a pod that is not Ready, so a seid // halted at an upgrade height would never take the new image. The API server -// refuses to change this field on an existing StatefulSet, so SyncStatefulSet -// recreates the StatefulSet when a node gains spec.nodeConfig (spec 013). -// Empty leaves the API default. +// refuses to change this field on an existing StatefulSet, which is why +// spec.nodeConfig is fixed at creation. Empty leaves the API default. func podManagementPolicy(node *seiv1alpha1.SeiNode) appsv1.PodManagementPolicyType { if node.Spec.NodeConfig != nil { return appsv1.ParallelPodManagement diff --git a/internal/noderesource/sync.go b/internal/noderesource/sync.go index b239f037..b223903a 100644 --- a/internal/noderesource/sync.go +++ b/internal/noderesource/sync.go @@ -6,7 +6,6 @@ import ( appsv1 "k8s.io/api/apps/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" @@ -103,40 +102,11 @@ func SyncStatefulSet( node.Status.StatefulSet = nil return nil, nil } - // A node that gained spec.nodeConfig needs a different - // podManagementPolicy, which the API server refuses to change - // in place (spec 013). Delete the StatefulSet with orphan - // propagation: the running pod and the data PVC stay, the next - // reconcile applies the new StatefulSet, which adopts the pod - // and rolls it once onto the nodeConfig template. - if effectivePodManagementPolicy(existing.Spec.PodManagementPolicy) != effectivePodManagementPolicy(desired.Spec.PodManagementPolicy) { - if err := c.Delete(ctx, existing, client.PropagationPolicy(metav1.DeletePropagationOrphan)); err != nil && !apierrors.IsNotFound(err) { - return nil, fmt.Errorf("deleting statefulset to change its pod management policy: %w", err) - } - node.Status.StatefulSet = nil - return nil, nil - } case apierrors.IsNotFound(err): // Live object missing; the Apply below recreates it. default: return nil, fmt.Errorf("fetching tracked statefulset: %w", err) } - } else { - // An orphan delete leaves the StatefulSet terminating until the - // garbage collector releases its pods. Wait for it to go: an Apply - // now would patch the terminating object, and the API server would - // reject the policy change. Its delete event triggers the next - // reconcile. - existing := &appsv1.StatefulSet{} - switch err := c.Get(ctx, key, existing); { - case err == nil: - if existing.DeletionTimestamp != nil { - return nil, nil - } - case apierrors.IsNotFound(err): - default: - return nil, fmt.Errorf("fetching statefulset: %w", err) - } } // client.Apply decodes the apiserver response into desired in-place, @@ -150,12 +120,3 @@ func SyncStatefulSet( } return desired, nil } - -// effectivePodManagementPolicy resolves an empty policy to the API default, so -// a rendered "" compares equal to the live OrderedReady. -func effectivePodManagementPolicy(p appsv1.PodManagementPolicyType) appsv1.PodManagementPolicyType { - if p == "" { - return appsv1.OrderedReadyPodManagement - } - return p -} diff --git a/internal/noderesource/sync_switch_test.go b/internal/noderesource/sync_switch_test.go deleted file mode 100644 index 232a0cdb..00000000 --- a/internal/noderesource/sync_switch_test.go +++ /dev/null @@ -1,141 +0,0 @@ -package noderesource - -import ( - "context" - "testing" - - . "github.com/onsi/gomega" - appsv1 "k8s.io/api/apps/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/client/fake" - "sigs.k8s.io/controller-runtime/pkg/client/interceptor" - - seiv1alpha1 "github.com/sei-protocol/sei-k8s-controller/api/v1alpha1" - "github.com/sei-protocol/sei-k8s-controller/internal/platform/platformtest" -) - -const switchName = "nc-switch" - -func switchedNode() *seiv1alpha1.SeiNode { - node := newSnapshotNode(switchName, testNamespace) - node.Spec.FullNode.Snapshot = nil - node.Spec.NodeConfig = &seiv1alpha1.NodeConfig{ - ConfigRef: seiv1alpha1.ConfigFileRef{Name: "nc-switch-config-v1"}, - AppRef: seiv1alpha1.ConfigFileRef{Name: "nc-switch-config-v1"}, - } - node.Status.StatefulSet = &seiv1alpha1.StatefulSetRef{Name: switchName, UID: originalTestUID} - return node -} - -func liveStatefulSet(policy appsv1.PodManagementPolicyType) *appsv1.StatefulSet { - return &appsv1.StatefulSet{ - ObjectMeta: metav1.ObjectMeta{Name: switchName, Namespace: testNamespace, UID: originalTestUID}, - Spec: appsv1.StatefulSetSpec{PodManagementPolicy: policy}, - } -} - -// Spec 013 Req 1.2: a node that gained spec.nodeConfig gets its StatefulSet -// deleted with orphan propagation, so the pod and the data PVC stay; the next -// sync applies the Parallel StatefulSet. -func TestSyncStatefulSet_NodeConfigSwitchRecreatesWithOrphan(t *testing.T) { - g := NewWithT(t) - s := newSyncTestScheme(t) - node := switchedNode() - - var propagation *metav1.DeletionPropagation - c := fake.NewClientBuilder(). - WithScheme(s). - WithObjects(node, liveStatefulSet(appsv1.OrderedReadyPodManagement)). - WithStatusSubresource(&seiv1alpha1.SeiNode{}). - WithInterceptorFuncs(interceptor.Funcs{ - Delete: func(ctx context.Context, cl client.WithWatch, obj client.Object, opts ...client.DeleteOption) error { - o := &client.DeleteOptions{} - o.ApplyOptions(opts) - propagation = o.PropagationPolicy - return cl.Delete(ctx, obj, opts...) - }, - }). - Build() - - sts, err := SyncStatefulSet(context.Background(), c, s, node, platformtest.Config()) - g.Expect(err).NotTo(HaveOccurred()) - g.Expect(sts).To(BeNil(), "the switch returns nil so the next reconcile applies") - g.Expect(node.Status.StatefulSet).To(BeNil()) - g.Expect(propagation).NotTo(BeNil()) - g.Expect(*propagation).To(Equal(metav1.DeletePropagationOrphan)) - - live := &appsv1.StatefulSet{} - err = c.Get(context.Background(), types.NamespacedName{Name: switchName, Namespace: testNamespace}, live) - g.Expect(apierrors.IsNotFound(err)).To(BeTrue(), "the old StatefulSet must be gone; got err=%v", err) - - sts, err = SyncStatefulSet(context.Background(), c, s, node, platformtest.Config()) - g.Expect(err).NotTo(HaveOccurred()) - g.Expect(sts).NotTo(BeNil()) - g.Expect(sts.Spec.PodManagementPolicy).To(Equal(appsv1.ParallelPodManagement)) - g.Expect(sts.Spec.UpdateStrategy.Type).To(Equal(appsv1.RollingUpdateStatefulSetStrategyType)) -} - -// Spec 013 Req 1.3: an unchanged policy leaves the StatefulSet in place. A -// rendered empty policy equals the live API default. -func TestSyncStatefulSet_SamePolicyKeepsStatefulSet(t *testing.T) { - cases := []struct { - name string - node func() *seiv1alpha1.SeiNode - live appsv1.PodManagementPolicyType - }{ - {"nodeConfig node on Parallel", switchedNode, appsv1.ParallelPodManagement}, - {"controller-configured node on the API default", func() *seiv1alpha1.SeiNode { - n := switchedNode() - n.Spec.NodeConfig = nil - return n - }, appsv1.OrderedReadyPodManagement}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - g := NewWithT(t) - s := newSyncTestScheme(t) - node := tc.node() - c := fake.NewClientBuilder(). - WithScheme(s). - WithObjects(node, liveStatefulSet(tc.live)). - WithStatusSubresource(&seiv1alpha1.SeiNode{}). - Build() - - sts, err := SyncStatefulSet(context.Background(), c, s, node, platformtest.Config()) - g.Expect(err).NotTo(HaveOccurred()) - g.Expect(sts).NotTo(BeNil()) - g.Expect(sts.UID).To(Equal(types.UID(originalTestUID)), "no delete may occur") - }) - } -} - -// Spec 013 Req 1.4: while the orphaned StatefulSet terminates, the sync waits -// instead of applying over it. -func TestSyncStatefulSet_WaitsForTerminatingStatefulSet(t *testing.T) { - g := NewWithT(t) - s := newSyncTestScheme(t) - node := switchedNode() - node.Status.StatefulSet = nil - - terminating := liveStatefulSet(appsv1.OrderedReadyPodManagement) - now := metav1.Now() - terminating.DeletionTimestamp = &now - terminating.Finalizers = []string{metav1.FinalizerOrphanDependents} - - c := fake.NewClientBuilder(). - WithScheme(s). - WithObjects(node, terminating). - WithStatusSubresource(&seiv1alpha1.SeiNode{}). - Build() - - sts, err := SyncStatefulSet(context.Background(), c, s, node, platformtest.Config()) - g.Expect(err).NotTo(HaveOccurred()) - g.Expect(sts).To(BeNil()) - - live := &appsv1.StatefulSet{} - g.Expect(c.Get(context.Background(), types.NamespacedName{Name: switchName, Namespace: testNamespace}, live)).To(Succeed()) - g.Expect(live.Spec.PodManagementPolicy).To(Equal(appsv1.OrderedReadyPodManagement), "no apply may patch the terminating object") -} diff --git a/manifests/sei.io_seinodes.yaml b/manifests/sei.io_seinodes.yaml index 93ce686e..538f1628 100644 --- a/manifests/sei.io_seinodes.yaml +++ b/manifests/sei.io_seinodes.yaml @@ -107,11 +107,9 @@ spec: The node shapes below reach config.toml through a task that writes configuration seid can only learn at run time, so they are rejected beside nodeConfig too. - nodeConfig may be added to an existing node but never removed (spec 013, - temporary for the arctic-1 migration, PLT-1410). The StatefulSet's + nodeConfig is fixed for the node's lifetime. The StatefulSet's podManagementPolicy follows it, and the API server refuses to change that - field on an existing StatefulSet, so SyncStatefulSet recreates the - StatefulSet when the field must change. + field on an existing StatefulSet. dataResetGeneration is a request counter: each increase asks for one data reset. It can only increase, so a git revert cannot lower it and rearm a wipe, and it needs nodeConfig, because only that node's plans run the reset. @@ -1504,9 +1502,10 @@ spec: the engine''s config.toml keys are controller-derived' rule: '!has(self.nodeConfig) || !has(self.consensus) || !has(self.consensus.engine) || self.consensus.engine != ''Autobahn''' - - message: 'spec.nodeConfig cannot be removed from an existing SeiNode: - replace the node (dataVolume.import can carry its data over)' - rule: '!has(oldSelf.nodeConfig) || has(self.nodeConfig)' + - message: 'spec.nodeConfig can be neither added to nor removed from an + existing SeiNode: it is fixed at creation, so replace the node (dataVolume.import + can carry its data over)' + rule: has(self.nodeConfig) == has(oldSelf.nodeConfig) - message: 'spec.dataResetGeneration needs spec.nodeConfig: only a node that reads its config from ConfigMaps runs the declarative data reset' rule: '!has(self.dataResetGeneration) || has(self.nodeConfig)'