From 848409a6c81cdd378746c2d63fb59716328cdf8e Mon Sep 17 00:00:00 2001 From: Andrew Kenworthy Date: Thu, 30 Jul 2026 18:14:23 +0200 Subject: [PATCH 1/3] extract apply and update_status steps --- rust/operator-binary/src/controller/apply.rs | 220 ++++++++++++++++++ .../src/controller/build/mod.rs | 7 +- rust/operator-binary/src/controller/mod.rs | 17 +- .../src/controller/update_status.rs | 86 +++++++ rust/operator-binary/src/hdfs_controller.rs | 208 +++-------------- 5 files changed, 353 insertions(+), 185 deletions(-) create mode 100644 rust/operator-binary/src/controller/apply.rs create mode 100644 rust/operator-binary/src/controller/update_status.rs diff --git a/rust/operator-binary/src/controller/apply.rs b/rust/operator-binary/src/controller/apply.rs new file mode 100644 index 00000000..cc019648 --- /dev/null +++ b/rust/operator-binary/src/controller/apply.rs @@ -0,0 +1,220 @@ +//! The apply step in the HdfsCluster controller. + +use std::marker::PhantomData; + +use snafu::{ResultExt, Snafu}; +use stackable_operator::{ + client::Client, + cluster_resources::{ClusterResource, ClusterResourceApplyStrategy, ClusterResources}, + deep_merger::ObjectOverrides, + iter::reverse_if, + k8s_openapi::api::core::v1::ConfigMap, + kube::{ResourceExt, runtime::reflector::ObjectRef}, + status::rollout::check_statefulset_rollout_complete, + v2::cluster_resources::cluster_resources_new, +}; +use strum::{EnumDiscriminants, IntoStaticStr}; + +use crate::{ + controller::{ + Applied, KubernetesResources, Prepared, ValidatedCluster, controller_name, operator_name, + product_name, + }, + crd::{UpgradeState, constants::FIELD_MANAGER_SCOPE}, +}; + +#[derive(Snafu, Debug, EnumDiscriminants)] +#[strum_discriminants(derive(IntoStaticStr))] +pub enum Error { + #[snafu(display("failed to apply Kubernetes resource"))] + ApplyResource { + source: stackable_operator::cluster_resources::Error, + }, + + #[snafu(display("failed to apply the StatefulSet {name:?}"))] + ApplyRoleGroupStatefulSet { + source: stackable_operator::cluster_resources::Error, + name: String, + }, + + #[snafu(display("cannot create discovery config map {name:?}"))] + ApplyDiscoveryConfigMap { + source: stackable_operator::client::Error, + name: String, + }, + + #[snafu(display("failed to delete orphaned resources"))] + DeleteOrphanedResources { + source: stackable_operator::cluster_resources::Error, + }, +} + +type Result = std::result::Result; + +/// The outcome of the apply step: the applied resources, plus whether every StatefulSet was +/// applied and — during an upgrade or downgrade — fully rolled out. +pub struct AppliedResources { + pub resources: KubernetesResources, + /// `false` while a rolling upgrade or downgrade is still in progress. The role-ordered + /// rollout then stopped at the incomplete StatefulSet, so the later ones were not applied + /// in this run, and the status must keep its upgrade/downgrade state. + pub statefulsets_rolled_out: bool, +} + +/// Applier for the Kubernetes resource specifications produced by this controller. +/// +/// Unlike its siblings in the other operators, this Applier is HDFS-specific: StatefulSets are +/// rolled out in role order during upgrades (reversed for downgrades), each role gated on the +/// previous one's rollout being complete. +pub struct Applier<'a> { + client: &'a Client, + cluster_resources: ClusterResources<'a>, +} + +impl<'a> Applier<'a> { + pub fn new( + client: &'a Client, + cluster: &ValidatedCluster, + apply_strategy: ClusterResourceApplyStrategy, + object_overrides: &'a ObjectOverrides, + ) -> Applier<'a> { + let cluster_resources = cluster_resources_new( + &product_name(), + &operator_name(), + &controller_name(), + &cluster.name, + &cluster.namespace, + &cluster.uid, + apply_strategy, + object_overrides, + ); + + Applier { + client, + cluster_resources, + } + } + + /// Applies the given Kubernetes resources and marks them as applied. + /// + /// `applied.resources.stateful_sets` contains only the StatefulSets that were actually + /// applied: during an upgrade or downgrade the role-ordered rollout stops at the first + /// StatefulSet whose rollout is incomplete (see [`AppliedResources`]). + pub async fn apply( + mut self, + resources: KubernetesResources, + upgrade_state: Option, + ) -> Result { + // Destructured without `..`, so adding a field to [`KubernetesResources`] fails to + // compile here instead of silently never being applied. + let KubernetesResources { + services, + config_maps, + pod_disruption_budgets, + stateful_sets, + service_accounts, + role_bindings, + status: _, + } = resources; + + // Apply order is: StatefulSets last (a changed mounted ConfigMap/Secret + // must exist first, else Pods restart -- commons-operator#111). The ServiceAccount comes + // first because the Pods reference it at creation time. + let service_accounts = self.add_resources(service_accounts).await?; + let role_bindings = self.add_resources(role_bindings).await?; + let services = self.add_resources(services).await?; + let config_maps = self.add_resources(config_maps).await?; + let pod_disruption_budgets = self.add_resources(pod_disruption_budgets).await?; + + // StatefulSets must be rolled out in role order during upgrades (a namenode's version + // must be >= the datanodes', and so on), with each role finishing its rollout before the + // next starts. + // https://hadoop.apache.org/docs/r3.4.0/hadoop-project-dist/hadoop-hdfs/HdfsRollingUpgrade.html#Upgrading_Non-Federated_Clusters + // The build output is already ordered by role, so it is applied as-is; downgrades have + // the opposite version relationship and are therefore rolled out in reverse. + let downgrading = matches!(upgrade_state, Some(UpgradeState::Downgrading)); + if downgrading { + tracing::info!("HdfsCluster is being downgraded, deploying in reverse order"); + } + let mut applied_stateful_sets = vec![]; + let mut statefulsets_rolled_out = true; + for statefulset in reverse_if(downgrading, stateful_sets.into_iter()) { + let name = statefulset.name_any(); + let applied_statefulset = self + .cluster_resources + .add(self.client, statefulset) + .await + .with_context(|_| ApplyRoleGroupStatefulSetSnafu { name })?; + + if upgrade_state.is_some() + && let Err(reason) = check_statefulset_rollout_complete(&applied_statefulset) + { + // Ensure each role is fully upgraded before moving on to the next. + tracing::info!( + rolegroup.statefulset = %ObjectRef::from_obj(&applied_statefulset), + reason = &reason as &dyn std::error::Error, + "rolegroup is still upgrading, waiting..." + ); + applied_stateful_sets.push(applied_statefulset); + statefulsets_rolled_out = false; + break; + } + applied_stateful_sets.push(applied_statefulset); + } + + // During upgrades we do partial deployments; we don't want to garbage collect after + // those since we *will* redeploy (or properly orphan) the remaining resources later. + if statefulsets_rolled_out { + self.cluster_resources + .delete_orphaned_resources(self.client) + .await + .context(DeleteOrphanedResourcesSnafu)?; + } + + Ok(AppliedResources { + resources: KubernetesResources { + stateful_sets: applied_stateful_sets, + services, + config_maps, + pod_disruption_budgets, + service_accounts, + role_bindings, + status: PhantomData, + }, + statefulsets_rolled_out, + }) + } + + async fn add_resources( + &mut self, + resources: Vec, + ) -> Result> { + let mut applied_resources = vec![]; + + for resource in resources { + let applied_resource = self + .cluster_resources + .add(self.client, resource) + .await + .context(ApplyResourceSnafu)?; + applied_resources.push(applied_resource); + } + + Ok(applied_resources) + } +} + +/// Applies the discovery `ConfigMap` directly, outside the [`ClusterResources`] tracking. +/// +/// The discovery CM is linked to the cluster lifecycle via ownerreference. Therefore, it must +/// not be added to the "orphaned" cluster resources: it is applied after +/// [`Applier::apply`], whose orphan deletion must never see it. +pub async fn apply_discovery_config_map(client: &Client, discovery_cm: &ConfigMap) -> Result<()> { + client + .apply_patch(FIELD_MANAGER_SCOPE, discovery_cm, discovery_cm) + .await + .with_context(|_| ApplyDiscoveryConfigMapSnafu { + name: discovery_cm.metadata.name.clone().unwrap_or_default(), + })?; + Ok(()) +} diff --git a/rust/operator-binary/src/controller/build/mod.rs b/rust/operator-binary/src/controller/build/mod.rs index a19c50d0..f5cb657f 100644 --- a/rust/operator-binary/src/controller/build/mod.rs +++ b/rust/operator-binary/src/controller/build/mod.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::{collections::HashMap, marker::PhantomData}; use snafu::{ResultExt, Snafu}; use stackable_operator::{ @@ -13,7 +13,7 @@ use stackable_operator::{ use crate::{ controller::{ - KubernetesResources, ValidatedCluster, + KubernetesResources, Prepared, ValidatedCluster, build::resource::rbac::{build_role_binding, build_service_account}, }, crd::{ @@ -82,7 +82,7 @@ pub enum Error { pub fn build( cluster: &ValidatedCluster, cluster_info: &KubernetesClusterInfo, -) -> Result { +) -> Result, Error> { let mut services = vec![]; let mut config_maps = vec![]; let mut stateful_sets = vec![]; @@ -143,6 +143,7 @@ pub fn build( stateful_sets, service_accounts: vec![build_service_account(cluster)], role_bindings: vec![build_role_binding(cluster)], + status: PhantomData, }) } diff --git a/rust/operator-binary/src/controller/mod.rs b/rust/operator-binary/src/controller/mod.rs index 2f351d7a..1b768d85 100644 --- a/rust/operator-binary/src/controller/mod.rs +++ b/rust/operator-binary/src/controller/mod.rs @@ -1,4 +1,4 @@ -use std::{collections::BTreeMap, str::FromStr}; +use std::{collections::BTreeMap, marker::PhantomData, str::FromStr}; use stackable_operator::{ commons::product_image_selection::ResolvedProductImage, @@ -35,10 +35,18 @@ use crate::{ hdfs_controller::RESOURCE_MANAGER_HDFS_CONTROLLER, }; +pub mod apply; pub mod build; pub mod dereference; +pub mod update_status; pub mod validate; +/// Marker for prepared Kubernetes resources which are not applied yet. +pub struct Prepared; + +/// Marker for applied Kubernetes resources. +pub struct Applied; + /// Every Kubernetes resource produced by the build step. /// /// The resources are flat, unordered collections. The reconcile step re-groups the @@ -46,13 +54,18 @@ pub mod validate; /// upgrades. The discovery `ConfigMap` is not part of this set: it depends on a live /// Kubernetes client (to resolve listener addresses) and is therefore built and applied /// separately in the reconcile step. -pub struct KubernetesResources { +/// +/// `T` is a marker that indicates if these resources are only [`Prepared`] or already [`Applied`]. +/// The marker is useful e.g. to ensure that the cluster status is updated based on the applied +/// resources. +pub struct KubernetesResources { pub services: Vec, pub config_maps: Vec, pub pod_disruption_budgets: Vec, pub stateful_sets: Vec, pub service_accounts: Vec, pub role_bindings: Vec, + pub status: PhantomData, } /// The [`RoleGroupConfig`] specialised for HDFS: the validated config is the diff --git a/rust/operator-binary/src/controller/update_status.rs b/rust/operator-binary/src/controller/update_status.rs new file mode 100644 index 00000000..c7947712 --- /dev/null +++ b/rust/operator-binary/src/controller/update_status.rs @@ -0,0 +1,86 @@ +//! The update_status step in the HdfsCluster controller. + +use snafu::{ResultExt, Snafu}; +use stackable_operator::{ + client::Client, + status::condition::{ + compute_conditions, operations::ClusterOperationsConditionBuilder, + statefulset::StatefulSetConditionBuilder, + }, +}; +use strum::{EnumDiscriminants, IntoStaticStr}; + +use crate::{ + OPERATOR_NAME, + controller::{ValidatedCluster, apply::AppliedResources}, + crd::{HdfsClusterStatus, UpgradeState, v1alpha1}, +}; + +#[derive(Snafu, Debug, EnumDiscriminants)] +#[strum_discriminants(derive(IntoStaticStr))] +pub enum Error { + #[snafu(display("failed to update status"))] + ApplyStatus { + source: stackable_operator::client::Error, + }, +} + +type Result = std::result::Result; + +/// Computes the cluster status from the outcome of the apply step and patches it onto the +/// [`v1alpha1::HdfsCluster`]. Takes [`AppliedResources`] so the type system proves the status +/// derives from applied resources — including whether the role-ordered StatefulSet rollout is +/// still in progress — not merely built ones. +pub async fn update_status( + client: &Client, + hdfs: &v1alpha1::HdfsCluster, + cluster: &ValidatedCluster, + applied: &AppliedResources, +) -> Result<()> { + let mut ss_cond_builder = StatefulSetConditionBuilder::default(); + for stateful_set in &applied.resources.stateful_sets { + ss_cond_builder.add(stateful_set.clone()); + } + + let cluster_operation_cond_builder = + ClusterOperationsConditionBuilder::new(&hdfs.spec.cluster_operation); + + let upgrade_state = cluster.status.upgrade_state; + + let status = HdfsClusterStatus { + conditions: compute_conditions(hdfs, &[&ss_cond_builder, &cluster_operation_cond_builder]), + // FIXME: We can't currently leave upgrade mode automatically, since we don't know when an upgrade is finalized + deployed_product_version: Some( + cluster + .status + .deployed_product_version + .clone() + // Keep current version if set, otherwise (on initial deploy) fall back + // to the user's specified version. + .unwrap_or_else(|| cluster.image.product_version.clone()), + ), + upgrade_target_product_version: match upgrade_state { + // User is upgrading, whatever they're upgrading to is (by definition) the target + Some(UpgradeState::Upgrading) => Some(cluster.image.product_version.clone()), + Some(UpgradeState::Downgrading) => { + if applied.statefulsets_rolled_out { + // Downgrade is done, clear + tracing::info!("downgrade deployed, clearing upgrade state"); + None + } else { + // Downgrade is still in progress, preserve the current value + cluster.status.upgrade_target_product_version.clone() + } + } + // Upgrade is complete (if any), clear + None => None, + }, + }; + + client + .apply_patch_status(OPERATOR_NAME, hdfs, &status) + .await + .context(ApplyStatusSnafu)?; + + Ok(()) +} diff --git a/rust/operator-binary/src/hdfs_controller.rs b/rust/operator-binary/src/hdfs_controller.rs index 5b94a220..11a97642 100644 --- a/rust/operator-binary/src/hdfs_controller.rs +++ b/rust/operator-binary/src/hdfs_controller.rs @@ -5,36 +5,27 @@ use stackable_operator::{ cli::OperatorEnvironmentOptions, client::Client, cluster_resources::ClusterResourceApplyStrategy, - iter::reverse_if, kube::{ - Resource, ResourceExt, + Resource, core::{DeserializeGuard, error_boundary}, - runtime::{controller::Action, events::Recorder, reflector::ObjectRef}, + runtime::{controller::Action, events::Recorder}, }, kvp::LabelError, logging::controller::ReconcilerError, shared::time::Duration, - status::{ - condition::{ - compute_conditions, operations::ClusterOperationsConditionBuilder, - statefulset::StatefulSetConditionBuilder, - }, - rollout::check_statefulset_rollout_complete, - }, - v2::cluster_resources::cluster_resources_new, }; use strum::{EnumDiscriminants, IntoEnumIterator, IntoStaticStr}; use crate::{ - OPERATOR_NAME, controller::{ + apply::{self, Applier, apply_discovery_config_map}, build::{ self, resource::discovery::{self, build_discovery_config_map}, }, - controller_name, operator_name, product_name, + update_status::{self, update_status}, }, - crd::{HdfsClusterStatus, HdfsNodeRole, UpgradeState, constants::*, v1alpha1}, + crd::{HdfsNodeRole, v1alpha1}, event::{build_invalid_replica_message, publish_warning_event}, }; @@ -44,10 +35,11 @@ pub const HDFS_CONTROLLER_NAME: &str = "hdfs-controller"; #[derive(Snafu, Debug, EnumDiscriminants)] #[strum_discriminants(derive(IntoStaticStr))] pub enum Error { - #[snafu(display("failed to apply Kubernetes resource"))] - ApplyResource { - source: stackable_operator::cluster_resources::Error, - }, + #[snafu(display("failed to apply the Kubernetes resources"))] + ApplyResources { source: apply::Error }, + + #[snafu(display("failed to update the cluster status"))] + UpdateStatus { source: update_status::Error }, #[snafu(display("failed to dereference cluster resources"))] Dereference { @@ -59,17 +51,8 @@ pub enum Error { source: crate::controller::validate::Error, }, - #[snafu(display("cannot create role group stateful set {name:?}"))] - ApplyRoleGroupStatefulSet { - source: stackable_operator::cluster_resources::Error, - name: String, - }, - - #[snafu(display("cannot create discovery config map {name:?}"))] - ApplyDiscoveryConfigMap { - source: stackable_operator::client::Error, - name: String, - }, + #[snafu(display("failed to apply the discovery ConfigMap"))] + ApplyDiscoveryConfigMap { source: apply::Error }, #[snafu(display("failed to build Kubernetes resources"))] BuildResources { @@ -82,24 +65,9 @@ pub enum Error { #[snafu(display("cannot build config discovery config map"))] BuildDiscoveryConfigMap { source: discovery::Error }, - #[snafu(display("failed to delete orphaned resources"))] - DeleteOrphanedResources { - source: stackable_operator::cluster_resources::Error, - }, - #[snafu(display("failed to create cluster event"))] FailedToCreateClusterEvent { source: crate::event::Error }, - #[snafu(display("failed to apply PodDisruptionBudget"))] - ApplyPdb { - source: stackable_operator::cluster_resources::Error, - }, - - #[snafu(display("failed to update status"))] - ApplyStatus { - source: stackable_operator::client::Error, - }, - #[snafu(display("failed to build cluster resources label"))] BuildClusterResourcesLabel { source: LabelError }, @@ -147,59 +115,12 @@ pub async fn reconcile_hdfs( ) .context(ValidateSnafu)?; - let mut cluster_resources = cluster_resources_new( - &product_name(), - &operator_name(), - &controller_name(), - &validated_cluster.name, - &validated_cluster.namespace, - &validated_cluster.uid, - ClusterResourceApplyStrategy::from(&hdfs.spec.cluster_operation), - &hdfs.spec.object_overrides, - ); - // Build every (non-discovery) Kubernetes resource up front. This step needs no client: all // external references are already dereferenced and validated. The ServiceAccount name is // deterministic on the built RBAC object, so the build does not depend on the applied one. let resources = build::build(&validated_cluster, &client.kubernetes_cluster_info) .context(BuildResourcesSnafu)?; - // Apply Services, ConfigMaps and PodDisruptionBudgets first. The StatefulSets are applied - // afterwards so that every ConfigMap a Pod mounts already exists, which prevents unnecessary - // Pod restarts. See https://github.com/stackabletech/commons-operator/issues/111 for details. - for service_account in resources.service_accounts { - cluster_resources - .add(client, service_account) - .await - .context(ApplyResourceSnafu)?; - } - for role_binding in resources.role_bindings { - cluster_resources - .add(client, role_binding) - .await - .context(ApplyResourceSnafu)?; - } - for service in resources.services { - cluster_resources - .add(client, service) - .await - .context(ApplyResourceSnafu)?; - } - for config_map in resources.config_maps { - cluster_resources - .add(client, config_map) - .await - .context(ApplyResourceSnafu)?; - } - for pdb in resources.pod_disruption_budgets { - cluster_resources - .add(client, pdb) - .await - .context(ApplyResourceSnafu)?; - } - - let upgrade_state = validated_cluster.status.upgrade_state; - // Warn about invalid replica counts. This is validation feedback and independent of the // resource application below. for role in HdfsNodeRole::iter() { @@ -219,42 +140,19 @@ pub async fn reconcile_hdfs( } } - let mut ss_cond_builder = StatefulSetConditionBuilder::default(); - let mut deploy_done = true; - - // StatefulSets must be rolled out in role order during upgrades (a namenode's version must be - // >= the datanodes', and so on), with each role finishing its rollout before the next starts. - // https://hadoop.apache.org/docs/r3.4.0/hadoop-project-dist/hadoop-hdfs/HdfsRollingUpgrade.html#Upgrading_Non-Federated_Clusters - // The build output is already ordered by role, so it is applied as-is; downgrades have the - // opposite version relationship and are therefore rolled out in reverse. - let downgrading = matches!(upgrade_state, Some(UpgradeState::Downgrading)); - if downgrading { - tracing::info!("HdfsCluster is being downgraded, deploying in reverse order"); - } - for statefulset in reverse_if(downgrading, resources.stateful_sets.iter()) { - let name = statefulset.name_any(); - let deployed_statefulset = cluster_resources - .add(client, statefulset.clone()) - .await - .with_context(|_| ApplyRoleGroupStatefulSetSnafu { name })?; - ss_cond_builder.add(deployed_statefulset.clone()); - - if upgrade_state.is_some() { - // Ensure each role is fully upgraded before moving on to the next. - if let Err(reason) = check_statefulset_rollout_complete(&deployed_statefulset) { - tracing::info!( - rolegroup.statefulset = %ObjectRef::from_obj(&deployed_statefulset), - reason = &reason as &dyn std::error::Error, - "rolegroup is still upgrading, waiting..." - ); - deploy_done = false; - break; - } - } - } + let applied = Applier::new( + client, + &validated_cluster, + ClusterResourceApplyStrategy::from(&hdfs.spec.cluster_operation), + &hdfs.spec.object_overrides, + ) + .apply(resources, validated_cluster.status.upgrade_state) + .await + .context(ApplyResourcesSnafu)?; - // Discovery CM will fail to build until the rest of the cluster has been deployed, so do it last - // so that failure won't inhibit the rest of the cluster from booting up. + // Discovery CM will fail to build until the rest of the cluster has been + // deployed, so do it last so that failure won't inhibit the rest of the + // cluster from booting up. let discovery_cm = build_discovery_config_map( &validated_cluster, &client.kubernetes_cluster_info, @@ -267,63 +165,13 @@ pub async fn reconcile_hdfs( ) .context(BuildDiscoveryConfigMapSnafu)?; - // The discovery CM is linked to the cluster lifecycle via ownerreference. - // Therefore, must not be added to the "orphaned" cluster resources - client - .apply_patch(FIELD_MANAGER_SCOPE, &discovery_cm, &discovery_cm) + apply_discovery_config_map(client, &discovery_cm) .await - .with_context(|_| ApplyDiscoveryConfigMapSnafu { - name: discovery_cm.metadata.name.clone().unwrap_or_default(), - })?; - - let cluster_operation_cond_builder = - ClusterOperationsConditionBuilder::new(&hdfs.spec.cluster_operation); - - let status = HdfsClusterStatus { - conditions: compute_conditions(hdfs, &[&ss_cond_builder, &cluster_operation_cond_builder]), - // FIXME: We can't currently leave upgrade mode automatically, since we don't know when an upgrade is finalized - deployed_product_version: Some( - validated_cluster - .status - .deployed_product_version - .clone() - // Keep current version if set, otherwise (on initial deploy) fall back - // to the user's specified version. - .unwrap_or_else(|| validated_cluster.image.product_version.clone()), - ), - upgrade_target_product_version: match upgrade_state { - // User is upgrading, whatever they're upgrading to is (by definition) the target - Some(UpgradeState::Upgrading) => Some(validated_cluster.image.product_version.clone()), - Some(UpgradeState::Downgrading) => { - if deploy_done { - // Downgrade is done, clear - tracing::info!("downgrade deployed, clearing upgrade state"); - None - } else { - // Downgrade is still in progress, preserve the current value - validated_cluster - .status - .upgrade_target_product_version - .clone() - } - } - // Upgrade is complete (if any), clear - None => None, - }, - }; + .context(ApplyDiscoveryConfigMapSnafu)?; - // During upgrades we do partial deployments, we don't want to garbage collect after those - // since we *will* redeploy (or properly orphan) the remaining resources later. - if deploy_done { - cluster_resources - .delete_orphaned_resources(client) - .await - .context(DeleteOrphanedResourcesSnafu)?; - } - client - .apply_patch_status(OPERATOR_NAME, hdfs, &status) + update_status(client, hdfs, &validated_cluster, &applied) .await - .context(ApplyStatusSnafu)?; + .context(UpdateStatusSnafu)?; Ok(Action::await_change()) } From 24908e6649b66a67ca4debd3f5d4ca07bc5c338b Mon Sep 17 00:00:00 2001 From: Andrew Kenworthy Date: Wed, 5 Aug 2026 17:37:46 +0200 Subject: [PATCH 2/3] include listeners in the dereference/validate steps: the operator uses them for the discovery CM --- .../templates/clusterrole-operator.yaml | 8 +- rust/operator-binary/src/controller/apply.rs | 5 + .../src/controller/dereference.rs | 54 +++- rust/operator-binary/src/controller/mod.rs | 13 +- .../src/controller/validate.rs | 10 +- rust/operator-binary/src/crd/mod.rs | 255 +++++++++++++----- rust/operator-binary/src/hdfs_controller.rs | 43 +-- rust/operator-binary/src/main.rs | 39 ++- rust/operator-binary/src/test_support.rs | 1 + 9 files changed, 334 insertions(+), 94 deletions(-) diff --git a/deploy/helm/hdfs-operator/templates/clusterrole-operator.yaml b/deploy/helm/hdfs-operator/templates/clusterrole-operator.yaml index 5eb0b66b..6de12303 100644 --- a/deploy/helm/hdfs-operator/templates/clusterrole-operator.yaml +++ b/deploy/helm/hdfs-operator/templates/clusterrole-operator.yaml @@ -97,14 +97,16 @@ rules: verbs: - create - patch - # Read listener addresses to build the discovery ConfigMap for downstream clients. - # Listeners are managed by the listener-operator; this operator only reads them. + # The namenode Listeners are created by the listener-operator for the namenode listener + # volumes. List: their addresses go into the discovery ConfigMap for downstream clients. + # Watch: a reconciliation must re-trigger once the listener-operator writes the addresses. - apiGroups: - listeners.stackable.tech resources: - listeners verbs: - - get + - list + - watch # Watch HdfsClusters for reconciliation - apiGroups: - {{ include "operator.name" . }}.stackable.tech diff --git a/rust/operator-binary/src/controller/apply.rs b/rust/operator-binary/src/controller/apply.rs index cc019648..fa0f86c6 100644 --- a/rust/operator-binary/src/controller/apply.rs +++ b/rust/operator-binary/src/controller/apply.rs @@ -107,6 +107,11 @@ impl<'a> Applier<'a> { ) -> Result { // Destructured without `..`, so adding a field to [`KubernetesResources`] fails to // compile here instead of silently never being applied. + // + // The namenode Listeners are deliberately not part of these resources: this operator + // never creates them. The listener-operator creates one Listener per namenode pod for + // the listener volumes declared in the StatefulSets, and this operator only reads them + // back to build the discovery ConfigMap. let KubernetesResources { services, config_maps, diff --git a/rust/operator-binary/src/controller/dereference.rs b/rust/operator-binary/src/controller/dereference.rs index e73ac36e..1131006a 100644 --- a/rust/operator-binary/src/controller/dereference.rs +++ b/rust/operator-binary/src/controller/dereference.rs @@ -1,6 +1,14 @@ use snafu::{ResultExt, Snafu}; +use stackable_operator::{ + crd::listener::v1alpha1::Listener, + kube::api::ListParams, + v2::controller_utils::{get_cluster_name, get_namespace}, +}; -use crate::{controller::build::opa::HdfsOpaConfig, crd::v1alpha1}; +use crate::{ + controller::build::opa::HdfsOpaConfig, + crd::{is_namenode_listener, v1alpha1}, +}; #[derive(Snafu, Debug)] pub enum Error { @@ -8,11 +16,32 @@ pub enum Error { InvalidOpaConfig { source: crate::controller::build::opa::Error, }, + + #[snafu(display("failed to get the cluster name"))] + GetClusterName { + source: stackable_operator::v2::controller_utils::Error, + }, + + #[snafu(display("failed to get the cluster namespace"))] + GetClusterNamespace { + source: stackable_operator::v2::controller_utils::Error, + }, + + #[snafu(display("failed to list the namenode Listeners"))] + ListNamenodeListeners { + source: stackable_operator::client::Error, + }, } /// External references resolved during the dereference step. pub struct DereferencedObjects { pub hdfs_opa_config: Option, + /// The namenode pod `Listener`s as currently stored in the cluster, fetched because the + /// discovery `ConfigMap` is built from their ingress addresses. Unlike + /// [`Self::hdfs_opa_config`] they are not referenced from the spec: the listener-operator + /// creates them for the namenode listener volumes, so they can be missing or still + /// address-less around the first reconcile runs. + pub namenode_listeners: Vec, } pub async fn dereference( @@ -28,5 +57,26 @@ pub async fn dereference( None => None, }; - Ok(DereferencedObjects { hdfs_opa_config }) + let cluster_name = get_cluster_name(hdfs).context(GetClusterNameSnafu)?; + let namespace = get_namespace(hdfs).context(GetClusterNamespaceSnafu)?; + let namenode_listeners = client + .list::(namespace.as_ref(), &ListParams::default()) + .await + .context(ListNamenodeListenersSnafu)? + .into_iter() + .filter(|listener| { + listener + .metadata + .name + .as_deref() + .is_some_and(|listener_name| { + is_namenode_listener(listener_name, cluster_name.as_ref()) + }) + }) + .collect(); + + Ok(DereferencedObjects { + hdfs_opa_config, + namenode_listeners, + }) } diff --git a/rust/operator-binary/src/controller/mod.rs b/rust/operator-binary/src/controller/mod.rs index 1b768d85..f0fe8255 100644 --- a/rust/operator-binary/src/controller/mod.rs +++ b/rust/operator-binary/src/controller/mod.rs @@ -2,6 +2,7 @@ use std::{collections::BTreeMap, marker::PhantomData, str::FromStr}; use stackable_operator::{ commons::product_image_selection::ResolvedProductImage, + crd::listener, k8s_openapi::api::{ apps::v1::StatefulSet, core::v1::{ConfigMap, Service, ServiceAccount}, @@ -51,9 +52,10 @@ pub struct Applied; /// /// The resources are flat, unordered collections. The reconcile step re-groups the /// StatefulSets by role to preserve HDFS's ordered, rollout-gated deployment during -/// upgrades. The discovery `ConfigMap` is not part of this set: it depends on a live -/// Kubernetes client (to resolve listener addresses) and is therefore built and applied -/// separately in the reconcile step. +/// upgrades. The discovery `ConfigMap` is not part of this set: it can only be built once +/// every namenode `Listener` has an ingress address, so the reconcile step builds and +/// applies it separately (via [`apply::apply_discovery_config_map`], outside the +/// `ClusterResources` tracking). /// /// `T` is a marker that indicates if these resources are only [`Prepared`] or already [`Applied`]. /// The marker is useful e.g. to ensure that the cluster status is updated based on the applied @@ -97,6 +99,9 @@ pub struct ValidatedCluster { pub cluster_config: ValidatedClusterConfig, pub role_groups: BTreeMap>, pub role_configs: BTreeMap, + /// The namenode pod `Listener`s as currently stored in the cluster (see + /// [`crate::controller::dereference::DereferencedObjects::namenode_listeners`]). + pub namenode_listeners: Vec, /// The validated view of the cluster's current status, resolved once during /// validation. pub status: ValidatedClusterStatus, @@ -112,6 +117,7 @@ impl ValidatedCluster { cluster_config: ValidatedClusterConfig, role_groups: BTreeMap>, role_configs: BTreeMap, + namenode_listeners: Vec, status: ValidatedClusterStatus, ) -> Self { // `app_version_label_value` is constructed to be a valid label value, so it is also a valid @@ -135,6 +141,7 @@ impl ValidatedCluster { cluster_config, role_groups, role_configs, + namenode_listeners, status, } } diff --git a/rust/operator-binary/src/controller/validate.rs b/rust/operator-binary/src/controller/validate.rs index d8e41765..ae881ef3 100644 --- a/rust/operator-binary/src/controller/validate.rs +++ b/rust/operator-binary/src/controller/validate.rs @@ -76,6 +76,13 @@ pub fn validate_cluster( image_repository: &str, dereferenced_objects: DereferencedObjects, ) -> Result { + // Destructured without `..`, so adding a field to [`DereferencedObjects`] fails to + // compile here instead of silently never being validated. + let DereferencedObjects { + hdfs_opa_config, + namenode_listeners, + } = dereferenced_objects; + let image: product_image_selection::ResolvedProductImage = hdfs .spec .image @@ -138,9 +145,10 @@ pub fn validate_cluster( namespace, uid, image, - ValidatedClusterConfig::resolve(hdfs, dereferenced_objects.hdfs_opa_config), + ValidatedClusterConfig::resolve(hdfs, hdfs_opa_config), role_groups, role_configs, + namenode_listeners, status, )) } diff --git a/rust/operator-binary/src/crd/mod.rs b/rust/operator-binary/src/crd/mod.rs index 75fe5c84..92f1d2d6 100644 --- a/rust/operator-binary/src/crd/mod.rs +++ b/rust/operator-binary/src/crd/mod.rs @@ -6,10 +6,9 @@ use std::{ str::FromStr, }; -use futures::future::try_join_all; use security::AuthorizationConfig; use serde::{Deserialize, Serialize}; -use snafu::{OptionExt, ResultExt, Snafu}; +use snafu::{ResultExt, Snafu}; use stackable_operator::{ commons::{ affinity::StackableAffinity, @@ -26,8 +25,8 @@ use stackable_operator::{ }, crd::listener, deep_merger::ObjectOverrides, - k8s_openapi::{api::core::v1::Pod, apimachinery::pkg::api::resource::Quantity}, - kube::{CustomResource, runtime::reflector::ObjectRef}, + k8s_openapi::apimachinery::pkg::api::resource::Quantity, + kube::CustomResource, product_logging::{ self, spec::{ContainerLogConfig, Logging}, @@ -106,19 +105,6 @@ pub enum Error { #[snafu(display("fragment validation failure"))] FragmentValidationFailure { source: ValidationError }, - #[snafu(display("unable to get {listener} (for {pod})"))] - GetPodListener { - source: stackable_operator::client::Error, - listener: ObjectRef, - pod: ObjectRef, - }, - - #[snafu(display("{listener} (for {pod}) has no address"))] - PodListenerHasNoAddress { - listener: ObjectRef, - pod: ObjectRef, - }, - #[snafu(display("port {port} ({port_name:?}) is out of bounds, must be within {range:?}", range = 0..=u16::MAX))] PortOutOfBounds { source: TryFromIntError, @@ -553,63 +539,80 @@ impl HdfsNodeRole { } } -/// Returns the required port name and port number tuples exposed by pods of the -/// given `role`, depending on whether HTTPS is enabled. -/// The rolegroup selector labels for `rolegroup_ref`, owned by `owner` (either the -/// raw `HdfsCluster` or the [`crate::controller::ValidatedCluster`]). +/// The name of the [`Listener`](listener::v1alpha1::Listener) that the listener-operator +/// creates for the listener volume of the pod named `pod_name`. +fn pod_listener_name(pod_name: &str) -> String { + format!("{}-{}", *LISTENER_VOLUME_NAME, pod_name) +} + +/// Whether `listener_name` names a namenode pod +/// [`Listener`](listener::v1alpha1::Listener) of the cluster named `cluster_name`. +/// +/// This mirrors [`pod_listener_name`] applied to the namenode pod names built by +/// [`crate::controller::build::pod_refs`]: +/// `--namenode--`. +pub(crate) fn is_namenode_listener(listener_name: &str, cluster_name: &str) -> bool { + listener_name.starts_with(&format!( + "{listener_volume}-{cluster_name}-{role}-", + listener_volume = *LISTENER_VOLUME_NAME, + role = HdfsNodeRole::Name, + )) +} + /// Resolve the listener-based [`HdfsPodRef`]s for the given namenode `namenode_podrefs`, /// configured to access the cluster via [`Listener`](listener::v1alpha1::Listener) rather -/// than direct [`Pod`] access. +/// than direct pod access. /// /// This enables access from outside the Kubernetes cluster (if using a -/// [`listener::v1alpha1::ListenerClass`] configured for this). It assumes that all -/// `Listener`s have been created, and may fail while waiting for the cluster to come online. +/// [`listener::v1alpha1::ListenerClass`] configured for this). +/// +/// Returns `None` if any namenode pod has no `Listener` in `listeners`, or its `Listener` +/// has no ingress address yet: the `Listener`s are created for the StatefulSet listener +/// volumes and their addresses are only written by the listener-operator afterwards, so +/// both lag behind the first reconcile runs. /// /// This _only_ supports accessing namenodes, since journalnodes are considered internal, /// and datanodes are registered dynamically with the namenodes. -pub(crate) async fn namenode_listener_refs( - client: &stackable_operator::client::Client, +pub(crate) fn namenode_listener_refs( namenode_podrefs: Vec, -) -> Result, Error> { - try_join_all(namenode_podrefs.into_iter().map(|pod_ref| async { - let listener_name = format!("{}-{}", *LISTENER_VOLUME_NAME, pod_ref.pod_name); - let listener_ref = || { - ObjectRef::::new(&listener_name) - .within(pod_ref.namespace.as_ref()) - }; - let pod_obj_ref = - || ObjectRef::::new(&pod_ref.pod_name).within(pod_ref.namespace.as_ref()); - let listener = client - .get::(&listener_name, pod_ref.namespace.as_ref()) - .await - .context(GetPodListenerSnafu { - listener: listener_ref(), - pod: pod_obj_ref(), - })?; - let listener_address = listener - .status - .and_then(|s| s.ingress_addresses?.into_iter().next()) - .context(PodListenerHasNoAddressSnafu { - listener: listener_ref(), - pod: pod_obj_ref(), - })?; - Ok(HdfsPodRef { - fqdn_override: Some(listener_address.address), - ports: listener_address - .ports - .into_iter() - .map(|(port_name, port)| { - let port = Port(u16::try_from(port).context(PortOutOfBoundsSnafu { - port_name: &port_name, - port, - })?); - Ok((port_name, port)) + listeners: &[listener::v1alpha1::Listener], +) -> Result>, Error> { + namenode_podrefs + .into_iter() + .map(|pod_ref| { + let listener_name = pod_listener_name(&pod_ref.pod_name); + let Some(listener_address) = listeners + .iter() + .find(|listener| listener.metadata.name.as_deref() == Some(&*listener_name)) + .and_then(|listener| { + listener + .status + .as_ref()? + .ingress_addresses + .as_ref()? + .first() }) - .collect::>()?, - ..pod_ref + else { + return Ok(None); + }; + + Ok(Some(HdfsPodRef { + fqdn_override: Some(listener_address.address.clone()), + ports: listener_address + .ports + .iter() + .map(|(port_name, port)| { + let port = Port(u16::try_from(*port).context(PortOutOfBoundsSnafu { + port_name, + port: *port, + })?); + Ok((port_name.clone(), port)) + }) + .collect::>()?, + ..pod_ref + })) }) - })) - .await + .collect() } /// Reference to a single `Pod` that is a component of a [`HdfsCluster`] @@ -1300,6 +1303,130 @@ spec: ); } + fn namenode_pod_ref(pod_name: &str) -> HdfsPodRef { + HdfsPodRef { + namespace: NamespaceName::from_str("test").expect("valid namespace name"), + role_group_service_name: ServiceName::from_str("hdfs-namenode-default") + .expect("valid service name"), + pod_name: pod_name.to_owned(), + fqdn_override: None, + ports: HashMap::from([("rpc".to_string(), Port(8020))]), + } + } + + fn namenode_listener(name: &str, ingress: Option<(&str, i32)>) -> listener::v1alpha1::Listener { + let mut listener = + listener::v1alpha1::Listener::new(name, listener::v1alpha1::ListenerSpec::default()); + listener.status = Some(listener::v1alpha1::ListenerStatus { + service_name: None, + ingress_addresses: ingress.map(|(address, port)| { + vec![listener::v1alpha1::ListenerIngress { + address: address.to_owned(), + address_type: listener::v1alpha1::AddressType::Hostname, + ports: BTreeMap::from([("rpc".to_string(), port)]), + }] + }), + node_ports: None, + }); + listener + } + + #[test] + fn namenode_listener_refs_with_ready_listeners() { + let pod_refs = vec![ + namenode_pod_ref("hdfs-namenode-default-0"), + namenode_pod_ref("hdfs-namenode-default-1"), + ]; + let listeners = vec![ + namenode_listener( + "listener-hdfs-namenode-default-0", + Some(("namenode-0.example.org", 31000)), + ), + namenode_listener( + "listener-hdfs-namenode-default-1", + Some(("namenode-1.example.org", 31001)), + ), + ]; + + let listener_refs = namenode_listener_refs(pod_refs, &listeners) + .expect("the listener ports should be valid") + .expect("all listeners should have an address"); + + assert_eq!(listener_refs.len(), 2); + assert_eq!( + listener_refs[0].fqdn_override.as_deref(), + Some("namenode-0.example.org") + ); + assert_eq!(listener_refs[0].ports.get("rpc"), Some(&Port(31000))); + assert_eq!( + listener_refs[1].fqdn_override.as_deref(), + Some("namenode-1.example.org") + ); + assert_eq!(listener_refs[1].ports.get("rpc"), Some(&Port(31001))); + } + + #[test] + fn namenode_listener_refs_with_missing_listener() { + let pod_refs = vec![ + namenode_pod_ref("hdfs-namenode-default-0"), + namenode_pod_ref("hdfs-namenode-default-1"), + ]; + let listeners = vec![namenode_listener( + "listener-hdfs-namenode-default-0", + Some(("namenode-0.example.org", 31000)), + )]; + + let listener_refs = namenode_listener_refs(pod_refs, &listeners) + .expect("the listener ports should be valid"); + + assert!(listener_refs.is_none()); + } + + #[test] + fn namenode_listener_refs_with_addressless_listener() { + let pod_refs = vec![namenode_pod_ref("hdfs-namenode-default-0")]; + let listeners = vec![namenode_listener("listener-hdfs-namenode-default-0", None)]; + + let listener_refs = namenode_listener_refs(pod_refs, &listeners) + .expect("the listener ports should be valid"); + + assert!(listener_refs.is_none()); + } + + #[test] + fn namenode_listener_refs_with_out_of_bounds_port() { + let pod_refs = vec![namenode_pod_ref("hdfs-namenode-default-0")]; + let listeners = vec![namenode_listener( + "listener-hdfs-namenode-default-0", + Some(("namenode-0.example.org", 100_000)), + )]; + + let result = namenode_listener_refs(pod_refs, &listeners); + + assert!(matches!(result, Err(Error::PortOutOfBounds { .. }))); + } + + #[test] + fn namenode_listener_name_matching() { + assert!(is_namenode_listener( + "listener-hdfs-namenode-default-0", + "hdfs" + )); + assert!(!is_namenode_listener( + "listener-hdfs-datanode-default-0", + "hdfs" + )); + assert!(!is_namenode_listener( + "listener-other-namenode-default-0", + "hdfs" + )); + assert!(!is_namenode_listener( + "listener-hdfs2-namenode-default-0", + "hdfs" + )); + assert!(!is_namenode_listener("unrelated", "hdfs")); + } + impl RoundtripTestData for v1alpha1::HdfsClusterSpec { fn roundtrip_test_data() -> Vec { stackable_operator::utils::yaml_from_str_singleton_map(indoc::indoc! {r#" diff --git a/rust/operator-binary/src/hdfs_controller.rs b/rust/operator-binary/src/hdfs_controller.rs index 11a97642..d5e99cad 100644 --- a/rust/operator-binary/src/hdfs_controller.rs +++ b/rust/operator-binary/src/hdfs_controller.rs @@ -150,24 +150,35 @@ pub async fn reconcile_hdfs( .await .context(ApplyResourcesSnafu)?; - // Discovery CM will fail to build until the rest of the cluster has been - // deployed, so do it last so that failure won't inhibit the rest of the - // cluster from booting up. - let discovery_cm = build_discovery_config_map( - &validated_cluster, - &client.kubernetes_cluster_info, - &crate::crd::namenode_listener_refs( - client, - build::pod_refs(&validated_cluster, &HdfsNodeRole::Name), - ) - .await - .context(CollectDiscoveryConfigSnafu)?, + // The discovery ConfigMap is built from the namenode Listeners' ingress addresses, which + // only the listener-operator writes. Around the first reconcile runs the Listeners are + // missing or still address-less; the ConfigMap is skipped then instead of failing the + // whole run -- the Listener watch triggers a new run once the addresses are set. An + // already existing discovery ConfigMap is left untouched in that window (it is applied + // outside the ClusterResources orphan tracking). + match crate::crd::namenode_listener_refs( + build::pod_refs(&validated_cluster, &HdfsNodeRole::Name), + &validated_cluster.namenode_listeners, ) - .context(BuildDiscoveryConfigMapSnafu)?; + .context(CollectDiscoveryConfigSnafu)? + { + Some(namenode_listener_refs) => { + let discovery_cm = build_discovery_config_map( + &validated_cluster, + &client.kubernetes_cluster_info, + &namenode_listener_refs, + ) + .context(BuildDiscoveryConfigMapSnafu)?; - apply_discovery_config_map(client, &discovery_cm) - .await - .context(ApplyDiscoveryConfigMapSnafu)?; + apply_discovery_config_map(client, &discovery_cm) + .await + .context(ApplyDiscoveryConfigMapSnafu)?; + } + None => tracing::info!( + "not all namenode Listeners have an ingress address yet, skipping the discovery \ + ConfigMap" + ), + } update_status(client, hdfs, &validated_cluster, &applied) .await diff --git a/rust/operator-binary/src/main.rs b/rust/operator-binary/src/main.rs index fe6d1c25..600b8cd4 100644 --- a/rust/operator-binary/src/main.rs +++ b/rust/operator-binary/src/main.rs @@ -13,6 +13,7 @@ use stackable_operator::{ YamlSchema, cli::{Command, RunArguments}, client, + crd::listener::v1alpha1::Listener, eos::EndOfSupportChecker, k8s_openapi::api::{ apps::v1::StatefulSet, @@ -37,7 +38,7 @@ use tracing::info_span; use tracing_futures::Instrument; use crate::{ - crd::{HdfsCluster, HdfsClusterVersion, v1alpha1}, + crd::{HdfsCluster, HdfsClusterVersion, is_namenode_listener, v1alpha1}, webhooks::conversion::create_webhook_server, }; @@ -149,7 +150,7 @@ async fn main() -> anyhow::Result<()> { watch_namespace.get_api::>(&client), watcher::Config::default(), ); - let config_map_store = hdfs_controller.store(); + let hdfs_cluster_store = hdfs_controller.store(); let hdfs_controller = hdfs_controller .owns( watch_namespace.get_api::>(&client), @@ -166,11 +167,25 @@ async fn main() -> anyhow::Result<()> { .watches( watch_namespace.get_api::>(&client), watcher::Config::default(), - move |config_map| { - config_map_store + { + let hdfs_cluster_store = hdfs_cluster_store.clone(); + move |config_map| { + hdfs_cluster_store + .state() + .into_iter() + .filter(move |hdfs| references_config_map(hdfs, &config_map)) + .map(|hdfs| reflector::ObjectRef::from_obj(&*hdfs)) + } + }, + ) + .watches( + watch_namespace.get_api::>(&client), + watcher::Config::default(), + move |listener| { + hdfs_cluster_store .state() .into_iter() - .filter(move |hdfs| references_config_map(hdfs, &config_map)) + .filter(move |hdfs| references_listener(hdfs, &listener)) .map(|hdfs| reflector::ObjectRef::from_obj(&*hdfs)) }, ) @@ -239,5 +254,19 @@ fn references_config_map( } } +/// Whether `listener` belongs to a namenode pod of `hdfs`. Only those `Listener`s feed the +/// discovery ConfigMap, so only they need to re-trigger a reconciliation. +fn references_listener( + hdfs: &DeserializeGuard, + listener: &DeserializeGuard, +) -> bool { + let Ok(hdfs) = &hdfs.0 else { + return false; + }; + + hdfs.metadata.namespace == listener.namespace() + && is_namenode_listener(&listener.name_any(), &hdfs.name_any()) +} + #[cfg(test)] pub(crate) mod test_support; diff --git a/rust/operator-binary/src/test_support.rs b/rust/operator-binary/src/test_support.rs index 4f47b3b9..cbb2ac95 100644 --- a/rust/operator-binary/src/test_support.rs +++ b/rust/operator-binary/src/test_support.rs @@ -30,6 +30,7 @@ pub fn validate_cluster(hdfs: &v1alpha1::HdfsCluster) -> ValidatedCluster { "oci.example.org", crate::controller::dereference::DereferencedObjects { hdfs_opa_config: None, + namenode_listeners: vec![], }, ) .expect("cluster spec should be valid") From 960a6080b59b83b04e8708b0ddfa72f4cf7d6368 Mon Sep 17 00:00:00 2001 From: Andrew Kenworthy Date: Wed, 5 Aug 2026 17:56:57 +0200 Subject: [PATCH 3/3] changelog --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c485c156..d14bb85b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,14 @@ All notable changes to this project will be documented in this file. assembles all relevant Kubernetes resources before anything is applied ([#801]). - The RBAC ServiceAccount and RoleBinding are now built with the operator-rs `v2::rbac` functions and carry the full set of recommended labels ([#806]). - - Bump stackable-operator to 0.114.0 ([#810]). +- The reconciler now applies resources and derives the cluster status in discrete + apply and update_status steps ([#811]). [#801]: https://github.com/stackabletech/hdfs-operator/pull/801 [#806]: https://github.com/stackabletech/hdfs-operator/pull/806 [#810]: https://github.com/stackabletech/hdfs-operator/pull/810 +[#811]: https://github.com/stackabletech/hdfs-operator/pull/811 ## [26.7.0] - 2026-07-21