Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions crates/stackable-operator/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,17 @@ All notable changes to this project will be documented in this file.
### Changed

- BREAKING: [v2] Improve functions for recommended labels in `v2::kvp::label` ([#1261]).
- BREAKING: [v2] `env_overrides` in `v2::role_utils::CommonConfiguration` is now the new
`v2::env_overrides::EnvOverrides` type (a `BTreeMap<EnvVarName, String>`) instead of a
`HashMap<String, String>`, so environment variable names are validated on deserialization and
kept in a deterministic order ([#1262]).
`v2::role_utils` now defines its own `CommonConfiguration`, `Role` and `RoleGroup` instead of
re-exporting them from `crate::role_utils`.

[#1259]: https://github.com/stackabletech/operator-rs/pull/1259
[#1260]: https://github.com/stackabletech/operator-rs/pull/1260
[#1261]: https://github.com/stackabletech/operator-rs/pull/1261
[#1262]: https://github.com/stackabletech/operator-rs/pull/1262

## [0.115.0] - 2026-08-04

Expand Down
150 changes: 150 additions & 0 deletions crates/stackable-operator/src/v2/env_overrides.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
use std::collections::{BTreeMap, btree_map};

use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

use crate::v2::builder::pod::container::{EnvVarName, EnvVarSet};

/// A map from environment variable names to their values.
///
/// This is a newtype around `BTreeMap<EnvVarName, String>` instead of a bare type alias because a
/// `BTreeMap` keyed by [`EnvVarName`] would generate a JSON schema using `patternProperties` (from
/// the [`EnvVarName`] pattern), which is not supported in CRDs. The custom [`JsonSchema`]
/// implementation therefore exposes the field as a plain `BTreeMap<String, String>` in the CRD.
///
/// As a consequence, the Kubernetes API server does not enforce the [`EnvVarName`] pattern:
/// invalid names are accepted on `apply` and only rejected later, when the operator deserializes
/// the resource.
///
/// This uses a `BTreeMap<EnvVarName, String>` rather than an
/// [`EnvVarSet`](crate::v2::builder::pod::container::EnvVarSet), because for overrides only plain
/// values are supported at the moment. An `EnvVarSet` maps each name to a full `EnvVar`, which also
/// allows the other variants (such as `valueFrom`); those are intentionally not exposed here.
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct EnvOverrides(BTreeMap<EnvVarName, String>);

impl EnvOverrides {
pub fn new() -> Self {
Self(BTreeMap::new())
}

pub fn insert(&mut self, env_var_name: EnvVarName, value: String) -> Option<String> {
self.0.insert(env_var_name, value)
}

pub fn iter(&self) -> btree_map::Iter<'_, EnvVarName, String> {
self.0.iter()
}

pub fn len(&self) -> usize {
self.0.len()
}

pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}

impl FromIterator<(EnvVarName, String)> for EnvOverrides {
fn from_iter<T: IntoIterator<Item = (EnvVarName, String)>>(iter: T) -> Self {
Self(BTreeMap::from_iter(iter))
}
}

impl<'a> IntoIterator for &'a EnvOverrides {
type IntoIter = btree_map::Iter<'a, EnvVarName, String>;
type Item = (&'a EnvVarName, &'a String);

fn into_iter(self) -> Self::IntoIter {
self.0.iter()
}
}

impl JsonSchema for EnvOverrides {
fn schema_name() -> std::borrow::Cow<'static, str> {
"EnvOverrides".into()
}

fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
BTreeMap::<String, String>::json_schema(generator)
}
}

impl IntoIterator for EnvOverrides {
type IntoIter = btree_map::IntoIter<EnvVarName, String>;
type Item = (EnvVarName, String);

fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}

impl Extend<(EnvVarName, String)> for EnvOverrides {
fn extend<T: IntoIterator<Item = (EnvVarName, String)>>(&mut self, iter: T) {
self.0.extend(iter);
}
}

impl From<EnvOverrides> for EnvVarSet {
fn from(value: EnvOverrides) -> Self {
Self::new().with_values(value)
}
}

#[cfg(test)]
mod tests {
use serde_json::json;

use super::*;

#[test]
fn deserialize_valid_names() {
let overrides: EnvOverrides = serde_json::from_value(json!({
"FOO": "1",
"BAR": "2"
}))
.expect("should be valid EnvOverrides");

assert_eq!(
vec![
(EnvVarName::from_str_unsafe("BAR"), "2".to_owned()),
(EnvVarName::from_str_unsafe("FOO"), "1".to_owned())
],
overrides.into_iter().collect::<Vec<_>>()
);
}

#[test]
fn deserialize_rejects_invalid_names() {
// "=" is not allowed in environment variable names.
let result: Result<EnvOverrides, serde_json::Error> = serde_json::from_value(json!({
"FO=O": "1"
}));

assert_eq!(
Err(
"no match for the regular expression \"^[ -<>-~]+$\" in the value \"FO=O\""
.to_owned()
),
result.map_err(|err| err.to_string())
);
}

#[test]
fn json_schema_is_a_plain_string_map() {
let schema = serde_json::to_value(schemars::schema_for!(EnvOverrides))
.expect("should produce a valid JSON schema");

assert_eq!(
json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "EnvOverrides",
"type": "object",
"additionalProperties": {
"type": "string"
}
}),
schema
);
}
}
4 changes: 2 additions & 2 deletions crates/stackable-operator/src/v2/jvm_argument_overrides.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,8 @@ mod tests {

use super::*;
use crate::{
role_utils::{GenericRoleConfig, Role, RoleGroup},
v2::role_utils::{JavaCommonConfig, with_validated_config},
role_utils::GenericRoleConfig,
v2::role_utils::{JavaCommonConfig, Role, RoleGroup, with_validated_config},
};

// #[derive(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ pub enum Error {
#[snafu(display("invalid regular expression"))]
InvalidRegex { source: regex::Error },

#[snafu(display("regular expression not matched"))]
#[snafu(display("no match for the regular expression {regex:?} in the value {value:?}"))]
RegexNotMatched { value: String, regex: &'static str },

#[snafu(display("not a valid label value"))]
Expand Down Expand Up @@ -706,7 +706,9 @@ mod tests {
.map_err(|err| err.to_string())
);
assert_eq!(
Err("regular expression not matched".to_owned()),
Err(
"no match for the regular expression \"^[est-]+$\" in the value \"abc\"".to_owned()
),
serde_json::from_value::<T>(Value::String("abc".to_owned()))
.map_err(|err| err.to_string())
);
Expand Down
1 change: 1 addition & 0 deletions crates/stackable-operator/src/v2/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ pub mod cluster_resources;
pub mod config_file_writer;
pub mod config_overrides;
pub mod controller_utils;
pub mod env_overrides;
pub mod flask_config_writer;
pub mod jvm_argument_overrides;
pub mod kvp;
Expand Down
Loading