From 9c093080c7fa9ded58f9c706f4a43ff6051321f2 Mon Sep 17 00:00:00 2001 From: Techassi Date: Tue, 21 Apr 2026 13:49:59 +0200 Subject: [PATCH 1/6] feat(operator)!: Support dynamic product image selection --- .../stackable-operator/crds/DummyCluster.yaml | 27 ++- .../stackable-operator/src/cli/environment.rs | 21 +++ .../src/commons/product_image_selection.rs | 157 +++++++++++++----- 3 files changed, 162 insertions(+), 43 deletions(-) diff --git a/crates/stackable-operator/crds/DummyCluster.yaml b/crates/stackable-operator/crds/DummyCluster.yaml index f6eb9e955..015530393 100644 --- a/crates/stackable-operator/crds/DummyCluster.yaml +++ b/crates/stackable-operator/crds/DummyCluster.yaml @@ -1781,8 +1781,9 @@ spec: properties: custom: description: |- - Overwrite the docker image. - Specify the full docker image name, e.g. `oci.stackable.tech/sdp/superset:1.4.1-stackable2.1.0` + Overwrite the container image. + + Specify the full container image name, e.g. `oci.stackable.tech/sdp/superset:1.4.1-stackable2.1.0` type: string productVersion: description: Version of the product, e.g. `1.4.1`. @@ -1808,15 +1809,29 @@ spec: type: object nullable: true type: array - repo: - description: Name of the docker repo, e.g. `oci.stackable.tech/sdp` + registry: + description: |- + The container image registry, e.g. `oci.stackable.tech`. + + If not specified, the operator will use the image registry provided via the operator + environment options. + nullable: true + type: string + repository: + description: |- + The repository on the container image registry where the container image is located, e.g. + `sdp/airflow`. + + If not specified, the operator will use the image registry provided via the operator + environment options. nullable: true type: string stackableVersion: description: |- Stackable version of the product, e.g. `23.4`, `23.4.1` or `0.0.0-dev`. - If not specified, the operator will use its own version, e.g. `23.4.1`. - When using a nightly operator or a pr version, it will use the nightly `0.0.0-dev` image. + + If not specified, the operator will use its own version, e.g. `23.4.1`. When using a nightly + operator or a PR version, it will use the nightly `0.0.0-dev` image. nullable: true type: string type: object diff --git a/crates/stackable-operator/src/cli/environment.rs b/crates/stackable-operator/src/cli/environment.rs index bf6d28f22..b62498d77 100644 --- a/crates/stackable-operator/src/cli/environment.rs +++ b/crates/stackable-operator/src/cli/environment.rs @@ -13,4 +13,25 @@ pub struct OperatorEnvironmentOptions { /// something like `-operator`. #[arg(long, env)] pub operator_service_name: String, + + /// The image registry which should be used when resolving images provisioned by the operator. + /// + /// Example values include: `127.0.0.1` or `oci.example.org`. + /// + /// Note that when running the operator on Kubernetes we recommend to provide this value via + /// the deployment mechanism, like Helm. + #[arg(long, env, value_parser = url::Host::parse)] + pub image_registry: url::Host, + + /// The image repository used in conjunction with the `image_registry` to form the final image + /// name. + /// + /// Example values include: `airflow-operator` or `path/to/hbase-operator`. + /// + /// Note that when running the operator on Kubernetes we recommend to provide this value via + /// the deployment mechanism, like Helm. Additionally, care must be taken when this value is + /// used as part of the product image selection, as it (most likely) includes the `-operator` + /// suffix. + #[arg(long, env)] + pub image_repository: String, } diff --git a/crates/stackable-operator/src/commons/product_image_selection.rs b/crates/stackable-operator/src/commons/product_image_selection.rs index a11d823df..6d52822dd 100644 --- a/crates/stackable-operator/src/commons/product_image_selection.rs +++ b/crates/stackable-operator/src/commons/product_image_selection.rs @@ -42,7 +42,7 @@ pub struct ProductImage { #[serde(rename_all = "camelCase")] #[serde(untagged)] pub enum ProductImageSelection { - // Order matters! + // NOTE: Order matters! // The variants will be tried from top to bottom Custom(ProductImageCustom), StackableVersion(ProductImageStackableVersion), @@ -51,9 +51,11 @@ pub enum ProductImageSelection { #[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct ProductImageCustom { - /// Overwrite the docker image. - /// Specify the full docker image name, e.g. `oci.stackable.tech/sdp/superset:1.4.1-stackable2.1.0` + /// Overwrite the container image. + /// + /// Specify the full container image name, e.g. `oci.stackable.tech/sdp/superset:1.4.1-stackable2.1.0` custom: String, + /// Version of the product, e.g. `1.4.1`. product_version: String, } @@ -63,12 +65,25 @@ pub struct ProductImageCustom { pub struct ProductImageStackableVersion { /// Version of the product, e.g. `1.4.1`. product_version: String, + /// Stackable version of the product, e.g. `23.4`, `23.4.1` or `0.0.0-dev`. - /// If not specified, the operator will use its own version, e.g. `23.4.1`. - /// When using a nightly operator or a pr version, it will use the nightly `0.0.0-dev` image. + /// + /// If not specified, the operator will use its own version, e.g. `23.4.1`. When using a nightly + /// operator or a PR version, it will use the nightly `0.0.0-dev` image. stackable_version: Option, - /// Name of the docker repo, e.g. `oci.stackable.tech/sdp` - repo: Option, + + /// The container image registry, e.g. `oci.stackable.tech`. + /// + /// If not specified, the operator will use the image registry provided via the operator + /// environment options. + registry: Option, + + /// The repository on the container image registry where the container image is located, e.g. + /// `sdp/airflow`. + /// + /// If not specified, the operator will use the image registry provided via the operator + /// environment options. + repository: Option, } #[derive(Clone, Debug, PartialEq, JsonSchema)] @@ -106,12 +121,23 @@ pub enum PullPolicy { } impl ProductImage { - /// `image_base_name` should be base of the image name in the container image registry, e.g. `trino`. - /// `operator_version` needs to be the full operator version and a valid semver string. - /// Accepted values are `23.7.0`, `0.0.0-dev` or `0.0.0-pr123`. Other variants are not supported. + /// Resolves the product image to be used for containers. + /// + /// ### Parameters + /// + /// - `image_registry`: The default image registry which should be used when no custom registry + /// is specified. This value should come from the operator environment options, which are + /// provided via Helm for example. Example value: `oci.example.org` + /// - `image_repository`: The default repository on the image registry where the container image + /// is located. This value should come from the operator environment options, which are + /// provided via Helm for example. Example value: `my/namespace/image`. + /// - `operator_version`: The version must be the full operator version and a valid semver + /// string. Accepted values are `23.7.0`, `0.0.0-dev` or `0.0.0-pr123`. Other variants are not + /// supported. pub fn resolve( &self, - image_base_name: &str, + image_registry: &str, + image_repository: &str, operator_version: &str, ) -> Result { let image_pull_policy = self.pull_policy.as_ref().to_string(); @@ -139,10 +165,16 @@ impl ProductImage { }) } ProductImageSelection::StackableVersion(image_selection) => { - let repo = image_selection - .repo + let registry = image_selection + .registry .as_deref() - .unwrap_or(STACKABLE_DOCKER_REPO); + .unwrap_or(image_registry); + + let repository = image_selection + .repository + .as_deref() + .unwrap_or(image_repository); + let stackable_version = match &image_selection.stackable_version { Some(stackable_version) => stackable_version, None => { @@ -159,11 +191,11 @@ impl ProductImage { } } }; - let image = format!( - "{repo}/{image_base_name}:{product_version}-stackable{stackable_version}", - ); + let app_version = format!("{product_version}-stackable{stackable_version}"); let app_version_label_value = Self::prepare_app_version_label_value(&app_version)?; + let image = format!("{registry}/{repository}:{app_version}",); + Ok(ResolvedProductImage { product_version, app_version_label_value, @@ -215,7 +247,8 @@ mod tests { #[rstest] #[case::stackable_version_without_stackable_version_stable_version( - "superset", + "oci.stackable.tech", + "sdp/superset", "23.7.42", r" productVersion: 1.4.1 @@ -229,7 +262,8 @@ mod tests { } )] #[case::stackable_version_without_stackable_version_nightly( - "superset", + "oci.stackable.tech", + "sdp/superset", "0.0.0-dev", r" productVersion: 1.4.1 @@ -243,7 +277,8 @@ mod tests { } )] #[case::stackable_version_without_stackable_version_pr_version( - "superset", + "oci.stackable.tech", + "sdp/superset", "0.0.0-pr123", r" productVersion: 1.4.1 @@ -257,7 +292,8 @@ mod tests { } )] #[case::stackable_version_without_repo( - "superset", + "oci.stackable.tech", + "sdp/superset", "23.7.42", r" productVersion: 1.4.1 @@ -271,16 +307,52 @@ mod tests { pull_secrets: None, } )] - #[case::stackable_version_with_repo( - "trino", + #[case::stackable_version_with_registry( + "oci.stackable.tech", + "sdp/trino", + "23.7.42", + r" + productVersion: 1.4.1 + stackableVersion: 2.1.0 + registry: oci.example.org + ", + ResolvedProductImage { + image: "oci.example.org/sdp/trino:1.4.1-stackable2.1.0".to_string(), + app_version_label_value: "1.4.1-stackable2.1.0".parse().expect("static app version label is always valid"), + product_version: "1.4.1".to_string(), + image_pull_policy: "Always".to_string(), + pull_secrets: None, + } + )] + #[case::stackable_version_with_repository( + "oci.stackable.tech", + "sdp/trino", + "23.7.42", + r" + productVersion: 1.4.1 + stackableVersion: 2.1.0 + repository: stackable/trino + ", + ResolvedProductImage { + image: "oci.stackable.tech/stackable/trino:1.4.1-stackable2.1.0".to_string(), + app_version_label_value: "1.4.1-stackable2.1.0".parse().expect("static app version label is always valid"), + product_version: "1.4.1".to_string(), + image_pull_policy: "Always".to_string(), + pull_secrets: None, + } + )] + #[case::stackable_version_with_registry_and_repository( + "oci.stackable.tech", + "sdp/trino", "23.7.42", r" productVersion: 1.4.1 stackableVersion: 2.1.0 - repo: my.corp/myteam/stackable + registry: quay.io + repository: stackable/trino ", ResolvedProductImage { - image: "my.corp/myteam/stackable/trino:1.4.1-stackable2.1.0".to_string(), + image: "quay.io/stackable/trino:1.4.1-stackable2.1.0".to_string(), app_version_label_value: "1.4.1-stackable2.1.0".parse().expect("static app version label is always valid"), product_version: "1.4.1".to_string(), image_pull_policy: "Always".to_string(), @@ -288,7 +360,8 @@ mod tests { } )] #[case::custom_without_tag( - "superset", + "oci.stackable.tech", + "sdp/superset", "23.7.42", r" custom: my.corp/myteam/stackable/superset @@ -303,7 +376,8 @@ mod tests { } )] #[case::custom_with_tag( - "superset", + "oci.stackable.tech", + "sdp/superset", "23.7.42", r" custom: my.corp/myteam/stackable/superset:latest-and-greatest @@ -318,7 +392,8 @@ mod tests { } )] #[case::custom_with_colon_in_repo_and_without_tag( - "superset", + "oci.stackable.tech", + "sdp/superset", "23.7.42", r" custom: 127.0.0.1:8080/myteam/stackable/superset @@ -333,7 +408,8 @@ mod tests { } )] #[case::custom_with_colon_in_repo_and_with_tag( - "superset", + "oci.stackable.tech", + "sdp/superset", "23.7.42", r" custom: 127.0.0.1:8080/myteam/stackable/superset:latest-and-greatest @@ -348,7 +424,8 @@ mod tests { } )] #[case::custom_with_hash_in_repo_and_without_tag( - "superset", + "oci.stackable.tech", + "sdp/superset", "23.7.42", r" custom: oci.stackable.tech/sdp/superset@sha256:85fa483aa99b9997ce476b86893ad5ed81fb7fd2db602977eb8c42f76efc1098 @@ -363,7 +440,8 @@ mod tests { } )] #[case::custom_takes_precedence( - "superset", + "oci.stackable.tech", + "sdp/superset", "23.7.42", r" custom: my.corp/myteam/stackable/superset:latest-and-greatest @@ -379,7 +457,8 @@ mod tests { } )] #[case::pull_policy_if_not_present( - "superset", + "oci.stackable.tech", + "sdp/superset", "23.7.42", r" custom: my.corp/myteam/stackable/superset:latest-and-greatest @@ -395,7 +474,8 @@ mod tests { } )] #[case::pull_policy_always( - "superset", + "oci.stackable.tech", + "sdp/superset", "23.7.42", r" custom: my.corp/myteam/stackable/superset:latest-and-greatest @@ -411,7 +491,8 @@ mod tests { } )] #[case::pull_policy_never( - "superset", + "oci.stackable.tech", + "sdp/superset", "23.7.42", r" custom: my.corp/myteam/stackable/superset:latest-and-greatest @@ -427,7 +508,8 @@ mod tests { } )] #[case::pull_secrets( - "superset", + "oci.stackable.tech", + "sdp/superset", "23.7.42", r" custom: my.corp/myteam/stackable/superset:latest-and-greatest @@ -446,14 +528,15 @@ mod tests { } )] fn resolved_image_pass( - #[case] image_base_name: String, + #[case] image_registry: String, + #[case] image_repository: String, #[case] operator_version: String, #[case] input: String, #[case] expected: ResolvedProductImage, ) { let product_image: ProductImage = serde_yaml::from_str(&input).expect("Illegal test input"); let resolved_product_image = product_image - .resolve(&image_base_name, &operator_version) + .resolve(&image_registry, &image_repository, &operator_version) .expect("Illegal test input"); assert_eq!(resolved_product_image, expected); From 3a13c9d60b87024bc86b6b2c6308e4d5a6c16062 Mon Sep 17 00:00:00 2001 From: Techassi Date: Tue, 21 Apr 2026 14:41:15 +0200 Subject: [PATCH 2/6] chore(operator): Add changelog entries --- crates/stackable-operator/CHANGELOG.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/stackable-operator/CHANGELOG.md b/crates/stackable-operator/CHANGELOG.md index 54eabd978..0fa39474e 100644 --- a/crates/stackable-operator/CHANGELOG.md +++ b/crates/stackable-operator/CHANGELOG.md @@ -4,6 +4,24 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- BREAKING: Add two required CLI arguments and env vars to set image registry and repository ([#1199]): + - `IMAGE_REGISTRY` (`--image-registry`): Sets the image registry which should be used by the + operator to construct image names for provisioned containers, eg. `oci.example.org`. + - `IMAGE_REPOSITORY` (`--image-repository`): Sets the image repository which should be used by the + operator to construct image names for provisioned containers, eg. `my/repository/to/operator`. + +### Changed + +- BREAKING: The product image selection mechanism via `ProductImage::resolve` now takes three + parameters instead of two. The new parameters are: `image_registry`, `image_repository`, and + `operator_version`. +- BREAKING: The product image selection CRD interface splits up the `repo` key into `registry` and + `repository` for more clarity and consistency ([#1199]). + +[#1199]: https://github.com/stackabletech/operator-rs/pull/1199 + ## [0.110.1] - 2026-04-16 ### Added From f2f25ca784ffc564fef8fdd52fa230a282bd3679 Mon Sep 17 00:00:00 2001 From: Techassi Date: Thu, 23 Apr 2026 15:08:39 +0200 Subject: [PATCH 3/6] refactor(operator): Align code with decision This implements the decision taken in https://github.com/stackabletech/decisions/issues/85 --- .../stackable-operator/crds/DummyCluster.yaml | 16 +- .../stackable-operator/src/cli/environment.rs | 17 +- .../src/commons/product_image_selection.rs | 208 +++++++++--------- 3 files changed, 114 insertions(+), 127 deletions(-) diff --git a/crates/stackable-operator/crds/DummyCluster.yaml b/crates/stackable-operator/crds/DummyCluster.yaml index 015530393..3ca133c09 100644 --- a/crates/stackable-operator/crds/DummyCluster.yaml +++ b/crates/stackable-operator/crds/DummyCluster.yaml @@ -1781,9 +1781,9 @@ spec: properties: custom: description: |- - Overwrite the container image. + Provide a custom container image. - Specify the full container image name, e.g. `oci.stackable.tech/sdp/superset:1.4.1-stackable2.1.0` + Specify the full container image name, e.g. `oci.example.tech/namespace/superset:1.4.1-my-tag` type: string productVersion: description: Version of the product, e.g. `1.4.1`. @@ -1809,18 +1809,10 @@ spec: type: object nullable: true type: array - registry: - description: |- - The container image registry, e.g. `oci.stackable.tech`. - - If not specified, the operator will use the image registry provided via the operator - environment options. - nullable: true - type: string - repository: + repo: description: |- The repository on the container image registry where the container image is located, e.g. - `sdp/airflow`. + `oci.example.com/namespace`. If not specified, the operator will use the image registry provided via the operator environment options. diff --git a/crates/stackable-operator/src/cli/environment.rs b/crates/stackable-operator/src/cli/environment.rs index b62498d77..72db906d1 100644 --- a/crates/stackable-operator/src/cli/environment.rs +++ b/crates/stackable-operator/src/cli/environment.rs @@ -14,24 +14,13 @@ pub struct OperatorEnvironmentOptions { #[arg(long, env)] pub operator_service_name: String, - /// The image registry which should be used when resolving images provisioned by the operator. + /// The image repository which should be used when resolving images provisioned by the operator. /// - /// Example values include: `127.0.0.1` or `oci.example.org`. + /// This argument expects a valid registry host and path. Valid values include: + /// `oci.example.org/my/namespace` or `quay.io/organization` /// /// Note that when running the operator on Kubernetes we recommend to provide this value via /// the deployment mechanism, like Helm. - #[arg(long, env, value_parser = url::Host::parse)] - pub image_registry: url::Host, - - /// The image repository used in conjunction with the `image_registry` to form the final image - /// name. - /// - /// Example values include: `airflow-operator` or `path/to/hbase-operator`. - /// - /// Note that when running the operator on Kubernetes we recommend to provide this value via - /// the deployment mechanism, like Helm. Additionally, care must be taken when this value is - /// used as part of the product image selection, as it (most likely) includes the `-operator` - /// suffix. #[arg(long, env)] pub image_repository: String, } diff --git a/crates/stackable-operator/src/commons/product_image_selection.rs b/crates/stackable-operator/src/commons/product_image_selection.rs index 6d52822dd..c71f442ce 100644 --- a/crates/stackable-operator/src/commons/product_image_selection.rs +++ b/crates/stackable-operator/src/commons/product_image_selection.rs @@ -44,16 +44,16 @@ pub struct ProductImage { pub enum ProductImageSelection { // NOTE: Order matters! // The variants will be tried from top to bottom - Custom(ProductImageCustom), - StackableVersion(ProductImageStackableVersion), + Custom(CustomProductImage), + Auto(AutoProductImage), } #[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] -pub struct ProductImageCustom { - /// Overwrite the container image. +pub struct CustomProductImage { + /// Provide a custom container image. /// - /// Specify the full container image name, e.g. `oci.stackable.tech/sdp/superset:1.4.1-stackable2.1.0` + /// Specify the full container image name, e.g. `oci.example.tech/namespace/superset:1.4.1-my-tag` custom: String, /// Version of the product, e.g. `1.4.1`. @@ -62,7 +62,7 @@ pub struct ProductImageCustom { #[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] -pub struct ProductImageStackableVersion { +pub struct AutoProductImage { /// Version of the product, e.g. `1.4.1`. product_version: String, @@ -72,18 +72,12 @@ pub struct ProductImageStackableVersion { /// operator or a PR version, it will use the nightly `0.0.0-dev` image. stackable_version: Option, - /// The container image registry, e.g. `oci.stackable.tech`. - /// - /// If not specified, the operator will use the image registry provided via the operator - /// environment options. - registry: Option, - /// The repository on the container image registry where the container image is located, e.g. - /// `sdp/airflow`. + /// `oci.example.com/namespace`. /// /// If not specified, the operator will use the image registry provided via the operator /// environment options. - repository: Option, + repo: Option, } #[derive(Clone, Debug, PartialEq, JsonSchema)] @@ -125,57 +119,70 @@ impl ProductImage { /// /// ### Parameters /// - /// - `image_registry`: The default image registry which should be used when no custom registry - /// is specified. This value should come from the operator environment options, which are - /// provided via Helm for example. Example value: `oci.example.org` - /// - `image_repository`: The default repository on the image registry where the container image - /// is located. This value should come from the operator environment options, which are - /// provided via Helm for example. Example value: `my/namespace/image`. + /// - `image_name`: The final part of the complete image reference, the name of the image. + /// Example values: `airflow` or `nginx`. + /// - `image_repository`: The default repository consisting of a registry host and path. This + /// value should come from the operator environment options, which are provided via Helm for + /// example. Example value: `oci.example.org/my/namespace`. /// - `operator_version`: The version must be the full operator version and a valid semver /// string. Accepted values are `23.7.0`, `0.0.0-dev` or `0.0.0-pr123`. Other variants are not /// supported. + /// + /// ### Resolve mechanism + /// + /// The final product image is resolved in one of two ways defined by the [`ProductImageSelection`]: + /// + /// 1. When [`ProductImageSelection::Auto`] is selected by the user, the final product image + /// will be constructed based on the (user) provided values. + /// 2. When [`ProductImageSelection::Custom`] is selected by the user, the final product image + /// will be the exact value specified by the user. + // + // NOTE (@Techassi): The operator_version should probably be a Semver instead of a plain string pub fn resolve( &self, - image_registry: &str, + image_name: &str, image_repository: &str, operator_version: &str, ) -> Result { let image_pull_policy = self.pull_policy.as_ref().to_string(); let pull_secrets = self.pull_secrets.clone(); - let product_version = self.product_version().to_owned(); - match &self.image_selection { - ProductImageSelection::Custom(image_selection) => { - let image = ImageRef::parse(&image_selection.custom); - let image_tag_or_hash = image + ProductImageSelection::Custom(CustomProductImage { + custom, + product_version, + }) => { + let image_ref = ImageRef::parse(custom); + let image_tag_or_hash = image_ref .tag - .or(image.hash) + .or(image_ref.hash) .unwrap_or_else(|| "latest".to_string()); let app_version = format!("{product_version}-{image_tag_or_hash}"); let app_version_label_value = Self::prepare_app_version_label_value(&app_version)?; Ok(ResolvedProductImage { - product_version, + product_version: product_version.to_owned(), app_version_label_value, - image: image_selection.custom.clone(), + image: custom.to_owned(), image_pull_policy, pull_secrets, }) } - ProductImageSelection::StackableVersion(image_selection) => { - let registry = image_selection - .registry - .as_deref() - .unwrap_or(image_registry); - - let repository = image_selection - .repository + ProductImageSelection::Auto(AutoProductImage { + product_version, + stackable_version, + repo, + }) => { + let image_repository = repo .as_deref() - .unwrap_or(image_repository); + .unwrap_or(image_repository) + // Remove and leading and trailing whitespace + .trim() + // Trim the end to ensure no double slashes are produced below + .trim_end_matches('/'); - let stackable_version = match &image_selection.stackable_version { + let stackable_version = match stackable_version { Some(stackable_version) => stackable_version, None => { if operator_version.starts_with("0.0.0-pr") { @@ -192,12 +199,14 @@ impl ProductImage { } }; + // Trim the start to ensure no double slashes are produced below + let image_name = image_name.trim_start_matches('/'); let app_version = format!("{product_version}-stackable{stackable_version}"); let app_version_label_value = Self::prepare_app_version_label_value(&app_version)?; - let image = format!("{registry}/{repository}:{app_version}",); + let image = format!("{image_repository}/{image_name}:{app_version}"); Ok(ResolvedProductImage { - product_version, + product_version: product_version.to_owned(), app_version_label_value, image, image_pull_policy, @@ -212,11 +221,11 @@ impl ProductImage { /// automatically, e.g. from the LTS release line. pub fn product_version(&self) -> &str { match &self.image_selection { - ProductImageSelection::Custom(ProductImageCustom { + ProductImageSelection::Custom(CustomProductImage { product_version: pv, .. }) - | ProductImageSelection::StackableVersion(ProductImageStackableVersion { + | ProductImageSelection::Auto(AutoProductImage { product_version: pv, .. }) => pv, @@ -246,9 +255,9 @@ mod tests { use super::*; #[rstest] - #[case::stackable_version_without_stackable_version_stable_version( - "oci.stackable.tech", - "sdp/superset", + #[case::auto_with_leading_slash_in_name( + "/superset", + "oci.stackable.tech/sdp", "23.7.42", r" productVersion: 1.4.1 @@ -261,25 +270,25 @@ mod tests { pull_secrets: None, } )] - #[case::stackable_version_without_stackable_version_nightly( - "oci.stackable.tech", - "sdp/superset", - "0.0.0-dev", + #[case::auto_without_stackable_version_stable_version( + "superset", + "oci.stackable.tech/sdp", + "23.7.42", r" productVersion: 1.4.1 ", ResolvedProductImage { - image: "oci.stackable.tech/sdp/superset:1.4.1-stackable0.0.0-dev".to_string(), - app_version_label_value: "1.4.1-stackable0.0.0-dev".parse().expect("static app version label is always valid"), + image: "oci.stackable.tech/sdp/superset:1.4.1-stackable23.7.42".to_string(), + app_version_label_value: "1.4.1-stackable23.7.42".parse().expect("static app version label is always valid"), product_version: "1.4.1".to_string(), image_pull_policy: "Always".to_string(), pull_secrets: None, } )] - #[case::stackable_version_without_stackable_version_pr_version( - "oci.stackable.tech", - "sdp/superset", - "0.0.0-pr123", + #[case::auto_without_stackable_version_nightly( + "superset", + "oci.stackable.tech/sdp", + "0.0.0-dev", r" productVersion: 1.4.1 ", @@ -291,68 +300,65 @@ mod tests { pull_secrets: None, } )] - #[case::stackable_version_without_repo( - "oci.stackable.tech", - "sdp/superset", - "23.7.42", + #[case::auto_without_stackable_version_pr_version( + "superset", + "oci.stackable.tech/sdp", + "0.0.0-pr123", r" productVersion: 1.4.1 - stackableVersion: 2.1.0 ", ResolvedProductImage { - image: "oci.stackable.tech/sdp/superset:1.4.1-stackable2.1.0".to_string(), - app_version_label_value: "1.4.1-stackable2.1.0".parse().expect("static app version label is always valid"), + image: "oci.stackable.tech/sdp/superset:1.4.1-stackable0.0.0-dev".to_string(), + app_version_label_value: "1.4.1-stackable0.0.0-dev".parse().expect("static app version label is always valid"), product_version: "1.4.1".to_string(), image_pull_policy: "Always".to_string(), pull_secrets: None, } )] - #[case::stackable_version_with_registry( - "oci.stackable.tech", - "sdp/trino", + #[case::auto_without_repo( + "superset", + "oci.stackable.tech/sdp", "23.7.42", r" productVersion: 1.4.1 stackableVersion: 2.1.0 - registry: oci.example.org ", ResolvedProductImage { - image: "oci.example.org/sdp/trino:1.4.1-stackable2.1.0".to_string(), + image: "oci.stackable.tech/sdp/superset:1.4.1-stackable2.1.0".to_string(), app_version_label_value: "1.4.1-stackable2.1.0".parse().expect("static app version label is always valid"), product_version: "1.4.1".to_string(), image_pull_policy: "Always".to_string(), pull_secrets: None, } )] - #[case::stackable_version_with_repository( - "oci.stackable.tech", - "sdp/trino", + #[case::auto_with_repository( + "superset", + "oci.stackable.tech/sdp", "23.7.42", r" productVersion: 1.4.1 stackableVersion: 2.1.0 - repository: stackable/trino + repo: quay.io/stackable ", ResolvedProductImage { - image: "oci.stackable.tech/stackable/trino:1.4.1-stackable2.1.0".to_string(), + image: "quay.io/stackable/superset:1.4.1-stackable2.1.0".to_string(), app_version_label_value: "1.4.1-stackable2.1.0".parse().expect("static app version label is always valid"), product_version: "1.4.1".to_string(), image_pull_policy: "Always".to_string(), pull_secrets: None, } )] - #[case::stackable_version_with_registry_and_repository( - "oci.stackable.tech", - "sdp/trino", + #[case::auto_with_repository_trailing_slash( + "superset", + "oci.stackable.tech/sdp", "23.7.42", r" productVersion: 1.4.1 stackableVersion: 2.1.0 - registry: quay.io - repository: stackable/trino + repo: quay.io/stackable/ ", ResolvedProductImage { - image: "quay.io/stackable/trino:1.4.1-stackable2.1.0".to_string(), + image: "quay.io/stackable/superset:1.4.1-stackable2.1.0".to_string(), app_version_label_value: "1.4.1-stackable2.1.0".parse().expect("static app version label is always valid"), product_version: "1.4.1".to_string(), image_pull_policy: "Always".to_string(), @@ -360,8 +366,8 @@ mod tests { } )] #[case::custom_without_tag( - "oci.stackable.tech", - "sdp/superset", + "superset", + "oci.stackable.tech/sdp", "23.7.42", r" custom: my.corp/myteam/stackable/superset @@ -376,8 +382,8 @@ mod tests { } )] #[case::custom_with_tag( - "oci.stackable.tech", - "sdp/superset", + "superset", + "oci.stackable.tech/sdp", "23.7.42", r" custom: my.corp/myteam/stackable/superset:latest-and-greatest @@ -392,8 +398,8 @@ mod tests { } )] #[case::custom_with_colon_in_repo_and_without_tag( - "oci.stackable.tech", - "sdp/superset", + "superset", + "oci.stackable.tech/sdp", "23.7.42", r" custom: 127.0.0.1:8080/myteam/stackable/superset @@ -408,8 +414,8 @@ mod tests { } )] #[case::custom_with_colon_in_repo_and_with_tag( - "oci.stackable.tech", - "sdp/superset", + "superset", + "oci.stackable.tech/sdp", "23.7.42", r" custom: 127.0.0.1:8080/myteam/stackable/superset:latest-and-greatest @@ -424,8 +430,8 @@ mod tests { } )] #[case::custom_with_hash_in_repo_and_without_tag( - "oci.stackable.tech", - "sdp/superset", + "superset", + "oci.stackable.tech/sdp", "23.7.42", r" custom: oci.stackable.tech/sdp/superset@sha256:85fa483aa99b9997ce476b86893ad5ed81fb7fd2db602977eb8c42f76efc1098 @@ -440,8 +446,8 @@ mod tests { } )] #[case::custom_takes_precedence( - "oci.stackable.tech", - "sdp/superset", + "superset", + "oci.stackable.tech/sdp", "23.7.42", r" custom: my.corp/myteam/stackable/superset:latest-and-greatest @@ -457,8 +463,8 @@ mod tests { } )] #[case::pull_policy_if_not_present( - "oci.stackable.tech", - "sdp/superset", + "superset", + "oci.stackable.tech/sdp", "23.7.42", r" custom: my.corp/myteam/stackable/superset:latest-and-greatest @@ -474,8 +480,8 @@ mod tests { } )] #[case::pull_policy_always( - "oci.stackable.tech", - "sdp/superset", + "superset", + "oci.stackable.tech/sdp", "23.7.42", r" custom: my.corp/myteam/stackable/superset:latest-and-greatest @@ -491,8 +497,8 @@ mod tests { } )] #[case::pull_policy_never( - "oci.stackable.tech", - "sdp/superset", + "superset", + "oci.stackable.tech/sdp", "23.7.42", r" custom: my.corp/myteam/stackable/superset:latest-and-greatest @@ -508,8 +514,8 @@ mod tests { } )] #[case::pull_secrets( - "oci.stackable.tech", - "sdp/superset", + "superset", + "oci.stackable.tech/sdp", "23.7.42", r" custom: my.corp/myteam/stackable/superset:latest-and-greatest @@ -528,7 +534,7 @@ mod tests { } )] fn resolved_image_pass( - #[case] image_registry: String, + #[case] image_name: String, #[case] image_repository: String, #[case] operator_version: String, #[case] input: String, @@ -536,7 +542,7 @@ mod tests { ) { let product_image: ProductImage = serde_yaml::from_str(&input).expect("Illegal test input"); let resolved_product_image = product_image - .resolve(&image_registry, &image_repository, &operator_version) + .resolve(&image_name, &image_repository, &operator_version) .expect("Illegal test input"); assert_eq!(resolved_product_image, expected); From eb8a0be354abd9fde2f6709399ac6e933f07dccd Mon Sep 17 00:00:00 2001 From: Techassi Date: Thu, 23 Apr 2026 15:17:58 +0200 Subject: [PATCH 4/6] chore(operator): Adjust changelog --- crates/stackable-operator/CHANGELOG.md | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/crates/stackable-operator/CHANGELOG.md b/crates/stackable-operator/CHANGELOG.md index 0fa39474e..c20336705 100644 --- a/crates/stackable-operator/CHANGELOG.md +++ b/crates/stackable-operator/CHANGELOG.md @@ -6,19 +6,14 @@ All notable changes to this project will be documented in this file. ### Added -- BREAKING: Add two required CLI arguments and env vars to set image registry and repository ([#1199]): - - `IMAGE_REGISTRY` (`--image-registry`): Sets the image registry which should be used by the - operator to construct image names for provisioned containers, eg. `oci.example.org`. - - `IMAGE_REPOSITORY` (`--image-repository`): Sets the image repository which should be used by the - operator to construct image names for provisioned containers, eg. `my/repository/to/operator`. +- BREAKING: Add CLI argument and env var to set the image repository used to construct final product + image names: `IMAGE_REPOSITORY` (`--image-repository`), eg. `oci.example.org/my/namespace` ([#1199]). ### Changed - BREAKING: The product image selection mechanism via `ProductImage::resolve` now takes three - parameters instead of two. The new parameters are: `image_registry`, `image_repository`, and - `operator_version`. -- BREAKING: The product image selection CRD interface splits up the `repo` key into `registry` and - `repository` for more clarity and consistency ([#1199]). + parameters instead of two. The new parameters are: `image_name`, `image_repository`, and + `operator_version` ([#1199]). [#1199]: https://github.com/stackabletech/operator-rs/pull/1199 From 84b128e361186413b3f177c20662ca81beb4a947 Mon Sep 17 00:00:00 2001 From: Techassi Date: Thu, 23 Apr 2026 15:21:04 +0200 Subject: [PATCH 5/6] fix: Bump rustls-webpki to 0.103.13 to negate RUSTSEC-2026-0104 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 60cd40dd6..f1474ce3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2539,9 +2539,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.12" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", From ad8d63412811f6d310d3e82978b10dd571e5a323 Mon Sep 17 00:00:00 2001 From: Techassi Date: Thu, 23 Apr 2026 15:25:02 +0200 Subject: [PATCH 6/6] feat(operator): Also trim whitespace in image_name --- .../src/commons/product_image_selection.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/stackable-operator/src/commons/product_image_selection.rs b/crates/stackable-operator/src/commons/product_image_selection.rs index c71f442ce..ce3616b8c 100644 --- a/crates/stackable-operator/src/commons/product_image_selection.rs +++ b/crates/stackable-operator/src/commons/product_image_selection.rs @@ -199,8 +199,9 @@ impl ProductImage { } }; - // Trim the start to ensure no double slashes are produced below - let image_name = image_name.trim_start_matches('/'); + // Trim leading ans trailing whitespace and also trim the start to ensure no double + // slashes are produced below + let image_name = image_name.trim().trim_start_matches('/'); let app_version = format!("{product_version}-stackable{stackable_version}"); let app_version_label_value = Self::prepare_app_version_label_value(&app_version)?; let image = format!("{image_repository}/{image_name}:{app_version}");