Skip to content

Commit

Permalink
Add product image selection struct (#476)
Browse files Browse the repository at this point in the history
## 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.
  • Loading branch information
sbernauer committed Nov 11, 2022
1 parent 013a451 commit 6dbb859
Show file tree
Hide file tree
Showing 5 changed files with 302 additions and 1 deletion.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Expand Up @@ -4,10 +4,16 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### Added

- Added product image selection struct ([#476]).

### Changed

- BREAKING: `get_recommended_labels` and `with_recommended_labels` now takes a struct of named arguments ([#501]).
- Bump kube to `0.76.0` ([#476]).

[#476]: https://github.com/stackabletech/operator-rs/pull/476
[#501]: https://github.com/stackabletech/operator-rs/pull/501

## [0.26.1] - 2022-11-08
Expand Down
14 changes: 13 additions & 1 deletion src/builder/pod/container.rs
Expand Up @@ -4,7 +4,10 @@ use k8s_openapi::api::core::v1::{
};
use std::fmt;

use crate::{error::Error, validation::is_rfc_1123_label};
use crate::{
commons::product_image_selection::ResolvedProductImage, error::Error,
validation::is_rfc_1123_label,
};

/// A builder to build [`Container`] objects.
///
Expand Down Expand Up @@ -45,6 +48,15 @@ impl ContainerBuilder {
self
}

/// Adds the following container attributes from a [ResolvedProductImage]:
/// * image
/// * image_pull_policy
pub fn image_from_product_image(&mut self, product_image: &ResolvedProductImage) -> &mut Self {
self.image = Some(product_image.image.clone());
self.image_pull_policy = Some(product_image.image_pull_policy.clone());
self
}

pub fn add_env_var(&mut self, name: impl Into<String>, value: impl Into<String>) -> &mut Self {
self.env.get_or_insert_with(Vec::new).push(EnvVar {
name: name.into(),
Expand Down
14 changes: 14 additions & 0 deletions src/builder/pod/mod.rs
Expand Up @@ -3,6 +3,7 @@ pub mod security;
pub mod volume;

use crate::builder::meta::ObjectMetaBuilder;
use crate::commons::product_image_selection::ResolvedProductImage;
use crate::error::{Error, OperatorResult};

use k8s_openapi::{
Expand Down Expand Up @@ -290,6 +291,19 @@ impl PodBuilder {
self
}

/// Extend the pod's image_pull_secrets field with the pull secrets from a given [ResolvedProductImage]
pub fn image_pull_secrets_from_product_image(
&mut self,
product_image: &ResolvedProductImage,
) -> &mut Self {
if let Some(pull_secrets) = &product_image.pull_secrets {
self.image_pull_secrets
.get_or_insert_with(Vec::new)
.extend_from_slice(pull_secrets);
}
self
}

/// Hack because [`Pod`] predates [`LabelSelector`], and so its functionality is split between [`PodSpec::node_selector`] and [`Affinity::node_affinity`]
fn node_selector_for_label_selector(
label_selector: Option<LabelSelector>,
Expand Down
1 change: 1 addition & 0 deletions src/commons/mod.rs
Expand Up @@ -4,6 +4,7 @@ pub mod authentication;
pub mod ldap;
pub mod listener;
pub mod opa;
pub mod product_image_selection;
pub mod resources;
pub mod s3;
pub mod secret_class;
Expand Down
268 changes: 268 additions & 0 deletions src/commons/product_image_selection.rs
@@ -0,0 +1,268 @@
use k8s_openapi::api::core::v1::LocalObjectReference;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use strum::AsRefStr;

pub const STACKABLE_DOCKER_REPO: &str = "docker.stackable.tech/stackable";

#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProductImage {
#[serde(flatten)]
image_selection: ProductImageSelection,

#[serde(default)]
/// [Pull policy](https://kubernetes.io/docs/concepts/containers/images/#image-pull-policy) used when pulling the Images
pull_policy: PullPolicy,

/// [Image pull secrets](https://kubernetes.io/docs/concepts/containers/images/#specifying-imagepullsecrets-on-a-pod) to pull images from a private registry
pull_secrets: Option<Vec<LocalObjectReference>>,
}

#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
#[serde(untagged)]
pub enum ProductImageSelection {
// Order matters!
// The variants will be tried from top to bottom
Custom(ProductImageCustom),
StackableVersion(ProductImageStackableVersion),
}

#[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. `docker.stackable.tech/stackable/superset:1.4.1-stackable2.1.0`
custom: String,
/// Version of the product, e.g. `1.4.1`.
product_version: String,
}

#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProductImageStackableVersion {
/// Version of the product, e.g. `1.4.1`.
product_version: String,
/// Stackable version of the product, e.g. 2.1.0
stackable_version: String,
/// Name of the docker repo, e.g. `docker.stackable.tech/stackable`
repo: Option<String>,
}

#[derive(Clone, Debug, PartialEq, JsonSchema)]
pub struct ResolvedProductImage {
pub product_version: String,
pub image: String,
pub image_pull_policy: String,
pub pull_secrets: Option<Vec<LocalObjectReference>>,
}

#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(rename = "PascalCase")]
#[derive(AsRefStr)]
pub enum PullPolicy {
IfNotPresent,
Always,
Never,
}

impl Default for PullPolicy {
fn default() -> PullPolicy {
PullPolicy::IfNotPresent
}
}

impl ProductImage {
pub fn resolve(&self, image_base_name: &str) -> ResolvedProductImage {
let image_pull_policy = self.pull_policy.as_ref().to_string();
let pull_secrets = self.pull_secrets.clone();

match &self.image_selection {
ProductImageSelection::Custom(custom) => ResolvedProductImage {
product_version: custom.product_version.to_string(),
image: custom.custom.to_string(),
image_pull_policy,
pull_secrets,
},
ProductImageSelection::StackableVersion(stackable_version) => {
let repo = stackable_version
.repo
.as_deref()
.unwrap_or(STACKABLE_DOCKER_REPO);
let image = format!(
"{repo}/{image_base_name}:{product_version}-stackable{stackable_version}",
product_version = stackable_version.product_version,
stackable_version = stackable_version.stackable_version,
);
ResolvedProductImage {
product_version: stackable_version.product_version.to_string(),
image,
image_pull_policy,
pull_secrets,
}
}
}
}
}

#[cfg(test)]
mod tests {
use super::*;

use rstest::rstest;

#[rstest]
#[case::stackable_version_without_repo(
"superset",
r#"
productVersion: 1.4.1
stackableVersion: 2.1.0
"#,
ResolvedProductImage {
image: "docker.stackable.tech/stackable/superset:1.4.1-stackable2.1.0".to_string(),
product_version: "1.4.1".to_string(),
image_pull_policy: "IfNotPresent".to_string(),
pull_secrets: None,
}
)]
#[case::stackable_version_with_repo(
"trino",
r#"
productVersion: 1.4.1
stackableVersion: 2.1.0
repo: my.corp/myteam/stackable
"#,
ResolvedProductImage {
image: "my.corp/myteam/stackable/trino:1.4.1-stackable2.1.0".to_string(),
product_version: "1.4.1".to_string(),
image_pull_policy: "IfNotPresent".to_string(),
pull_secrets: None,
}
)]
#[case::custom(
"superset",
r#"
custom: my.corp/myteam/stackable/superset:latest-and-greatest
productVersion: 1.4.1
"#,
ResolvedProductImage {
image: "my.corp/myteam/stackable/superset:latest-and-greatest".to_string(),
product_version: "1.4.1".to_string(),
image_pull_policy: "IfNotPresent".to_string(),
pull_secrets: None,
}
)]
#[case::custom_takes_precedence(
"superset",
r#"
custom: my.corp/myteam/stackable/superset:latest-and-greatest
productVersion: 1.4.1
stackableVersion: not-used
"#,
ResolvedProductImage {
image: "my.corp/myteam/stackable/superset:latest-and-greatest".to_string(),
product_version: "1.4.1".to_string(),
image_pull_policy: "IfNotPresent".to_string(),
pull_secrets: None,
}
)]
#[case::pull_policy_if_not_present(
"superset",
r#"
custom: my.corp/myteam/stackable/superset:latest-and-greatest
productVersion: 1.4.1
pullPolicy: IfNotPresent
"#,
ResolvedProductImage {
image: "my.corp/myteam/stackable/superset:latest-and-greatest".to_string(),
product_version: "1.4.1".to_string(),
image_pull_policy: "IfNotPresent".to_string(),
pull_secrets: None,
}
)]
#[case::pull_policy_always(
"superset",
r#"
custom: my.corp/myteam/stackable/superset:latest-and-greatest
productVersion: 1.4.1
pullPolicy: Always
"#,
ResolvedProductImage {
image: "my.corp/myteam/stackable/superset:latest-and-greatest".to_string(),
product_version: "1.4.1".to_string(),
image_pull_policy: "Always".to_string(),
pull_secrets: None,
}
)]
#[case::pull_policy_never(
"superset",
r#"
custom: my.corp/myteam/stackable/superset:latest-and-greatest
productVersion: 1.4.1
pullPolicy: Never
"#,
ResolvedProductImage {
image: "my.corp/myteam/stackable/superset:latest-and-greatest".to_string(),
product_version: "1.4.1".to_string(),
image_pull_policy: "Never".to_string(),
pull_secrets: None,
}
)]
#[case::pull_secrets(
"superset",
r#"
custom: my.corp/myteam/stackable/superset:latest-and-greatest
productVersion: 1.4.1
pullPolicy: Always
pullSecrets:
- name: myPullSecrets1
- name: myPullSecrets2
"#,
ResolvedProductImage {
image: "my.corp/myteam/stackable/superset:latest-and-greatest".to_string(),
product_version: "1.4.1".to_string(),
image_pull_policy: "Always".to_string(),
pull_secrets: Some(vec![LocalObjectReference{name: Some("myPullSecrets1".to_string())}, LocalObjectReference{name: Some("myPullSecrets2".to_string())}]),
}
)]
fn test_correct_resolved_image(
#[case] image_base_name: 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);

assert_eq!(resolved_product_image, expected);
}

#[rstest]
#[case::custom(
r#"
custom: my.corp/myteam/stackable/superset:latest-and-greatest
"#,
"data did not match any variant of untagged enum ProductImageSelection at line 2 column 9"
)]
#[case::product_version(
r#"
productVersion: 1.4.1
"#,
"data did not match any variant of untagged enum ProductImageSelection at line 2 column 9"
)]
#[case::stackable_version(
r#"
stackableVersion: 2.1.0
"#,
"data did not match any variant of untagged enum ProductImageSelection at line 2 column 9"
)]
#[case::empty(
"{}",
"data did not match any variant of untagged enum ProductImageSelection"
)]
fn test_invalid_image(#[case] input: String, #[case] expected: String) {
let err = serde_yaml::from_str::<ProductImage>(&input).expect_err("Must be error");

assert_eq!(err.to_string(), expected);
}
}

0 comments on commit 6dbb859

Please sign in to comment.