Skip to content
5 changes: 5 additions & 0 deletions crates/stackable-operator/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,13 @@ All notable changes to this project will be documented in this file.

- BREAKING: Upgrade to `schemars` 1.0, `kube` 2.0 and `k8s-openapi` 0.26 (using Kubernetes 1.34) ([#1091]).

### Fixed

- BREAKING: Don't allow uppercase characters in Kubernetes object names ([#1095]).

[#1091]: https://github.com/stackabletech/operator-rs/pull/1091
[#1094]: https://github.com/stackabletech/operator-rs/pull/1094
[#1095]: https://github.com/stackabletech/operator-rs/pull/1095

## [0.97.0] - 2025-09-09

Expand Down
15 changes: 8 additions & 7 deletions crates/stackable-operator/src/builder/pod/container.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use {k8s_openapi::api::core::v1::PodSpec, std::collections::BTreeMap};

use crate::{
commons::product_image_selection::ResolvedProductImage,
validation::{self, is_rfc_1123_label},
validation::{self, is_lowercase_rfc_1123_label},
};

type Result<T, E = Error> = std::result::Result<T, E>;
Expand Down Expand Up @@ -351,10 +351,11 @@ impl ContainerBuilder {
}
}

/// Validates a container name is according to the [RFC 1123](https://www.ietf.org/rfc/rfc1123.txt) standard.
/// Validates a container name is according to the kubernetes-specific [RFC 1123](https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-label-names) standard.
/// Returns [Ok] if the name is according to the standard, and [Err] if not.
fn validate_container_name(container_name: &str) -> Result<()> {
is_rfc_1123_label(container_name).context(InvalidContainerNameSnafu { container_name })
is_lowercase_rfc_1123_label(container_name)
.context(InvalidContainerNameSnafu { container_name })
}
}

Expand Down Expand Up @@ -441,7 +442,7 @@ mod tests {
resources::ResourceRequirementsBuilder,
},
commons::resources::ResourceRequirementsType,
validation::RFC_1123_LABEL_FMT,
validation::LOWERCASE_RFC_1123_LABEL_FMT,
};

#[test]
Expand Down Expand Up @@ -604,11 +605,11 @@ mod tests {
assert!(ContainerBuilder::new("name-with-hyphen").is_ok());
assert_container_builder_err(
ContainerBuilder::new("ends-with-hyphen-"),
&format!(r#"regex used for validation is "{RFC_1123_LABEL_FMT}""#),
&format!(r#"regex used for validation is "{LOWERCASE_RFC_1123_LABEL_FMT}""#),
);
assert_container_builder_err(
ContainerBuilder::new("-starts-with-hyphen"),
&format!(r#"regex used for validation is "{RFC_1123_LABEL_FMT}""#),
&format!(r#"regex used for validation is "{LOWERCASE_RFC_1123_LABEL_FMT}""#),
);
}

Expand All @@ -623,7 +624,7 @@ mod tests {
assert_container_builder_err(
ContainerBuilder::new("name_name"),
&format!(
r#"(e.g. "example-label", or "1-label-1", regex used for validation is "{RFC_1123_LABEL_FMT}""#
r#"(e.g. "example-label", or "1-label-1", regex used for validation is "{LOWERCASE_RFC_1123_LABEL_FMT}""#
),
);
}
Expand Down
162 changes: 88 additions & 74 deletions crates/stackable-operator/src/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,27 +16,38 @@ use regex::Regex;
use snafu::Snafu;

/// Minimal length required by RFC 1123 is 63. Up to 255 allowed, unsupported by k8s.
const RFC_1123_LABEL_MAX_LENGTH: usize = 63;
pub const RFC_1123_LABEL_FMT: &str = "[a-zA-Z0-9]([-a-zA-Z0-9]*[a-zA-Z0-9])?";
const RFC_1123_LABEL_ERROR_MSG: &str = "a RFC 1123 label must consist of alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character";
pub const RFC_1123_LABEL_MAX_LENGTH: usize = 63;
// This is a modified RFC 1123 format according to the Kubernetes specification, see https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-label-names
pub const LOWERCASE_RFC_1123_LABEL_FMT: &str = "[a-z0-9]([-a-z0-9]*[a-z0-9])?";
const LOWERCASE_RFC_1123_LABEL_ERROR_MSG: &str = "a lowercase RFC 1123 label must consist of lower case alphanumeric characters or '-', and must start and end with an alphanumeric character";

/// This is a subdomain's max length in DNS (RFC 1123)
const RFC_1123_SUBDOMAIN_MAX_LENGTH: usize = 253;
const RFC_1123_SUBDOMAIN_FMT: &str =
concatcp!(RFC_1123_LABEL_FMT, "(\\.", RFC_1123_LABEL_FMT, ")*");
// This is a RFC 1123 format, see https://www.rfc-editor.org/rfc/rfc1123
const RFC_1123_LABEL_FMT: &str = "[a-zA-Z0-9]([-a-zA-Z0-9]*[a-zA-Z0-9])?";

const DOMAIN_MAX_LENGTH: usize = RFC_1123_SUBDOMAIN_MAX_LENGTH;
/// Same as [`RFC_1123_SUBDOMAIN_FMT`], but allows a trailing dot
const DOMAIN_FMT: &str = concatcp!(RFC_1123_SUBDOMAIN_FMT, "\\.?");
/// This is a subdomain's max length in DNS (RFC 1123)
pub const RFC_1123_SUBDOMAIN_MAX_LENGTH: usize = 253;
pub const LOWERCASE_RFC_1123_SUBDOMAIN_FMT: &str = concatcp!(
LOWERCASE_RFC_1123_LABEL_FMT,
"(\\.",
LOWERCASE_RFC_1123_LABEL_FMT,
")*"
);
const LOWERCASE_RFC_1123_SUBDOMAIN_ERROR_MSG: &str = "a lowercase RFC 1123 subdomain must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character";

pub const DOMAIN_MAX_LENGTH: usize = RFC_1123_SUBDOMAIN_MAX_LENGTH;

/// Same as [`RFC_1123_LABEL_FMT`], but allows a trailing dot
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// Same as [`RFC_1123_LABEL_FMT`], but allows a trailing dot
/// Same as [`LOWERCASE_RFC_1123_SUBDOMAIN_FMT`], but allows upper case letters and a trailing dot

const DOMAIN_FMT: &str = concatcp!(RFC_1123_LABEL_FMT, "(\\.", RFC_1123_LABEL_FMT, ")*\\.?");
const DOMAIN_ERROR_MSG: &str = "a domain must consist of alphanumeric characters, '-' or '.', and must start with an alphanumeric character and end with an alphanumeric character or '.'";

// FIXME: According to https://www.rfc-editor.org/rfc/rfc1035#section-2.3.1 domain names must start with a letter
// (and not a number).
const RFC_1035_LABEL_FMT: &str = "[a-z]([-a-z0-9]*[a-z0-9])?";
const RFC_1035_LABEL_ERROR_MSG: &str = "a DNS-1035 label must consist of lower case alphanumeric characters or '-', start with an alphabetic character, and end with an alphanumeric character";
// This is a modified RFC 1035 format according to the Kubernetes specification, see https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#rfc-1035-label-names
pub const LOWERCASE_RFC_1035_LABEL_FMT: &str = "[a-z]([-a-z0-9]*[a-z0-9])?";
const LOWERCASE_RFC_1035_LABEL_ERROR_MSG: &str = "a DNS-1035 label must consist of lower case alphanumeric characters or '-', start with an alphabetic character, and end with an alphanumeric character";

// This is a label's max length in DNS (RFC 1035)
const RFC_1035_LABEL_MAX_LENGTH: usize = 63;
pub const RFC_1035_LABEL_MAX_LENGTH: usize = 63;

// Technically Kerberos allows more realm names
// (https://web.mit.edu/kerberos/krb5-1.21/doc/admin/realm_config.html#realm-name),
Expand All @@ -54,12 +65,19 @@ pub(crate) static DOMAIN_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(&format!("^{DOMAIN_FMT}$")).expect("failed to compile domain regex")
});

static RFC_1123_LABEL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(&format!("^{RFC_1123_LABEL_FMT}$")).expect("failed to compile RFC 1123 label regex")
static LOWERCASE_RFC_1123_LABEL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(&format!("^{LOWERCASE_RFC_1123_LABEL_FMT}$"))
.expect("failed to compile RFC 1123 label regex")
});

static LOWERCASE_RFC_1123_SUBDOMAIN_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(&format!("^{LOWERCASE_RFC_1123_SUBDOMAIN_FMT}$"))
.expect("failed to compile RFC 1123 subdomain regex")
});

static RFC_1035_LABEL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(&format!("^{RFC_1035_LABEL_FMT}$")).expect("failed to compile RFC 1035 label regex")
static LOWERCASE_RFC_1035_LABEL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(&format!("^{LOWERCASE_RFC_1035_LABEL_FMT}$"))
.expect("failed to compile RFC 1035 label regex")
});

pub(crate) static KERBEROS_REALM_NAME_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Expand Down Expand Up @@ -198,28 +216,44 @@ pub fn is_domain(value: &str) -> Result {
])
}

/// Tests for a string that conforms to the definition of a label in DNS (RFC 1123).
/// Tests for a string that conforms to the kubernetes-specific definition of a label in DNS (RFC 1123)
/// used in Namespace names, see: [Kubernetes Docs](https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-label-names)
/// Maximum label length supported by k8s is 63 characters (minimum required).
pub fn is_rfc_1123_label(value: &str) -> Result {
pub fn is_lowercase_rfc_1123_label(value: &str) -> Result {
validate_all([
validate_str_length(value, RFC_1123_LABEL_MAX_LENGTH),
validate_str_regex(
value,
&RFC_1123_LABEL_REGEX,
RFC_1123_LABEL_ERROR_MSG,
&LOWERCASE_RFC_1123_LABEL_REGEX,
LOWERCASE_RFC_1123_LABEL_ERROR_MSG,
&["example-label", "1-label-1"],
),
])
}

/// Tests for a string that conforms to the definition of a label in DNS (RFC 1035).
pub fn is_rfc_1035_label(value: &str) -> Result {
/// Tests for a string that conforms to the kubernetes-specific definition of a subdomain in DNS (RFC 1123)
/// used in ConfigMap names, see [Kubernetes Docs](https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-subdomain-names)
pub fn is_lowercase_rfc_1123_subdomain(value: &str) -> Result {
validate_all([
validate_str_length(value, RFC_1123_SUBDOMAIN_MAX_LENGTH),
validate_str_regex(
value,
&LOWERCASE_RFC_1123_SUBDOMAIN_REGEX,
LOWERCASE_RFC_1123_SUBDOMAIN_ERROR_MSG,
&["example.com"],
),
])
}

/// Tests for a string that conforms to the kubernetes-specific definition of a label in DNS (RFC 1035)
/// used in Service names, see: [Kubernetes Docs](https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#rfc-1035-label-names)
pub fn is_lowercase_rfc_1035_label(value: &str) -> Result {
validate_all([
validate_str_length(value, RFC_1035_LABEL_MAX_LENGTH),
validate_str_regex(
value,
&RFC_1035_LABEL_REGEX,
RFC_1035_LABEL_ERROR_MSG,
&LOWERCASE_RFC_1035_LABEL_REGEX,
LOWERCASE_RFC_1035_LABEL_ERROR_MSG,
&["my-name", "abc-123"],
),
])
Expand Down Expand Up @@ -261,7 +295,7 @@ pub fn name_is_dns_label(name: &str, prefix: bool) -> Result {
name = mask_trailing_dash(name);
}

is_rfc_1035_label(&name)
is_lowercase_rfc_1035_label(&name)
}

/// Validates a namespace name.
Expand All @@ -277,28 +311,14 @@ mod tests {

use super::*;

const RFC_1123_SUBDOMAIN_ERROR_MSG: &str = "a RFC 1123 subdomain must consist of alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character";

static RFC_1123_SUBDOMAIN_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(&format!("^{RFC_1123_SUBDOMAIN_FMT}$"))
.expect("failed to compile RFC 1123 subdomain regex")
});

/// Tests for a string that conforms to the definition of a subdomain in DNS (RFC 1123).
fn is_rfc_1123_subdomain(value: &str) -> Result {
validate_all([
validate_str_length(value, RFC_1123_SUBDOMAIN_MAX_LENGTH),
validate_str_regex(
value,
&RFC_1123_SUBDOMAIN_REGEX,
RFC_1123_SUBDOMAIN_ERROR_MSG,
&["example.com"],
),
])
}

#[rstest]
#[case("")]
#[case("A")]
#[case("aBc")]
#[case("ABC")]
#[case("A1")]
#[case("A-1")]
#[case("1-A")]
#[case("-")]
#[case("a-")]
#[case("-a")]
Expand All @@ -325,6 +345,24 @@ mod tests {
#[case("1 ")]
#[case(" 1")]
#[case("1 2")]
#[case("A.a")]
#[case("aB.a")]
#[case("ab.A")]
#[case("A1.a")]
#[case("a1.A")]
#[case("A.1")]
#[case("aB.1")]
#[case("A1.1")]
#[case("0.A")]
#[case("01.A")]
#[case("012.A")]
#[case("1A.a")]
#[case("1a.A")]
#[case("1A.1")]
#[case("a.B.c.d.e")]
#[case("A.B.C.D.E")]
#[case("aa.bB.cc.dd.ee")]
#[case("AA.BB.CC.DD.EE")]
#[case("a@b")]
#[case("a,b")]
#[case("a_b")]
Expand All @@ -335,77 +373,53 @@ mod tests {
#[case("a$b")]
#[case(&"a".repeat(254))]
fn is_rfc_1123_subdomain_fail(#[case] value: &str) {
assert!(is_rfc_1123_subdomain(value).is_err());
assert!(is_lowercase_rfc_1123_subdomain(value).is_err());
}

#[rstest]
#[case("a")]
#[case("A")]
#[case("ab")]
#[case("abc")]
#[case("aBc")]
#[case("ABC")]
#[case("a1")]
#[case("A1")]
#[case("a-1")]
#[case("A-1")]
#[case("a--1--2--b")]
#[case("0")]
#[case("01")]
#[case("012")]
#[case("1a")]
#[case("1-a")]
#[case("1-A")]
#[case("1--a--b--2")]
#[case("a.a")]
#[case("A.a")]
#[case("ab.a")]
#[case("aB.a")]
#[case("ab.A")]
#[case("abc.a")]
#[case("a1.a")]
#[case("A1.a")]
#[case("a1.A")]
#[case("a-1.a")]
#[case("a--1--2--b.a")]
#[case("a.1")]
#[case("A.1")]
#[case("ab.1")]
#[case("aB.1")]
#[case("abc.1")]
#[case("a1.1")]
#[case("A1.1")]
#[case("a-1.1")]
#[case("a--1--2--b.1")]
#[case("0.a")]
#[case("0.A")]
#[case("01.a")]
#[case("01.A")]
#[case("012.a")]
#[case("012.A")]
#[case("1a.a")]
#[case("1A.a")]
#[case("1a.A")]
#[case("1-a.a")]
#[case("1--a--b--2")]
#[case("0.1")]
#[case("01.1")]
#[case("012.1")]
#[case("1a.1")]
#[case("1A.1")]
#[case("1-a.1")]
#[case("1--a--b--2.1")]
#[case("a.b.c.d.e")]
#[case("a.B.c.d.e")]
#[case("A.B.C.D.E")]
#[case("aa.bb.cc.dd.ee")]
#[case("aa.bB.cc.dd.ee")]
#[case("AA.BB.CC.DD.EE")]
#[case("1.2.3.4.5")]
#[case("11.22.33.44.55")]
#[case(&"a".repeat(253))]
fn is_rfc_1123_subdomain_pass(#[case] value: &str) {
assert!(is_rfc_1123_subdomain(value).is_ok());
assert!(is_lowercase_rfc_1123_subdomain(value).is_ok());
// Every valid RFC1123 is also a valid domain
assert!(is_domain(value).is_ok());
}
Expand Down Expand Up @@ -469,7 +483,7 @@ mod tests {
#[case("1 2")]
#[case(&"a".repeat(64))]
fn is_rfc_1035_label_fail(#[case] value: &str) {
assert!(is_rfc_1035_label(value).is_err());
assert!(is_lowercase_rfc_1035_label(value).is_err());
}

#[rstest]
Expand All @@ -481,6 +495,6 @@ mod tests {
#[case("a--1--2--b")]
#[case(&"a".repeat(63))]
fn is_rfc_1035_label_pass(#[case] value: &str) {
assert!(is_rfc_1035_label(value).is_ok());
assert!(is_lowercase_rfc_1035_label(value).is_ok());
}
}