Skip to content

Commit 863d53e

Browse files
committed
Add product image selection struct (#476)
## Description For #470 Reference implementation in kafka: stackabletech/kafka-operator#482 ADR is [here](https://docs.stackable.tech/home/stable/contributor/adr/ADR018-product_image_versioning.html) The idea is to add all the currently known enum variants to see that the concept works. Before merging the `ProductImageSelection::Stackable` enum variant will be removed/commented out.
1 parent 2ce7c89 commit 863d53e

File tree

6 files changed

+358
-4
lines changed

6 files changed

+358
-4
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,21 @@ All notable changes to this project will be documented in this file.
44

55
## [Unreleased]
66

7+
### Added
8+
9+
- Added product image selection struct ([#476]).
10+
711
### Changed
812

913
- BREAKING: `get_recommended_labels` and `with_recommended_labels` now takes a struct of named arguments ([#501]).
1014
- BREAKING: `get_recommended_labels` (and co) now takes the operator and controller names separately ([#492]).
1115
- BREAKING: `ClusterResources` now takes the operator and controller names separately ([#492]).
1216
- When upgrading, please use FQDN-style names for the operators (`{operator}.stackable.tech`).
17+
- Bump kube to `0.76.0` ([#476]).
1318
- Bump opentelemetry crates ([#502]).
1419
- Bump clap to 4.0 ([#503]).
1520

21+
[#476]: https://github.com/stackabletech/operator-rs/pull/476
1622
[#492]: https://github.com/stackabletech/operator-rs/pull/492
1723
[#501]: https://github.com/stackabletech/operator-rs/pull/501
1824
[#502]: https://github.com/stackabletech/operator-rs/pull/502

src/builder/pod/container.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ use k8s_openapi::api::core::v1::{
44
};
55
use std::fmt;
66

7-
use crate::{error::Error, validation::is_rfc_1123_label};
7+
use crate::{
8+
commons::product_image_selection::ResolvedProductImage, error::Error,
9+
validation::is_rfc_1123_label,
10+
};
811

912
/// A builder to build [`Container`] objects.
1013
///
@@ -45,6 +48,15 @@ impl ContainerBuilder {
4548
self
4649
}
4750

51+
/// Adds the following container attributes from a [ResolvedProductImage]:
52+
/// * image
53+
/// * image_pull_policy
54+
pub fn image_from_product_image(&mut self, product_image: &ResolvedProductImage) -> &mut Self {
55+
self.image = Some(product_image.image.clone());
56+
self.image_pull_policy = Some(product_image.image_pull_policy.clone());
57+
self
58+
}
59+
4860
pub fn add_env_var(&mut self, name: impl Into<String>, value: impl Into<String>) -> &mut Self {
4961
self.env.get_or_insert_with(Vec::new).push(EnvVar {
5062
name: name.into(),

src/builder/pod/mod.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ pub mod security;
33
pub mod volume;
44

55
use crate::builder::meta::ObjectMetaBuilder;
6+
use crate::commons::product_image_selection::ResolvedProductImage;
67
use crate::error::{Error, OperatorResult};
78

89
use k8s_openapi::{
@@ -290,6 +291,19 @@ impl PodBuilder {
290291
self
291292
}
292293

294+
/// Extend the pod's image_pull_secrets field with the pull secrets from a given [ResolvedProductImage]
295+
pub fn image_pull_secrets_from_product_image(
296+
&mut self,
297+
product_image: &ResolvedProductImage,
298+
) -> &mut Self {
299+
if let Some(pull_secrets) = &product_image.pull_secrets {
300+
self.image_pull_secrets
301+
.get_or_insert_with(Vec::new)
302+
.extend_from_slice(pull_secrets);
303+
}
304+
self
305+
}
306+
293307
/// Hack because [`Pod`] predates [`LabelSelector`], and so its functionality is split between [`PodSpec::node_selector`] and [`Affinity::node_affinity`]
294308
fn node_selector_for_label_selector(
295309
label_selector: Option<LabelSelector>,

src/commons/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ pub mod authentication;
44
pub mod ldap;
55
pub mod listener;
66
pub mod opa;
7+
pub mod product_image_selection;
78
pub mod resources;
89
pub mod s3;
910
pub mod secret_class;
Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
use k8s_openapi::api::core::v1::LocalObjectReference;
2+
use schemars::JsonSchema;
3+
use serde::{Deserialize, Serialize};
4+
use strum::AsRefStr;
5+
6+
#[cfg(doc)]
7+
use crate::labels::get_recommended_labels;
8+
9+
pub const STACKABLE_DOCKER_REPO: &str = "docker.stackable.tech/stackable";
10+
11+
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
12+
#[serde(rename_all = "camelCase")]
13+
pub struct ProductImage {
14+
#[serde(flatten)]
15+
image_selection: ProductImageSelection,
16+
17+
#[serde(default)]
18+
/// [Pull policy](https://kubernetes.io/docs/concepts/containers/images/#image-pull-policy) used when pulling the Images
19+
pull_policy: PullPolicy,
20+
21+
/// [Image pull secrets](https://kubernetes.io/docs/concepts/containers/images/#specifying-imagepullsecrets-on-a-pod) to pull images from a private registry
22+
pull_secrets: Option<Vec<LocalObjectReference>>,
23+
}
24+
25+
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
26+
#[serde(rename_all = "camelCase")]
27+
#[serde(untagged)]
28+
pub enum ProductImageSelection {
29+
// Order matters!
30+
// The variants will be tried from top to bottom
31+
Custom(ProductImageCustom),
32+
StackableVersion(ProductImageStackableVersion),
33+
}
34+
35+
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
36+
#[serde(rename_all = "camelCase")]
37+
pub struct ProductImageCustom {
38+
/// Overwrite the docker image.
39+
/// Specify the full docker image name, e.g. `docker.stackable.tech/stackable/superset:1.4.1-stackable2.1.0`
40+
custom: String,
41+
/// Version of the product, e.g. `1.4.1`.
42+
product_version: String,
43+
}
44+
45+
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
46+
#[serde(rename_all = "camelCase")]
47+
pub struct ProductImageStackableVersion {
48+
/// Version of the product, e.g. `1.4.1`.
49+
product_version: String,
50+
/// Stackable version of the product, e.g. 2.1.0
51+
stackable_version: String,
52+
/// Name of the docker repo, e.g. `docker.stackable.tech/stackable`
53+
repo: Option<String>,
54+
}
55+
56+
#[derive(Clone, Debug, PartialEq, JsonSchema)]
57+
pub struct ResolvedProductImage {
58+
/// Version of the product, e.g. `1.4.1`.
59+
pub product_version: String,
60+
/// App version as formatted for [`get_recommended_labels`]
61+
pub app_version_label: String,
62+
/// Image to be used for the product image e.g. `docker.stackable.tech/stackable/superset:1.4.1-stackable2.1.0`
63+
pub image: String,
64+
/// Image pull policy for the containers using the product image
65+
pub image_pull_policy: String,
66+
/// Image pull secrets for the containers using the product image
67+
pub pull_secrets: Option<Vec<LocalObjectReference>>,
68+
}
69+
70+
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
71+
#[serde(rename = "PascalCase")]
72+
#[derive(AsRefStr)]
73+
pub enum PullPolicy {
74+
IfNotPresent,
75+
Always,
76+
Never,
77+
}
78+
79+
impl Default for PullPolicy {
80+
fn default() -> PullPolicy {
81+
PullPolicy::IfNotPresent
82+
}
83+
}
84+
85+
impl ProductImage {
86+
pub fn resolve(&self, image_base_name: &str) -> ResolvedProductImage {
87+
let image_pull_policy = self.pull_policy.as_ref().to_string();
88+
let pull_secrets = self.pull_secrets.clone();
89+
90+
match &self.image_selection {
91+
ProductImageSelection::Custom(custom) => {
92+
let custom_image_tag = custom
93+
.custom
94+
.split_once(':')
95+
.map_or("latest", |splits| splits.1);
96+
let app_version_label = format!("{}-{}", custom.product_version, custom_image_tag);
97+
ResolvedProductImage {
98+
product_version: custom.product_version.to_string(),
99+
app_version_label,
100+
image: custom.custom.to_string(),
101+
image_pull_policy,
102+
pull_secrets,
103+
}
104+
}
105+
ProductImageSelection::StackableVersion(stackable_version) => {
106+
let repo = stackable_version
107+
.repo
108+
.as_deref()
109+
.unwrap_or(STACKABLE_DOCKER_REPO);
110+
let image = format!(
111+
"{repo}/{image_base_name}:{product_version}-stackable{stackable_version}",
112+
product_version = stackable_version.product_version,
113+
stackable_version = stackable_version.stackable_version,
114+
);
115+
let app_version_label = format!(
116+
"{product_version}-stackable{stackable_version}",
117+
product_version = stackable_version.product_version,
118+
stackable_version = stackable_version.stackable_version,
119+
);
120+
ResolvedProductImage {
121+
product_version: stackable_version.product_version.to_string(),
122+
app_version_label,
123+
image,
124+
image_pull_policy,
125+
pull_secrets,
126+
}
127+
}
128+
}
129+
}
130+
}
131+
132+
#[cfg(test)]
133+
mod tests {
134+
use super::*;
135+
136+
use rstest::rstest;
137+
138+
#[rstest]
139+
#[case::stackable_version_without_repo(
140+
"superset",
141+
r#"
142+
productVersion: 1.4.1
143+
stackableVersion: 2.1.0
144+
"#,
145+
ResolvedProductImage {
146+
image: "docker.stackable.tech/stackable/superset:1.4.1-stackable2.1.0".to_string(),
147+
app_version_label: "1.4.1-stackable2.1.0".to_string(),
148+
product_version: "1.4.1".to_string(),
149+
image_pull_policy: "IfNotPresent".to_string(),
150+
pull_secrets: None,
151+
}
152+
)]
153+
#[case::stackable_version_with_repo(
154+
"trino",
155+
r#"
156+
productVersion: 1.4.1
157+
stackableVersion: 2.1.0
158+
repo: my.corp/myteam/stackable
159+
"#,
160+
ResolvedProductImage {
161+
image: "my.corp/myteam/stackable/trino:1.4.1-stackable2.1.0".to_string(),
162+
app_version_label: "1.4.1-stackable2.1.0".to_string(),
163+
product_version: "1.4.1".to_string(),
164+
image_pull_policy: "IfNotPresent".to_string(),
165+
pull_secrets: None,
166+
}
167+
)]
168+
#[case::custom_without_tag(
169+
"superset",
170+
r#"
171+
custom: my.corp/myteam/stackable/superset
172+
productVersion: 1.4.1
173+
"#,
174+
ResolvedProductImage {
175+
image: "my.corp/myteam/stackable/superset".to_string(),
176+
app_version_label: "1.4.1-latest".to_string(),
177+
product_version: "1.4.1".to_string(),
178+
image_pull_policy: "IfNotPresent".to_string(),
179+
pull_secrets: None,
180+
}
181+
)]
182+
#[case::custom_with_tag(
183+
"superset",
184+
r#"
185+
custom: my.corp/myteam/stackable/superset:latest-and-greatest
186+
productVersion: 1.4.1
187+
"#,
188+
ResolvedProductImage {
189+
image: "my.corp/myteam/stackable/superset:latest-and-greatest".to_string(),
190+
app_version_label: "1.4.1-latest-and-greatest".to_string(),
191+
product_version: "1.4.1".to_string(),
192+
image_pull_policy: "IfNotPresent".to_string(),
193+
pull_secrets: None,
194+
}
195+
)]
196+
#[case::custom_takes_precedence(
197+
"superset",
198+
r#"
199+
custom: my.corp/myteam/stackable/superset:latest-and-greatest
200+
productVersion: 1.4.1
201+
stackableVersion: not-used
202+
"#,
203+
ResolvedProductImage {
204+
image: "my.corp/myteam/stackable/superset:latest-and-greatest".to_string(),
205+
app_version_label: "1.4.1-latest-and-greatest".to_string(),
206+
product_version: "1.4.1".to_string(),
207+
image_pull_policy: "IfNotPresent".to_string(),
208+
pull_secrets: None,
209+
}
210+
)]
211+
#[case::pull_policy_if_not_present(
212+
"superset",
213+
r#"
214+
custom: my.corp/myteam/stackable/superset:latest-and-greatest
215+
productVersion: 1.4.1
216+
pullPolicy: IfNotPresent
217+
"#,
218+
ResolvedProductImage {
219+
image: "my.corp/myteam/stackable/superset:latest-and-greatest".to_string(),
220+
app_version_label: "1.4.1-latest-and-greatest".to_string(),
221+
product_version: "1.4.1".to_string(),
222+
image_pull_policy: "IfNotPresent".to_string(),
223+
pull_secrets: None,
224+
}
225+
)]
226+
#[case::pull_policy_always(
227+
"superset",
228+
r#"
229+
custom: my.corp/myteam/stackable/superset:latest-and-greatest
230+
productVersion: 1.4.1
231+
pullPolicy: Always
232+
"#,
233+
ResolvedProductImage {
234+
image: "my.corp/myteam/stackable/superset:latest-and-greatest".to_string(),
235+
app_version_label: "1.4.1-latest-and-greatest".to_string(),
236+
product_version: "1.4.1".to_string(),
237+
image_pull_policy: "Always".to_string(),
238+
pull_secrets: None,
239+
}
240+
)]
241+
#[case::pull_policy_never(
242+
"superset",
243+
r#"
244+
custom: my.corp/myteam/stackable/superset:latest-and-greatest
245+
productVersion: 1.4.1
246+
pullPolicy: Never
247+
"#,
248+
ResolvedProductImage {
249+
image: "my.corp/myteam/stackable/superset:latest-and-greatest".to_string(),
250+
app_version_label: "1.4.1-latest-and-greatest".to_string(),
251+
product_version: "1.4.1".to_string(),
252+
image_pull_policy: "Never".to_string(),
253+
pull_secrets: None,
254+
}
255+
)]
256+
#[case::pull_secrets(
257+
"superset",
258+
r#"
259+
custom: my.corp/myteam/stackable/superset:latest-and-greatest
260+
productVersion: 1.4.1
261+
pullPolicy: Always
262+
pullSecrets:
263+
- name: myPullSecrets1
264+
- name: myPullSecrets2
265+
"#,
266+
ResolvedProductImage {
267+
image: "my.corp/myteam/stackable/superset:latest-and-greatest".to_string(),
268+
app_version_label: "1.4.1-latest-and-greatest".to_string(),
269+
product_version: "1.4.1".to_string(),
270+
image_pull_policy: "Always".to_string(),
271+
pull_secrets: Some(vec![LocalObjectReference{name: Some("myPullSecrets1".to_string())}, LocalObjectReference{name: Some("myPullSecrets2".to_string())}]),
272+
}
273+
)]
274+
fn test_correct_resolved_image(
275+
#[case] image_base_name: String,
276+
#[case] input: String,
277+
#[case] expected: ResolvedProductImage,
278+
) {
279+
let product_image: ProductImage = serde_yaml::from_str(&input).expect("Illegal test input");
280+
let resolved_product_image = product_image.resolve(&image_base_name);
281+
282+
assert_eq!(resolved_product_image, expected);
283+
}
284+
285+
#[rstest]
286+
#[case::custom(
287+
r#"
288+
custom: my.corp/myteam/stackable/superset:latest-and-greatest
289+
"#,
290+
"data did not match any variant of untagged enum ProductImageSelection at line 2 column 9"
291+
)]
292+
#[case::product_version(
293+
r#"
294+
productVersion: 1.4.1
295+
"#,
296+
"data did not match any variant of untagged enum ProductImageSelection at line 2 column 9"
297+
)]
298+
#[case::stackable_version(
299+
r#"
300+
stackableVersion: 2.1.0
301+
"#,
302+
"data did not match any variant of untagged enum ProductImageSelection at line 2 column 9"
303+
)]
304+
#[case::empty(
305+
"{}",
306+
"data did not match any variant of untagged enum ProductImageSelection"
307+
)]
308+
fn test_invalid_image(#[case] input: String, #[case] expected: String) {
309+
let err = serde_yaml::from_str::<ProductImage>(&input).expect_err("Must be error");
310+
311+
assert_eq!(err.to_string(), expected);
312+
}
313+
}

0 commit comments

Comments
 (0)