diff --git a/crates/stackable-operator/CHANGELOG.md b/crates/stackable-operator/CHANGELOG.md index d54ef2860..2e8732c3d 100644 --- a/crates/stackable-operator/CHANGELOG.md +++ b/crates/stackable-operator/CHANGELOG.md @@ -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 diff --git a/crates/stackable-operator/src/builder/pod/container.rs b/crates/stackable-operator/src/builder/pod/container.rs index 3f1620a2f..62c6115b5 100644 --- a/crates/stackable-operator/src/builder/pod/container.rs +++ b/crates/stackable-operator/src/builder/pod/container.rs @@ -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 = std::result::Result; @@ -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 }) } } @@ -441,7 +442,7 @@ mod tests { resources::ResourceRequirementsBuilder, }, commons::resources::ResourceRequirementsType, - validation::RFC_1123_LABEL_FMT, + validation::LOWERCASE_RFC_1123_LABEL_FMT, }; #[test] @@ -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}""#), ); } @@ -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}""# ), ); } diff --git a/crates/stackable-operator/src/validation.rs b/crates/stackable-operator/src/validation.rs index d9b895715..dbc5beb9e 100644 --- a/crates/stackable-operator/src/validation.rs +++ b/crates/stackable-operator/src/validation.rs @@ -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; + +/// String of one or multiple [`RFC_1123_LABEL_FMT`] separated by dots but also allowing 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), @@ -54,12 +65,19 @@ pub(crate) static DOMAIN_REGEX: LazyLock = LazyLock::new(|| { Regex::new(&format!("^{DOMAIN_FMT}$")).expect("failed to compile domain regex") }); -static RFC_1123_LABEL_REGEX: LazyLock = LazyLock::new(|| { - Regex::new(&format!("^{RFC_1123_LABEL_FMT}$")).expect("failed to compile RFC 1123 label regex") +static LOWERCASE_RFC_1123_LABEL_REGEX: LazyLock = 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 = LazyLock::new(|| { + Regex::new(&format!("^{LOWERCASE_RFC_1123_SUBDOMAIN_FMT}$")) + .expect("failed to compile RFC 1123 subdomain regex") }); -static RFC_1035_LABEL_REGEX: LazyLock = LazyLock::new(|| { - Regex::new(&format!("^{RFC_1035_LABEL_FMT}$")).expect("failed to compile RFC 1035 label regex") +static LOWERCASE_RFC_1035_LABEL_REGEX: LazyLock = 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 = LazyLock::new(|| { @@ -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"], ), ]) @@ -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. @@ -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 = 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")] @@ -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")] @@ -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()); } @@ -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] @@ -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()); } }