From ada6a43e75987a183b268c697d0c797c77716a0e Mon Sep 17 00:00:00 2001 From: Andrew Kenworthy Date: Mon, 24 Aug 2026 09:55:54 +0200 Subject: [PATCH 1/6] add early-exit and complete owns list --- Cargo.lock | 1 + Cargo.nix | 4 + Cargo.toml | 1 + .../templates/clusterrole-operator.yaml | 12 +- rust/operator-binary/Cargo.toml | 1 + rust/operator-binary/src/hdfs_controller.rs | 107 +++++++++++++++++- rust/operator-binary/src/main.rs | 22 +++- 7 files changed, 139 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 56e5c5e4..b8868338 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3186,6 +3186,7 @@ dependencies = [ "clap", "const_format", "futures 0.3.34", + "http", "indoc", "rstest", "serde", diff --git a/Cargo.nix b/Cargo.nix index b435a013..25b1ae79 100644 --- a/Cargo.nix +++ b/Cargo.nix @@ -10513,6 +10513,10 @@ rec { } ]; devDependencies = [ + { + name = "http"; + packageId = "http"; + } { name = "rstest"; packageId = "rstest"; diff --git a/Cargo.toml b/Cargo.toml index 6554520e..60abd7da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ built = { version = "0.8", features = ["chrono", "git2"] } clap = "4.6" const_format = "0.2" futures = { version = "0.3", features = ["compat"] } +http = "1.3" indoc = "2.0" rstest = "0.26" semver = "1.0" diff --git a/deploy/helm/hdfs-operator/templates/clusterrole-operator.yaml b/deploy/helm/hdfs-operator/templates/clusterrole-operator.yaml index 6de12303..0955fc81 100644 --- a/deploy/helm/hdfs-operator/templates/clusterrole-operator.yaml +++ b/deploy/helm/hdfs-operator/templates/clusterrole-operator.yaml @@ -28,7 +28,8 @@ rules: - list - patch - watch - # serviceaccounts are applied via SSA and tracked for orphan cleanup. + # serviceaccounts are applied via SSA and tracked for orphan cleanup + # and watched by the controller. - apiGroups: - "" resources: @@ -39,7 +40,9 @@ rules: - get - list - patch - # rolebindings are applied via SSA and tracked for orphan cleanup. + - watch + # rolebindings are applied via SSA and tracked for orphan cleanup + # and watched by the controller. - apiGroups: - rbac.authorization.k8s.io resources: @@ -50,6 +53,7 @@ rules: - get - list - patch + - watch # statefulsets are applied via SSA, tracked for orphan cleanup. - apiGroups: - apps @@ -62,7 +66,8 @@ rules: - list - patch - watch - # poddisruptionbudgets are applied via SSA and tracked for orphan cleanup. + # poddisruptionbudgets are applied via SSA and tracked for orphan cleanup + # and watched by the controller. - apiGroups: - policy resources: @@ -73,6 +78,7 @@ rules: - get - list - patch + - watch # Required for maintaining the CRDs within the operator (including the conversion webhook info). # Also for the startup condition check before the controller can run. - apiGroups: diff --git a/rust/operator-binary/Cargo.toml b/rust/operator-binary/Cargo.toml index 1de297f6..5ad256e0 100644 --- a/rust/operator-binary/Cargo.toml +++ b/rust/operator-binary/Cargo.toml @@ -25,6 +25,7 @@ tracing-futures.workspace = true tracing.workspace = true [dev-dependencies] +http.workspace = true rstest.workspace = true serde_yaml.workspace = true diff --git a/rust/operator-binary/src/hdfs_controller.rs b/rust/operator-binary/src/hdfs_controller.rs index 10b9aec2..9dcc9c4f 100644 --- a/rust/operator-binary/src/hdfs_controller.rs +++ b/rust/operator-binary/src/hdfs_controller.rs @@ -84,6 +84,10 @@ pub async fn reconcile_hdfs( ) -> HdfsOperatorResult { tracing::info!("Starting reconcile"); + if hdfs.meta().deletion_timestamp.is_some() { + return Ok(Action::await_change()); + } + let hdfs = hdfs .0 .as_ref() @@ -160,13 +164,25 @@ mod test { use std::str::FromStr; use stackable_operator::{ - builder::pod::PodBuilder, commons::networking::DomainName, kube::api::ObjectMeta, - kvp::Labels, utils::cluster_info::KubernetesClusterInfo, + builder::pod::PodBuilder, + client::Client, + commons::networking::DomainName, + kube::{ + Client as KubeClient, Config, + api::ObjectMeta, + runtime::{ + controller::Action, + events::{Recorder, Reporter}, + }, + }, + kvp::Labels, + utils::cluster_info::KubernetesClusterInfo, v2::types::operator::RoleGroupName, }; use super::*; use crate::{ + HDFS_FULL_CONTROLLER_NAME, controller::build::container::ContainerConfig, test_support::{deserialize_cluster, role_group_config, validate_cluster}, }; @@ -261,4 +277,91 @@ spec: Some("group-value".to_string()) ); } + + /// A [`Ctx`] whose client points at a closed port. Any API call made through it fails the + /// reconciliation, so an `Ok` result proves the reconciler returned before touching the + /// Kubernetes API. + fn unreachable_ctx() -> Arc { + let config = Config::new( + "http://127.0.0.1:1" + .parse::() + .expect("valid static URI"), + ); + let kube_client = KubeClient::try_from(config).expect("client from static config"); + + Arc::new(Ctx { + client: Client::new( + kube_client.clone(), + None, + "default".to_owned(), + KubernetesClusterInfo { + cluster_domain: DomainName::from_str("cluster.local") + .expect("valid cluster domain"), + }, + ), + operator_environment: OperatorEnvironmentOptions { + operator_namespace: "stackable-operators".to_owned(), + operator_service_name: "hdfs-operator".to_owned(), + image_repository: "oci.stackable.tech/sdp".to_owned(), + }, + event_recorder: Arc::new(Recorder::new( + kube_client, + Reporter { + controller: HDFS_FULL_CONTROLLER_NAME.to_string(), + instance: None, + }, + )), + }) + } + + fn reconcile(hdfs: DeserializeGuard) -> Result { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current-thread tokio runtime") + .block_on(async { reconcile_hdfs(Arc::new(hdfs), unreachable_ctx()).await }) + } + + #[test] + fn reconcile_exits_early_for_deleted_cluster() { + let hdfs = serde_yaml::from_str( + r#" +apiVersion: hdfs.stackable.tech/v1alpha1 +kind: HdfsCluster +metadata: + name: hdfs + namespace: default + deletionTimestamp: "2026-08-14T12:00:00Z" +spec: + image: + productVersion: 3.2.2 +"#, + ) + .expect("valid cluster YAML"); + + let action = reconcile(hdfs).expect("a deleted cluster reconciles without any API call"); + + assert_eq!(action, Action::await_change()); + } + + #[test] + fn reconcile_exits_early_for_deleted_cluster_with_invalid_spec() { + let hdfs = serde_yaml::from_str( + r#" +apiVersion: hdfs.stackable.tech/v1alpha1 +kind: HdfsCluster +metadata: + name: hdfs + namespace: default + deletionTimestamp: "2026-08-14T12:00:00Z" +spec: {} +"#, + ) + .expect("YAML parses; the invalid spec is captured inside the DeserializeGuard"); + + let action = + reconcile(hdfs).expect("a deleted cluster reconciles even when its spec is invalid"); + + assert_eq!(action, Action::await_change()); + } } diff --git a/rust/operator-binary/src/main.rs b/rust/operator-binary/src/main.rs index 4ce582a0..1c2ee474 100644 --- a/rust/operator-binary/src/main.rs +++ b/rust/operator-binary/src/main.rs @@ -17,7 +17,9 @@ use stackable_operator::{ eos::EndOfSupportChecker, k8s_openapi::api::{ apps::v1::StatefulSet, - core::v1::{ConfigMap, Service}, + core::v1::{ConfigMap, Service, ServiceAccount}, + policy::v1::PodDisruptionBudget, + rbac::v1::RoleBinding, }, kube::{ Api, CustomResourceExt, ResourceExt, @@ -156,15 +158,27 @@ async fn main() -> anyhow::Result<()> { let hdfs_cluster_store = hdfs_controller.store(); let hdfs_controller = hdfs_controller .owns( - watch_namespace.get_api::>(&client), + watch_namespace.get_api::(&client), watcher::Config::default(), ) .owns( - watch_namespace.get_api::>(&client), + watch_namespace.get_api::(&client), watcher::Config::default(), ) .owns( - watch_namespace.get_api::>(&client), + watch_namespace.get_api::(&client), + watcher::Config::default(), + ) + .owns( + watch_namespace.get_api::(&client), + watcher::Config::default(), + ) + .owns( + watch_namespace.get_api::(&client), + watcher::Config::default(), + ) + .owns( + watch_namespace.get_api::(&client), watcher::Config::default(), ) .watches( From e5cda5ca2cb07b3d67f6fbfa4a2600305ea538df Mon Sep 17 00:00:00 2001 From: Andrew Kenworthy Date: Mon, 24 Aug 2026 10:04:58 +0200 Subject: [PATCH 2/6] added early-exit unit test --- rust/operator-binary/src/hdfs_controller.rs | 26 +++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/rust/operator-binary/src/hdfs_controller.rs b/rust/operator-binary/src/hdfs_controller.rs index 9dcc9c4f..12c3ab11 100644 --- a/rust/operator-binary/src/hdfs_controller.rs +++ b/rust/operator-binary/src/hdfs_controller.rs @@ -364,4 +364,30 @@ spec: {} assert_eq!(action, Action::await_change()); } + + #[test] + fn reconcile_proceeds_for_live_cluster() { + let hdfs = serde_yaml::from_str( + r#" +apiVersion: hdfs.stackable.tech/v1alpha1 +kind: HdfsCluster +metadata: + name: hdfs + namespace: default +spec: + image: + productVersion: 3.5.0 + clusterConfig: + zookeeperConfigMapName: simple-znode +"#, + ) + .expect("valid HbaseCluster YAML"); + + let result = reconcile(hdfs); + + assert!( + matches!(result, Err(Error::Dereference { .. })), + "a live cluster must reach the API but when dereferencing against the unreachable test server: {result:?}" + ); + } } From a5737d92ddc324d9f56c577a1f69d2746b99a98c Mon Sep 17 00:00:00 2001 From: Andrew Kenworthy Date: Mon, 24 Aug 2026 10:05:46 +0200 Subject: [PATCH 3/6] changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04b63093..a39fdf1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,8 @@ All notable changes to this project will be documented in this file. - Fix a longstanding problem of including empty `categories`, `shortNames` and `additionalPrinterColumns` in the CRDs, which could cause problems with GitOps tools (e.g. ArgoCD) reporting a diff in the custom resources. See [our internal issue](https://github.com/stackabletech/hdfs-operator/issues/626) and [the fix](https://github.com/kube-rs/kube/pull/2042) for details ([#814]). +- The operator now watches all resources that it creates and early-exits the reconcile action when the + cluster is marked for deletion ([#821]). [#801]: https://github.com/stackabletech/hdfs-operator/pull/801 [#806]: https://github.com/stackabletech/hdfs-operator/pull/806 @@ -36,6 +38,7 @@ All notable changes to this project will be documented in this file. [#811]: https://github.com/stackabletech/hdfs-operator/pull/811 [#814]: https://github.com/stackabletech/hdfs-operator/pull/814 [#819]: https://github.com/stackabletech/hdfs-operator/pull/819 +[#821]: https://github.com/stackabletech/hdfs-operator/pull/821 ## [26.7.0] - 2026-07-21 From e35431f37a22974429f9d4614d280d7d4ddff5d0 Mon Sep 17 00:00:00 2001 From: Andrew Kenworthy Date: Mon, 24 Aug 2026 10:13:56 +0200 Subject: [PATCH 4/6] typo --- rust/operator-binary/src/hdfs_controller.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/operator-binary/src/hdfs_controller.rs b/rust/operator-binary/src/hdfs_controller.rs index 12c3ab11..c4e29a0d 100644 --- a/rust/operator-binary/src/hdfs_controller.rs +++ b/rust/operator-binary/src/hdfs_controller.rs @@ -381,7 +381,7 @@ spec: zookeeperConfigMapName: simple-znode "#, ) - .expect("valid HbaseCluster YAML"); + .expect("valid cluster YAML"); let result = reconcile(hdfs); From 34cbb7328c035021fa2f5a8ec293bda5f3a0676c Mon Sep 17 00:00:00 2001 From: Andrew Kenworthy Date: Mon, 24 Aug 2026 11:26:21 +0200 Subject: [PATCH 5/6] add kuttl test step --- .../kuttl/cluster-operation/60-assert.yaml | 170 ++++++++++++++++++ .../60-delete-owned-resources.yaml | 60 +++++++ 2 files changed, 230 insertions(+) create mode 100644 tests/templates/kuttl/cluster-operation/60-assert.yaml create mode 100644 tests/templates/kuttl/cluster-operation/60-delete-owned-resources.yaml diff --git a/tests/templates/kuttl/cluster-operation/60-assert.yaml b/tests/templates/kuttl/cluster-operation/60-assert.yaml new file mode 100644 index 00000000..d2247480 --- /dev/null +++ b/tests/templates/kuttl/cluster-operation/60-assert.yaml @@ -0,0 +1,170 @@ +--- +# The recreated StatefulSets must bring the cluster back to ready, and the recreated +# objects must carry an owner reference back to the HdfsCluster so that garbage +# collection still works for them. +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +metadata: + name: recreate-owned-resources +timeout: 600 +commands: + - script: kubectl -n $NAMESPACE wait --for=condition=available hdfsclusters.hdfs.stackable.tech/hdfs --timeout 601s +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: hdfs-namenode-default + ownerReferences: + - apiVersion: hdfs.stackable.tech/v1alpha1 + controller: true + kind: HdfsCluster + name: hdfs +status: + readyReplicas: 2 + replicas: 2 +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: hdfs-datanode-default + ownerReferences: + - apiVersion: hdfs.stackable.tech/v1alpha1 + controller: true + kind: HdfsCluster + name: hdfs +status: + readyReplicas: 1 + replicas: 1 +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: hdfs-journalnode-default + ownerReferences: + - apiVersion: hdfs.stackable.tech/v1alpha1 + controller: true + kind: HdfsCluster + name: hdfs +status: + readyReplicas: 1 + replicas: 1 +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: hdfs-serviceaccount + ownerReferences: + - apiVersion: hdfs.stackable.tech/v1alpha1 + controller: true + kind: HdfsCluster + name: hdfs +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: hdfs-rolebinding + ownerReferences: + - apiVersion: hdfs.stackable.tech/v1alpha1 + controller: true + kind: HdfsCluster + name: hdfs +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: hdfs-namenode + ownerReferences: + - apiVersion: hdfs.stackable.tech/v1alpha1 + controller: true + kind: HdfsCluster + name: hdfs +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: hdfs-datanode + ownerReferences: + - apiVersion: hdfs.stackable.tech/v1alpha1 + controller: true + kind: HdfsCluster + name: hdfs +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: hdfs-journalnode + ownerReferences: + - apiVersion: hdfs.stackable.tech/v1alpha1 + controller: true + kind: HdfsCluster + name: hdfs +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: hdfs + ownerReferences: + - apiVersion: hdfs.stackable.tech/v1alpha1 + controller: true + kind: HdfsCluster + name: hdfs +--- +apiVersion: v1 +kind: Service +metadata: + name: hdfs-namenode-default + ownerReferences: + - apiVersion: hdfs.stackable.tech/v1alpha1 + controller: true + kind: HdfsCluster + name: hdfs +--- +apiVersion: v1 +kind: Service +metadata: + name: hdfs-namenode-default-metrics + ownerReferences: + - apiVersion: hdfs.stackable.tech/v1alpha1 + controller: true + kind: HdfsCluster + name: hdfs +--- +apiVersion: v1 +kind: Service +metadata: + name: hdfs-datanode-default + ownerReferences: + - apiVersion: hdfs.stackable.tech/v1alpha1 + controller: true + kind: HdfsCluster + name: hdfs +--- +apiVersion: v1 +kind: Service +metadata: + name: hdfs-datanode-default-metrics + ownerReferences: + - apiVersion: hdfs.stackable.tech/v1alpha1 + controller: true + kind: HdfsCluster + name: hdfs +--- +apiVersion: v1 +kind: Service +metadata: + name: hdfs-journalnode-default + ownerReferences: + - apiVersion: hdfs.stackable.tech/v1alpha1 + controller: true + kind: HdfsCluster + name: hdfs +--- +apiVersion: v1 +kind: Service +metadata: + name: hdfs-journalnode-default-metrics + ownerReferences: + - apiVersion: hdfs.stackable.tech/v1alpha1 + controller: true + kind: HdfsCluster + name: hdfs diff --git a/tests/templates/kuttl/cluster-operation/60-delete-owned-resources.yaml b/tests/templates/kuttl/cluster-operation/60-delete-owned-resources.yaml new file mode 100644 index 00000000..98c7fbf7 --- /dev/null +++ b/tests/templates/kuttl/cluster-operation/60-delete-owned-resources.yaml @@ -0,0 +1,60 @@ +# Every resource the operator applies carries an ownerReference and a `.owns()` watch +# (main.rs); deleting it must trigger a reconcile of the HdfsCluster, which re-applies +# it. This step checks that chain — the ClusterRole's `watch` verbs plus the `.owns()` +# routing — and lives here because this test already cycles the pods. The `.watches()` +# registrations (referenced-but-unowned ConfigMaps and Listeners) can't be tested by +# deletion: the operator never recreates what it didn't apply. The namenode Listeners +# are also not owned resources — they are created by the listener-operator from the +# StatefulSet's listener volume claims, not applied by this operator. +# +# Recreation is proven by UID change: mere existence could pass without any deletion. +# TestStep commands run exactly once (no kuttl retry loop), so the polling stays quiet. +# +# The ConfigMap deleted here is the discovery one, named after the cluster. +# Deliberately skipped: the role-group ConfigMaps +# (hdfs-{namenode,datanode,journalnode}-default) — `.owns(ConfigMap)` is already +# exercised via the discovery ConfigMap, and deleting ConfigMaps mounted into running +# pods would disturb them. +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +metadata: + name: delete-owned-resources +timeout: 300 +commands: + - script: | + set -eu + + delete_and_await_recreation() { + kind=$1 + name=$2 + old_uid=$(kubectl get -n "$NAMESPACE" "$kind" "$name" -o jsonpath='{.metadata.uid}') + kubectl delete -n "$NAMESPACE" "$kind" "$name" --wait=false + # Recreation is a single reconcile away, so this normally succeeds on the + # first iteration; 30s is a generous upper bound well below the step timeout. + for _ in $(seq 1 30); do + new_uid=$(kubectl get -n "$NAMESPACE" "$kind" "$name" -o jsonpath='{.metadata.uid}' 2>/dev/null || true) + if [ -n "$new_uid" ] && [ "$new_uid" != "$old_uid" ]; then + return 0 + fi + sleep 1 + done + echo "$kind/$name was not recreated (old uid: $old_uid, current: '${new_uid:-}')" >&2 + return 1 + } + + delete_and_await_recreation serviceaccount hdfs-serviceaccount + delete_and_await_recreation rolebinding hdfs-rolebinding + delete_and_await_recreation poddisruptionbudget hdfs-namenode + delete_and_await_recreation poddisruptionbudget hdfs-datanode + delete_and_await_recreation poddisruptionbudget hdfs-journalnode + delete_and_await_recreation configmap hdfs + delete_and_await_recreation service hdfs-namenode-default + delete_and_await_recreation service hdfs-namenode-default-metrics + delete_and_await_recreation service hdfs-datanode-default + delete_and_await_recreation service hdfs-datanode-default-metrics + delete_and_await_recreation service hdfs-journalnode-default + delete_and_await_recreation service hdfs-journalnode-default-metrics + delete_and_await_recreation statefulset hdfs-namenode-default + delete_and_await_recreation statefulset hdfs-datanode-default + delete_and_await_recreation statefulset hdfs-journalnode-default From 91cf2d6302e776da20eeaadbaea241a00803f7b9 Mon Sep 17 00:00:00 2001 From: Andrew Kenworthy Date: Mon, 24 Aug 2026 16:51:23 +0200 Subject: [PATCH 6/6] improve tests, consolidate rbac sections --- Cargo.lock | 1 - Cargo.nix | 4 - Cargo.toml | 1 - .../templates/clusterrole-operator.yaml | 21 +-- rust/operator-binary/Cargo.toml | 1 - rust/operator-binary/src/hdfs_controller.rs | 134 ++++++------------ .../60-delete-owned-resources.yaml | 75 +++++----- 7 files changed, 83 insertions(+), 154 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b8868338..56e5c5e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3186,7 +3186,6 @@ dependencies = [ "clap", "const_format", "futures 0.3.34", - "http", "indoc", "rstest", "serde", diff --git a/Cargo.nix b/Cargo.nix index 25b1ae79..b435a013 100644 --- a/Cargo.nix +++ b/Cargo.nix @@ -10513,10 +10513,6 @@ rec { } ]; devDependencies = [ - { - name = "http"; - packageId = "http"; - } { name = "rstest"; packageId = "rstest"; diff --git a/Cargo.toml b/Cargo.toml index 60abd7da..6554520e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,6 @@ built = { version = "0.8", features = ["chrono", "git2"] } clap = "4.6" const_format = "0.2" futures = { version = "0.3", features = ["compat"] } -http = "1.3" indoc = "2.0" rstest = "0.26" semver = "1.0" diff --git a/deploy/helm/hdfs-operator/templates/clusterrole-operator.yaml b/deploy/helm/hdfs-operator/templates/clusterrole-operator.yaml index 0955fc81..320b5993 100644 --- a/deploy/helm/hdfs-operator/templates/clusterrole-operator.yaml +++ b/deploy/helm/hdfs-operator/templates/clusterrole-operator.yaml @@ -13,27 +13,16 @@ rules: - nodes/proxy verbs: - get - # Manage core workload resources created per HdfsCluster. - # All resources are applied via Server-Side Apply (create + patch) and tracked for - # orphan cleanup (list + delete). + # Manage core workload resources created per HdfsCluster (the ServiceAccount + # provides workload pod identity). All resources are applied via Server-Side Apply + # (create + patch), tracked for orphan cleanup (list + delete) and watched by the + # controller. - apiGroups: - "" resources: - configmaps - - services - verbs: - - create - - delete - - get - - list - - patch - - watch - # serviceaccounts are applied via SSA and tracked for orphan cleanup - # and watched by the controller. - - apiGroups: - - "" - resources: - serviceaccounts + - services verbs: - create - delete diff --git a/rust/operator-binary/Cargo.toml b/rust/operator-binary/Cargo.toml index 5ad256e0..1de297f6 100644 --- a/rust/operator-binary/Cargo.toml +++ b/rust/operator-binary/Cargo.toml @@ -25,7 +25,6 @@ tracing-futures.workspace = true tracing.workspace = true [dev-dependencies] -http.workspace = true rstest.workspace = true serde_yaml.workspace = true diff --git a/rust/operator-binary/src/hdfs_controller.rs b/rust/operator-binary/src/hdfs_controller.rs index c4e29a0d..0f358590 100644 --- a/rust/operator-binary/src/hdfs_controller.rs +++ b/rust/operator-binary/src/hdfs_controller.rs @@ -278,78 +278,15 @@ spec: ); } - /// A [`Ctx`] whose client points at a closed port. Any API call made through it fails the - /// reconciliation, so an `Ok` result proves the reconciler returned before touching the - /// Kubernetes API. - fn unreachable_ctx() -> Arc { - let config = Config::new( - "http://127.0.0.1:1" - .parse::() - .expect("valid static URI"), - ); - let kube_client = KubeClient::try_from(config).expect("client from static config"); - - Arc::new(Ctx { - client: Client::new( - kube_client.clone(), - None, - "default".to_owned(), - KubernetesClusterInfo { - cluster_domain: DomainName::from_str("cluster.local") - .expect("valid cluster domain"), - }, - ), - operator_environment: OperatorEnvironmentOptions { - operator_namespace: "stackable-operators".to_owned(), - operator_service_name: "hdfs-operator".to_owned(), - image_repository: "oci.stackable.tech/sdp".to_owned(), - }, - event_recorder: Arc::new(Recorder::new( - kube_client, - Reporter { - controller: HDFS_FULL_CONTROLLER_NAME.to_string(), - instance: None, - }, - )), - }) - } - - fn reconcile(hdfs: DeserializeGuard) -> Result { - tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("current-thread tokio runtime") - .block_on(async { reconcile_hdfs(Arc::new(hdfs), unreachable_ctx()).await }) - } - + /// The client points at a closed port, so any API call would fail the reconciliation: an `Ok` + /// proves that a cluster being deleted returns before the reconciler touches the Kubernetes + /// API, and because the spec is invalid, before the [`DeserializeGuard`] is unwrapped. #[test] fn reconcile_exits_early_for_deleted_cluster() { let hdfs = serde_yaml::from_str( r#" apiVersion: hdfs.stackable.tech/v1alpha1 kind: HdfsCluster -metadata: - name: hdfs - namespace: default - deletionTimestamp: "2026-08-14T12:00:00Z" -spec: - image: - productVersion: 3.2.2 -"#, - ) - .expect("valid cluster YAML"); - - let action = reconcile(hdfs).expect("a deleted cluster reconciles without any API call"); - - assert_eq!(action, Action::await_change()); - } - - #[test] - fn reconcile_exits_early_for_deleted_cluster_with_invalid_spec() { - let hdfs = serde_yaml::from_str( - r#" -apiVersion: hdfs.stackable.tech/v1alpha1 -kind: HdfsCluster metadata: name: hdfs namespace: default @@ -359,35 +296,44 @@ spec: {} ) .expect("YAML parses; the invalid spec is captured inside the DeserializeGuard"); - let action = - reconcile(hdfs).expect("a deleted cluster reconciles even when its spec is invalid"); + let action = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current-thread tokio runtime") + .block_on(async { + let kube_client = KubeClient::try_from(Config::new( + "http://127.0.0.1:1".parse().expect("valid static URI"), + )) + .expect("client from static config"); + + let ctx = Arc::new(Ctx { + client: Client::new( + kube_client.clone(), + None, + "default".to_owned(), + KubernetesClusterInfo { + cluster_domain: DomainName::from_str("cluster.local") + .expect("valid cluster domain"), + }, + ), + event_recorder: Arc::new(Recorder::new( + kube_client, + Reporter { + controller: HDFS_FULL_CONTROLLER_NAME.to_string(), + instance: None, + }, + )), + operator_environment: OperatorEnvironmentOptions { + operator_namespace: "stackable-operators".to_owned(), + operator_service_name: "hdfs-operator".to_owned(), + image_repository: "oci.stackable.tech/sdp".to_owned(), + }, + }); + + reconcile_hdfs(Arc::new(hdfs), ctx).await + }) + .expect("a deleted cluster reconciles without any API call"); assert_eq!(action, Action::await_change()); } - - #[test] - fn reconcile_proceeds_for_live_cluster() { - let hdfs = serde_yaml::from_str( - r#" -apiVersion: hdfs.stackable.tech/v1alpha1 -kind: HdfsCluster -metadata: - name: hdfs - namespace: default -spec: - image: - productVersion: 3.5.0 - clusterConfig: - zookeeperConfigMapName: simple-znode -"#, - ) - .expect("valid cluster YAML"); - - let result = reconcile(hdfs); - - assert!( - matches!(result, Err(Error::Dereference { .. })), - "a live cluster must reach the API but when dereferencing against the unreachable test server: {result:?}" - ); - } } diff --git a/tests/templates/kuttl/cluster-operation/60-delete-owned-resources.yaml b/tests/templates/kuttl/cluster-operation/60-delete-owned-resources.yaml index 98c7fbf7..45ac09c5 100644 --- a/tests/templates/kuttl/cluster-operation/60-delete-owned-resources.yaml +++ b/tests/templates/kuttl/cluster-operation/60-delete-owned-resources.yaml @@ -1,21 +1,16 @@ +--- # Every resource the operator applies carries an ownerReference and a `.owns()` watch -# (main.rs); deleting it must trigger a reconcile of the HdfsCluster, which re-applies -# it. This step checks that chain — the ClusterRole's `watch` verbs plus the `.owns()` -# routing — and lives here because this test already cycles the pods. The `.watches()` -# registrations (referenced-but-unowned ConfigMaps and Listeners) can't be tested by -# deletion: the operator never recreates what it didn't apply. The namenode Listeners -# are also not owned resources — they are created by the listener-operator from the -# StatefulSet's listener volume claims, not applied by this operator. -# -# Recreation is proven by UID change: mere existence could pass without any deletion. -# TestStep commands run exactly once (no kuttl retry loop), so the polling stays quiet. +# (main.rs): deleting it must trigger a reconcile of the HdfsCluster that re-applies it, +# proving the `.owns()` routing and the ClusterRole `watch` verbs end to end. +# `.watches()` registrations can't be tested this way: the operator never recreates +# what it didn't apply. # -# The ConfigMap deleted here is the discovery one, named after the cluster. -# Deliberately skipped: the role-group ConfigMaps -# (hdfs-{namenode,datanode,journalnode}-default) — `.owns(ConfigMap)` is already -# exercised via the discovery ConfigMap, and deleting ConfigMaps mounted into running -# pods would disturb them. ---- +# Resources are discovered by label (ClusterResources::add enforces the labels on +# everything the operator applies), so new resources and kinds are covered +# automatically. Labels over-match on derived objects, so each match must also carry +# a controller ownerReference pointing at the HdfsCluster; kinds that can never pass that +# gate are excluded up front. Recreation is proven by UID change, and a floor guard +# catches a selector that silently matches nothing. apiVersion: kuttl.dev/v1beta1 kind: TestStep metadata: @@ -26,35 +21,41 @@ commands: set -eu delete_and_await_recreation() { - kind=$1 - name=$2 - old_uid=$(kubectl get -n "$NAMESPACE" "$kind" "$name" -o jsonpath='{.metadata.uid}') - kubectl delete -n "$NAMESPACE" "$kind" "$name" --wait=false + resource=$1 + old_uid=$(kubectl get -n "$NAMESPACE" "$resource" -o jsonpath='{.metadata.uid}') + kubectl delete -n "$NAMESPACE" "$resource" --wait=false # Recreation is a single reconcile away, so this normally succeeds on the # first iteration; 30s is a generous upper bound well below the step timeout. for _ in $(seq 1 30); do - new_uid=$(kubectl get -n "$NAMESPACE" "$kind" "$name" -o jsonpath='{.metadata.uid}' 2>/dev/null || true) + new_uid=$(kubectl get -n "$NAMESPACE" "$resource" -o jsonpath='{.metadata.uid}' 2>/dev/null || true) if [ -n "$new_uid" ] && [ "$new_uid" != "$old_uid" ]; then return 0 fi sleep 1 done - echo "$kind/$name was not recreated (old uid: $old_uid, current: '${new_uid:-}')" >&2 + echo "$resource was not recreated (old uid: $old_uid, current: '${new_uid:-}')" >&2 return 1 } - delete_and_await_recreation serviceaccount hdfs-serviceaccount - delete_and_await_recreation rolebinding hdfs-rolebinding - delete_and_await_recreation poddisruptionbudget hdfs-namenode - delete_and_await_recreation poddisruptionbudget hdfs-datanode - delete_and_await_recreation poddisruptionbudget hdfs-journalnode - delete_and_await_recreation configmap hdfs - delete_and_await_recreation service hdfs-namenode-default - delete_and_await_recreation service hdfs-namenode-default-metrics - delete_and_await_recreation service hdfs-datanode-default - delete_and_await_recreation service hdfs-datanode-default-metrics - delete_and_await_recreation service hdfs-journalnode-default - delete_and_await_recreation service hdfs-journalnode-default-metrics - delete_and_await_recreation statefulset hdfs-namenode-default - delete_and_await_recreation statefulset hdfs-datanode-default - delete_and_await_recreation statefulset hdfs-journalnode-default + selector="app.kubernetes.io/instance=hdfs,app.kubernetes.io/managed-by=hdfs.stackable.tech_hdfs-controller" + excluded="^(pods|persistentvolumeclaims|endpoints|events)$|^endpointslices\.|^controllerrevisions\.|^events\." + + deleted=0 + for kind in $(kubectl api-resources --verbs=list --namespaced -o name | grep -Ev "$excluded" | sort); do + for resource in $(kubectl get -n "$NAMESPACE" "$kind" -l "$selector" -o name 2>/dev/null); do + owner=$(kubectl get -n "$NAMESPACE" "$resource" -o jsonpath='{.metadata.ownerReferences[?(@.controller==true)].kind}/{.metadata.ownerReferences[?(@.controller==true)].name}' 2>/dev/null || true) + if [ "$owner" != "HdfsCluster/hdfs" ]; then + echo "skipping $resource: controller owner is '${owner:-none}', not the HdfsCluster" + continue + fi + delete_and_await_recreation "$resource" + deleted=$((deleted + 1)) + done + done + + # Guard against the sweep silently matching nothing (wrong selector, renamed + # labels): the fixture is known to produce well over this many owned resources. + if [ "$deleted" -lt 10 ]; then + echo "only $deleted labelled resources were swept - the label selector is broken" >&2 + exit 1 + fi