From 3af09acace502d8b76bf2b70ecbd519e0a637dba Mon Sep 17 00:00:00 2001 From: Malte Sander Date: Wed, 5 Aug 2026 17:53:26 +0200 Subject: [PATCH 1/4] refactor: Add pipeline stage marker to KubernetesResources Makes `KubernetesResources` generic over a marker type that records how far the resources have travelled through the reconcile pipeline. The build step now returns `KubernetesResources`. This prepares the extraction of the apply and update_status steps: once an `Applied` marker exists, the type system can enforce that the cluster status is derived from the resources the API server returned, and not from the ones that were merely built. --- rust/operator-binary/src/controller/build.rs | 7 ++++--- rust/operator-binary/src/controller/mod.rs | 13 +++++++++++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/rust/operator-binary/src/controller/build.rs b/rust/operator-binary/src/controller/build.rs index 762b5158..72437d71 100644 --- a/rust/operator-binary/src/controller/build.rs +++ b/rust/operator-binary/src/controller/build.rs @@ -1,6 +1,6 @@ //! Builders that turn a `ValidatedCluster` into Kubernetes resource contents. -use std::str::FromStr; +use std::{marker::PhantomData, str::FromStr}; use snafu::{ResultExt, Snafu}; use stackable_operator::{ @@ -11,7 +11,7 @@ use stackable_operator::{ }; use crate::controller::{ - KubernetesResources, ValidatedCluster, + KubernetesResources, Prepared, ValidatedCluster, build::resource::{ config_map, listener::{build_group_listener, group_listener_name}, @@ -59,7 +59,7 @@ pub enum Error { pub fn build( cluster: &ValidatedCluster, cluster_info: &KubernetesClusterInfo, -) -> Result { +) -> Result, Error> { let mut stateful_sets = vec![]; let mut services = vec![]; let mut listeners = vec![]; @@ -148,6 +148,7 @@ pub fn build( pod_disruption_budgets, 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 551b9737..57adbf9e 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::{ @@ -67,8 +67,16 @@ pub(crate) fn shared_spooling_secret_name(cluster_name: &ClusterName) -> String // Placeholder version label value for resources whose labels must not change after deployment. stackable_operator::constant!(UNVERSIONED_PRODUCT_VERSION: ProductVersion = "none"); +/// Marker for prepared Kubernetes resources which are not applied yet. +pub struct Prepared; + /// Every Kubernetes resource produced by the client-free [`build()`](build::build) step. -pub struct KubernetesResources { +/// +/// `T` marks how far the resources have travelled through the reconcile pipeline (currently +/// [`Prepared`], later also applied). The marker lets the type system enforce, for example, that +/// the cluster status is derived from the resources the API server returned rather than from the +/// ones we merely built. +pub struct KubernetesResources { pub stateful_sets: Vec, pub services: Vec, pub listeners: Vec, @@ -76,6 +84,7 @@ pub struct KubernetesResources { pub pod_disruption_budgets: Vec, pub service_accounts: Vec, pub role_bindings: Vec, + pub status: PhantomData, } #[derive(Clone, Debug)] From 879a40ce77f54a88eb68a1efca6054a683228b98 Mon Sep 17 00:00:00 2001 From: Malte Sander Date: Wed, 5 Aug 2026 17:56:59 +0200 Subject: [PATCH 2/4] refactor: Extract the apply step into an Applier Moves the resource application out of `reconcile_trino` into a dedicated `controller::apply` module. The `Applier` owns the `ClusterResources` and turns `KubernetesResources` into `KubernetesResources`, so later steps can require resources that Kubernetes actually acknowledged. `ensure_random_secrets` moves along, since it is a read-or-create client operation that cannot live in the client free build step. It stays outside `ClusterResources` on purpose, so orphan deletion never removes it and an existing Secret is never overwritten. The apply order is unchanged. Orphaned resources are now deleted before the status is patched instead of after, which matches the sibling operators. --- rust/operator-binary/src/controller/apply.rs | 179 +++++++++++++++++++ rust/operator-binary/src/controller/mod.rs | 11 +- rust/operator-binary/src/trino_controller.rs | 145 +++------------ 3 files changed, 212 insertions(+), 123 deletions(-) create mode 100644 rust/operator-binary/src/controller/apply.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..4961f004 --- /dev/null +++ b/rust/operator-binary/src/controller/apply.rs @@ -0,0 +1,179 @@ +//! The apply step in the TrinoCluster controller. + +use std::marker::PhantomData; + +use snafu::{ResultExt, Snafu}; +use stackable_operator::{ + client::Client, + cluster_resources::{ClusterResource, ClusterResourceApplyStrategy, ClusterResources}, + commons::random_secret_creation, + deep_merger::ObjectOverrides, + v2::cluster_resources::cluster_resources_new, +}; +use strum::{EnumDiscriminants, IntoStaticStr}; + +use crate::{ + controller::{ + Applied, KubernetesResources, Prepared, ValidatedCluster, controller_name, operator_name, + product_name, shared_internal_secret_name, shared_spooling_secret_name, + }, + crd::{ENV_INTERNAL_SECRET, ENV_SPOOLING_SECRET}, +}; + +#[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 delete orphaned resources"))] + DeleteOrphanedResources { + source: stackable_operator::cluster_resources::Error, + }, + + #[snafu(display("failed to create internal secret"))] + CreateInternalSecret { + source: random_secret_creation::Error, + }, +} + +type Result = std::result::Result; + +/// Applier for the Kubernetes resource specifications produced by this controller. +/// +/// The implementation is not tied to this controller and could theoretically be moved to +/// stackable_operator if [`KubernetesResources`] would contain all possible resource types. +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. + /// + /// Resources that are owned by this cluster but no longer part of `resources` are deleted + /// afterwards. + pub async fn apply( + mut self, + resources: KubernetesResources, + ) -> Result> { + // Destructured without `..`, so that adding a field to [`KubernetesResources`] fails to + // compile here instead of the new resource silently never being applied. + let KubernetesResources { + stateful_sets, + services, + listeners, + config_maps, + pod_disruption_budgets, + service_accounts, + role_bindings, + status: _, + } = resources; + + // 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 listeners = self.add_resources(listeners).await?; + let config_maps = self.add_resources(config_maps).await?; + let pod_disruption_budgets = self.add_resources(pod_disruption_budgets).await?; + + // Note: The StatefulSet needs to be applied after all ConfigMaps and Secrets it mounts + // to prevent unnecessary Pod restarts. + // See https://github.com/stackabletech/commons-operator/issues/111 for details. + let stateful_sets = self.add_resources(stateful_sets).await?; + + self.cluster_resources + .delete_orphaned_resources(self.client) + .await + .context(DeleteOrphanedResourcesSnafu)?; + + Ok(KubernetesResources { + stateful_sets, + services, + listeners, + config_maps, + pod_disruption_budgets, + service_accounts, + role_bindings, + status: PhantomData, + }) + } + + /// Applies the given resources and returns them as the API server echoed them back. + 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) + } +} + +/// Ensures the two shared random Secrets (internal communication and spooling) exist, creating +/// any that are missing. +/// +/// These are read-or-create client operations, so they cannot be part of the client-free +/// `build()` step. They are also deliberately not tracked in [`ClusterResources`], so that they +/// survive orphan deletion and an existing Secret is never overwritten (rotating them would +/// invalidate all running queries). +pub async fn ensure_random_secrets(client: &Client, cluster: &ValidatedCluster) -> Result<()> { + random_secret_creation::create_random_secret_if_not_exists( + &shared_internal_secret_name(&cluster.name), + ENV_INTERNAL_SECRET, + 512, + cluster, + client, + ) + .await + .context(CreateInternalSecretSnafu)?; + + // This secret is created even if spooling is not configured. + // Trino currently requires the secret to be exactly 256 bits long. + random_secret_creation::create_random_secret_if_not_exists( + &shared_spooling_secret_name(&cluster.name), + ENV_SPOOLING_SECRET, + 32, + cluster, + client, + ) + .await + .context(CreateInternalSecretSnafu)?; + + Ok(()) +} diff --git a/rust/operator-binary/src/controller/mod.rs b/rust/operator-binary/src/controller/mod.rs index 57adbf9e..c1b0d272 100644 --- a/rust/operator-binary/src/controller/mod.rs +++ b/rust/operator-binary/src/controller/mod.rs @@ -43,6 +43,7 @@ use crate::{ trino_controller::{CONTROLLER_NAME, OPERATOR_NAME}, }; +pub(crate) mod apply; pub(crate) mod build; pub(crate) mod dereference; pub(crate) mod validate; @@ -70,12 +71,14 @@ stackable_operator::constant!(UNVERSIONED_PRODUCT_VERSION: ProductVersion = "non /// Marker for prepared Kubernetes resources which are not applied yet. pub struct Prepared; +/// Marker for Kubernetes resources which have been applied to the Kubernetes cluster. +pub struct Applied; + /// Every Kubernetes resource produced by the client-free [`build()`](build::build) step. /// -/// `T` marks how far the resources have travelled through the reconcile pipeline (currently -/// [`Prepared`], later also applied). The marker lets the type system enforce, for example, that -/// the cluster status is derived from the resources the API server returned rather than from the -/// ones we merely built. +/// `T` marks whether these resources are only [`Prepared`] or already [`Applied`]. The marker +/// lets the type system enforce, for example, that the cluster status is derived from the +/// resources the API server returned rather than from the ones we merely built. pub struct KubernetesResources { pub stateful_sets: Vec, pub services: Vec, diff --git a/rust/operator-binary/src/trino_controller.rs b/rust/operator-binary/src/trino_controller.rs index 238c7316..1afec9a9 100644 --- a/rust/operator-binary/src/trino_controller.rs +++ b/rust/operator-binary/src/trino_controller.rs @@ -6,7 +6,6 @@ use snafu::{ResultExt, Snafu}; use stackable_operator::{ cli::OperatorEnvironmentOptions, cluster_resources::ClusterResourceApplyStrategy, - commons::random_secret_creation, kube::{ core::{DeserializeGuard, error_boundary}, runtime::controller::Action, @@ -17,16 +16,15 @@ use stackable_operator::{ compute_conditions, operations::ClusterOperationsConditionBuilder, statefulset::StatefulSetConditionBuilder, }, - v2::cluster_resources::cluster_resources_new, }; use strum::{EnumDiscriminants, IntoStaticStr}; use crate::{ controller::{ - ValidatedCluster, build, controller_name, dereference, operator_name, product_name, - shared_internal_secret_name, shared_spooling_secret_name, validate, + apply::{self, Applier, ensure_random_secrets}, + build, dereference, validate, }, - crd::{ENV_INTERNAL_SECRET, ENV_SPOOLING_SECRET, v1alpha1}, + crd::v1alpha1, }; pub struct Ctx { @@ -43,18 +41,14 @@ pub(crate) const CONTAINER_IMAGE_BASE_NAME: &str = "trino"; #[derive(Snafu, Debug, EnumDiscriminants)] #[strum_discriminants(derive(IntoStaticStr))] pub enum Error { - #[snafu(display("failed to delete orphaned resources"))] - DeleteOrphanedResources { - source: stackable_operator::cluster_resources::Error, - }, - #[snafu(display("failed to build the Kubernetes resources"))] BuildResources { source: build::Error }, - #[snafu(display("failed to apply Kubernetes resource"))] - ApplyResource { - source: stackable_operator::cluster_resources::Error, - }, + #[snafu(display("failed to ensure the shared random Secrets exist"))] + EnsureSecrets { source: apply::Error }, + + #[snafu(display("failed to apply the Kubernetes resources"))] + ApplyResources { source: apply::Error }, #[snafu(display("failed to update status"))] ApplyStatus { @@ -71,11 +65,6 @@ pub enum Error { #[snafu(display("failed to validate cluster"))] ValidateCluster { source: validate::Error }, - - #[snafu(display("failed to create internal secret"))] - CreateInternalSecret { - source: random_secret_creation::Error, - }, } type Result = std::result::Result; @@ -115,76 +104,29 @@ pub async fn reconcile_trino( "Validated TrinoCluster" ); - let mut cluster_resources = cluster_resources_new( - &product_name(), - &operator_name(), - &controller_name(), - &validated_cluster.name, - &validated_cluster.namespace, - &validated_cluster.uid, - ClusterResourceApplyStrategy::from(&trino.spec.cluster_operation), - &trino.spec.object_overrides, - ); - - ensure_random_secrets(client, &validated_cluster).await?; - + // build (no client required) let resources = build::build(&validated_cluster, &client.kubernetes_cluster_info) .context(BuildResourcesSnafu)?; - let mut sts_cond_builder = StatefulSetConditionBuilder::default(); - - 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 listener in resources.listeners { - cluster_resources - .add(client, listener) - .await - .context(ApplyResourceSnafu)?; - } - - for config_map in resources.config_maps { - cluster_resources - .add(client, config_map) - .await - .context(ApplyResourceSnafu)?; - } + // apply (client required) + ensure_random_secrets(client, &validated_cluster) + .await + .context(EnsureSecretsSnafu)?; - for pdb in resources.pod_disruption_budgets { - cluster_resources - .add(client, pdb) - .await - .context(ApplyResourceSnafu)?; - } + let applied = Applier::new( + client, + &validated_cluster, + ClusterResourceApplyStrategy::from(&trino.spec.cluster_operation), + &trino.spec.object_overrides, + ) + .apply(resources) + .await + .context(ApplyResourcesSnafu)?; - // Note: The StatefulSet needs to be applied after all ConfigMaps and Secrets it mounts - // to prevent unnecessary Pod restarts. - // See https://github.com/stackabletech/commons-operator/issues/111 for details. - for stateful_set in resources.stateful_sets { - sts_cond_builder.add( - cluster_resources - .add(client, stateful_set) - .await - .context(ApplyResourceSnafu)?, - ); + // update status (client required) + let mut sts_cond_builder = StatefulSetConditionBuilder::default(); + for stateful_set in &applied.stateful_sets { + sts_cond_builder.add(stateful_set.clone()); } let cluster_operation_cond_builder = @@ -197,10 +139,6 @@ pub async fn reconcile_trino( ), }; - cluster_resources - .delete_orphaned_resources(client) - .await - .context(DeleteOrphanedResourcesSnafu)?; client .apply_patch_status(OPERATOR_NAME, trino, &status) .await @@ -209,37 +147,6 @@ pub async fn reconcile_trino( Ok(Action::await_change()) } -/// Ensures the two shared random Secrets (internal communication and spooling) exist, creating -/// any that are missing. -async fn ensure_random_secrets( - client: &stackable_operator::client::Client, - cluster: &ValidatedCluster, -) -> Result<()> { - random_secret_creation::create_random_secret_if_not_exists( - &shared_internal_secret_name(&cluster.name), - ENV_INTERNAL_SECRET, - 512, - cluster, - client, - ) - .await - .context(CreateInternalSecretSnafu)?; - - // This secret is created even if spooling is not configured. - // Trino currently requires the secret to be exactly 256 bits long. - random_secret_creation::create_random_secret_if_not_exists( - &shared_spooling_secret_name(&cluster.name), - ENV_SPOOLING_SECRET, - 32, - cluster, - client, - ) - .await - .context(CreateInternalSecretSnafu)?; - - Ok(()) -} - pub fn error_policy( _obj: Arc>, error: &Error, From 0b95b104165b1fe35f72c0cb578ec1c60136a457 Mon Sep 17 00:00:00 2001 From: Malte Sander Date: Wed, 5 Aug 2026 18:02:29 +0200 Subject: [PATCH 3/4] refactor: Extract the update_status step Moves the cluster status computation out of `reconcile_trino` into a dedicated `controller::update_status` module. It accepts only `KubernetesResources`, so the type system enforces that the conditions are derived from the resources the API server acknowledged and not from the ones that were merely built. --- rust/operator-binary/src/controller/mod.rs | 1 + .../src/controller/update_status.rs | 61 +++++++++++++++++++ rust/operator-binary/src/trino_controller.rs | 42 +++++-------- 3 files changed, 76 insertions(+), 28 deletions(-) create mode 100644 rust/operator-binary/src/controller/update_status.rs diff --git a/rust/operator-binary/src/controller/mod.rs b/rust/operator-binary/src/controller/mod.rs index c1b0d272..cd255751 100644 --- a/rust/operator-binary/src/controller/mod.rs +++ b/rust/operator-binary/src/controller/mod.rs @@ -46,6 +46,7 @@ use crate::{ pub(crate) mod apply; pub(crate) mod build; pub(crate) mod dereference; +pub(crate) mod update_status; pub(crate) mod validate; pub use stackable_operator::v2::product_logging::framework::STACKABLE_LOG_DIR; 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..9dc358c8 --- /dev/null +++ b/rust/operator-binary/src/controller/update_status.rs @@ -0,0 +1,61 @@ +//! The update_status step in the TrinoCluster controller. + +use snafu::{ResultExt, Snafu}; +use stackable_operator::{ + client::Client, + status::condition::{ + compute_conditions, operations::ClusterOperationsConditionBuilder, + statefulset::StatefulSetConditionBuilder, + }, +}; +use strum::{EnumDiscriminants, IntoStaticStr}; + +use crate::{ + controller::{Applied, KubernetesResources}, + crd::v1alpha1, + trino_controller::OPERATOR_NAME, +}; + +#[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 applied resources and patches it onto the +/// [`v1alpha1::TrinoCluster`]. +/// +/// Takes [`KubernetesResources`], so the type system proves that the status is derived +/// from the resources the API server acknowledged and not from the ones we merely built. +pub async fn update_status( + client: &Client, + trino: &v1alpha1::TrinoCluster, + applied: &KubernetesResources, +) -> Result<()> { + let mut sts_cond_builder = StatefulSetConditionBuilder::default(); + for stateful_set in &applied.stateful_sets { + sts_cond_builder.add(stateful_set.clone()); + } + + let cluster_operation_cond_builder = + ClusterOperationsConditionBuilder::new(&trino.spec.cluster_operation); + + let status = v1alpha1::TrinoClusterStatus { + conditions: compute_conditions( + trino, + &[&sts_cond_builder, &cluster_operation_cond_builder], + ), + }; + + client + .apply_patch_status(OPERATOR_NAME, trino, &status) + .await + .context(ApplyStatusSnafu)?; + + Ok(()) +} diff --git a/rust/operator-binary/src/trino_controller.rs b/rust/operator-binary/src/trino_controller.rs index 1afec9a9..d302e912 100644 --- a/rust/operator-binary/src/trino_controller.rs +++ b/rust/operator-binary/src/trino_controller.rs @@ -1,4 +1,10 @@ -//! Ensures that `Pod`s are configured and running for each [`v1alpha1::TrinoCluster`] +//! Ensures that `Pod`s are configured and running for each [`v1alpha1::TrinoCluster`]. +//! +//! This is the controller driver: it runs the +//! `dereference -> validate -> build -> apply -> update_status` pipeline. The validated cluster +//! type and the individual steps live under the [`crate::controller`] module tree; this file is +//! kept next to `main.rs` for consistency with the other Stackable operators. + use std::sync::Arc; use const_format::concatcp; @@ -12,17 +18,15 @@ use stackable_operator::{ }, logging::controller::ReconcilerError, shared::time::Duration, - status::condition::{ - compute_conditions, operations::ClusterOperationsConditionBuilder, - statefulset::StatefulSetConditionBuilder, - }, }; use strum::{EnumDiscriminants, IntoStaticStr}; use crate::{ controller::{ apply::{self, Applier, ensure_random_secrets}, - build, dereference, validate, + build, dereference, + update_status::{self, update_status}, + validate, }, crd::v1alpha1, }; @@ -50,10 +54,8 @@ pub enum Error { #[snafu(display("failed to apply the Kubernetes resources"))] ApplyResources { source: apply::Error }, - #[snafu(display("failed to update status"))] - ApplyStatus { - source: stackable_operator::client::Error, - }, + #[snafu(display("failed to update the cluster status"))] + UpdateStatus { source: update_status::Error }, #[snafu(display("invalid TrinoCluster object"))] InvalidTrinoCluster { @@ -124,25 +126,9 @@ pub async fn reconcile_trino( .context(ApplyResourcesSnafu)?; // update status (client required) - let mut sts_cond_builder = StatefulSetConditionBuilder::default(); - for stateful_set in &applied.stateful_sets { - sts_cond_builder.add(stateful_set.clone()); - } - - let cluster_operation_cond_builder = - ClusterOperationsConditionBuilder::new(&trino.spec.cluster_operation); - - let status = v1alpha1::TrinoClusterStatus { - conditions: compute_conditions( - trino, - &[&sts_cond_builder, &cluster_operation_cond_builder], - ), - }; - - client - .apply_patch_status(OPERATOR_NAME, trino, &status) + update_status(client, trino, &applied) .await - .context(ApplyStatusSnafu)?; + .context(UpdateStatusSnafu)?; Ok(Action::await_change()) } From 8d69bf24d6cd7baa3bd2b1c85b3288054fa3991a Mon Sep 17 00:00:00 2001 From: Malte Sander Date: Wed, 5 Aug 2026 18:06:13 +0200 Subject: [PATCH 4/4] chore: adapt changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56cd009a..ed06ce44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,10 +13,13 @@ All notable changes to this project will be documented in this file. functions and carry the full set of recommended labels ([#913]). - BREAKING: The `coordinators` and `workers` roles are now required by the CRD. Previously a TrinoCluster missing either role was accepted by the API server but failed reconciliation ([#913]). +- The reconciler now applies resources and derives the cluster status in discrete + apply and update_status steps for the `trino_controller` ([#923]). [#909]: https://github.com/stackabletech/trino-operator/pull/909 [#913]: https://github.com/stackabletech/trino-operator/pull/913 [#918]: https://github.com/stackabletech/trino-operator/pull/918 +[#923]: https://github.com/stackabletech/trino-operator/pull/923 ## [26.7.0] - 2026-07-21