Skip to content

Commit 20b0183

Browse files
feat: Add stackable-versioned and k8s-version crates for CRD versioning (#764)
* chore: Add skeleton code * feat: Add basic container and attribute validation * Start field attribute validation * Move code generation into structs * Adjust field actions which require generation in multiple versions * Add basic support for added and always present fields * feat(k8s-version): Add Kubernetes version crate This crate enables parsing of Kubernetes API versions from strings into well-defined structs with support for ordering and equality checking. * feat(k8s-version): Add support for FromMeta This adds support for darling's FromMeta trait, which enables implementors to be constructed from macro attributes. * chore(k8s-version): Add changelog * test(k8s-version): Add more unit tests * docs(k8s-version): Add README * chore: Switch work machine * Add basic support for renamed fields * Enfore version sorting, add option to opt out * Add basic support for deprecated fields * Remove unused dependency * Fix k8s-version unit tests * Add basic support for multiple field actions on one field * Restructure field validation code * Generate chain of statuses * Add Ord impl for Level and Version * Add Part(Ord) unit tests for Level and Version * Add FromMeta unit test for Level * Generate code for multiple field actions * Improve field attribute validation * Improve error handling, add doc comments * k8s-version: Add validated Group * k8s-version: Add library doc comments * k8s-version: Add doc comments for error enums * Add more (doc) comments * Add changelog for stackable-versioned * Apply suggestions Co-authored-by: Nick <[email protected]> * Clean-up suggestions * Rename API_VERSION_REGEX to API_GROUP_REGEX * Use expect instead of unwrap for regular expressions * Include duplicate version name in error message * Bump json-patch to 1.4.0 because 1.3.0 was yanked * Adjust level format * Improve derive macro test * Add how to use the ApiVersion::new() function * Add doc comment for FieldAttributes::validate_versions * Fix doc comment * Fix doc tests --------- Co-authored-by: Nick <[email protected]>
1 parent 462c031 commit 20b0183

23 files changed

+1576
-1
lines changed

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
Please see the relevant crate changelogs:
44

5+
- [k8s-version](./crates/k8s-version/CHANGELOG.md)
56
- [stackable-certs](./crates/stackable-certs/CHANGELOG.md)
67
- [stackable-operator](./crates/stackable-operator/CHANGELOG.md)
78
- [stackable-operator-derive](./crates/stackable-operator-derive/CHANGELOG.md)
89
- [stackable-telemetry](./crates/stackable-telemetry/CHANGELOG.md)
10+
- [stackable-versioned](./crates/stackable-versioned/CHANGELOG.md)
911
- [stackable-webhook](./crates/stackable-webhook/CHANGELOG.md)

Cargo.toml

+2-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ futures = "0.3.30"
2626
futures-util = "0.3.30"
2727
hyper = { version = "1.3.1", features = ["full"] }
2828
hyper-util = "0.1.3"
29-
json-patch = "1.2.0"
29+
json-patch = "1.4.0"
3030
k8s-openapi = { version = "0.21.1", default-features = false, features = ["schemars", "v1_29"] }
3131
# We use rustls instead of openssl for easier portablitly, e.g. so that we can build stackablectl without the need to vendor (build from source) openssl
3232
kube = { version = "0.90.0", default-features = false, features = ["client", "jsonpatch", "runtime", "derive", "rustls-tls"] }
@@ -46,6 +46,7 @@ rand_core = "0.6.4"
4646
regex = "1.10.4"
4747
rsa = { version = "0.9.6", features = ["sha2"] }
4848
rstest = "0.19.0"
49+
rstest_reuse = "0.6.0"
4950
schemars = { version = "0.8.16", features = ["url"] }
5051
semver = "1.0.22"
5152
serde = { version = "1.0.198", features = ["derive"] }

crates/k8s-version/CHANGELOG.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
## [Unreleased]

crates/k8s-version/Cargo.toml

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
[package]
2+
name = "k8s-version"
3+
version = "0.1.0"
4+
authors.workspace = true
5+
license.workspace = true
6+
edition.workspace = true
7+
repository.workspace = true
8+
9+
[features]
10+
darling = ["dep:darling"]
11+
12+
[dependencies]
13+
darling = { workspace = true, optional = true }
14+
lazy_static.workspace = true
15+
regex.workspace = true
16+
snafu.workspace = true
17+
18+
[dev-dependencies]
19+
rstest.workspace = true
20+
rstest_reuse.workspace = true
21+
quote.workspace = true
22+
proc-macro2.workspace = true
23+
syn.workspace = true

crates/k8s-version/README.md

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# k8s-version
2+
3+
A small helper crate to parse and validate Kubernetes resource API versions.
4+
5+
```rust
6+
use k8s_version::ApiVersion;
7+
8+
let api_version = ApiVersion::from_str("extensions/v1beta1")?;
9+
```

crates/k8s-version/src/api_version.rs

+158
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
use std::{cmp::Ordering, fmt::Display, str::FromStr};
2+
3+
use snafu::{ResultExt, Snafu};
4+
5+
#[cfg(feature = "darling")]
6+
use darling::FromMeta;
7+
8+
use crate::{Group, ParseGroupError, ParseVersionError, Version};
9+
10+
/// Error variants which can be encountered when creating a new [`ApiVersion`]
11+
/// from unparsed input.
12+
#[derive(Debug, PartialEq, Snafu)]
13+
pub enum ParseApiVersionError {
14+
#[snafu(display("failed to parse version"))]
15+
ParseVersion { source: ParseVersionError },
16+
17+
#[snafu(display("failed to parse group"))]
18+
ParseGroup { source: ParseGroupError },
19+
}
20+
21+
/// A Kubernetes API version, following the `(<GROUP>/)<VERSION>` format.
22+
///
23+
/// The `<VERSION>` string must follow the DNS label format defined in the
24+
/// [Kubernetes design proposals archive][1]. The `<GROUP>` string must be lower
25+
/// case and must be a valid DNS subdomain.
26+
///
27+
/// ### See
28+
///
29+
/// - <https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#api-conventions>
30+
/// - <https://kubernetes.io/docs/reference/using-api/#api-versioning>
31+
/// - <https://kubernetes.io/docs/reference/using-api/#api-groups>
32+
///
33+
/// [1]: https://github.com/kubernetes/design-proposals-archive/blob/main/architecture/identifiers.md#definitions
34+
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
35+
pub struct ApiVersion {
36+
pub group: Option<Group>,
37+
pub version: Version,
38+
}
39+
40+
impl FromStr for ApiVersion {
41+
type Err = ParseApiVersionError;
42+
43+
fn from_str(input: &str) -> Result<Self, Self::Err> {
44+
let (group, version) = if let Some((group, version)) = input.split_once('/') {
45+
let group = Group::from_str(group).context(ParseGroupSnafu)?;
46+
47+
(
48+
Some(group),
49+
Version::from_str(version).context(ParseVersionSnafu)?,
50+
)
51+
} else {
52+
(None, Version::from_str(input).context(ParseVersionSnafu)?)
53+
};
54+
55+
Ok(Self { group, version })
56+
}
57+
}
58+
59+
impl PartialOrd for ApiVersion {
60+
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
61+
match self.group.partial_cmp(&other.group) {
62+
Some(Ordering::Equal) => {}
63+
_ => return None,
64+
}
65+
self.version.partial_cmp(&other.version)
66+
}
67+
}
68+
69+
impl Display for ApiVersion {
70+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71+
match &self.group {
72+
Some(group) => write!(f, "{group}/{version}", version = self.version),
73+
None => write!(f, "{version}", version = self.version),
74+
}
75+
}
76+
}
77+
78+
#[cfg(feature = "darling")]
79+
impl FromMeta for ApiVersion {
80+
fn from_string(value: &str) -> darling::Result<Self> {
81+
Self::from_str(value).map_err(darling::Error::custom)
82+
}
83+
}
84+
85+
impl ApiVersion {
86+
/// Create a new Kubernetes API version.
87+
pub fn new(group: Option<Group>, version: Version) -> Self {
88+
Self { group, version }
89+
}
90+
91+
/// Try to create a new Kubernetes API version based on the unvalidated
92+
/// `group` string.
93+
pub fn try_new(group: Option<&str>, version: Version) -> Result<Self, ParseApiVersionError> {
94+
let group = group
95+
.map(|g| g.parse())
96+
.transpose()
97+
.context(ParseGroupSnafu)?;
98+
99+
Ok(Self { group, version })
100+
}
101+
}
102+
103+
#[cfg(test)]
104+
mod test {
105+
use super::*;
106+
use crate::Level;
107+
108+
use rstest::rstest;
109+
110+
#[cfg(feature = "darling")]
111+
use quote::quote;
112+
113+
#[cfg(feature = "darling")]
114+
fn parse_meta(tokens: proc_macro2::TokenStream) -> ::std::result::Result<syn::Meta, String> {
115+
let attribute: syn::Attribute = syn::parse_quote!(#[#tokens]);
116+
Ok(attribute.meta)
117+
}
118+
119+
#[rstest]
120+
#[case("extensions/v1beta1", ApiVersion { group: Some("extensions".parse().unwrap()), version: Version { major: 1, level: Some(Level::Beta(1)) } })]
121+
#[case("v1beta1", ApiVersion { group: None, version: Version { major: 1, level: Some(Level::Beta(1)) } })]
122+
#[case("v1", ApiVersion { group: None, version: Version { major: 1, level: None } })]
123+
fn valid_api_version(#[case] input: &str, #[case] expected: ApiVersion) {
124+
let api_version = ApiVersion::from_str(input).expect("valid Kubernetes api version");
125+
assert_eq!(api_version, expected);
126+
}
127+
128+
#[rstest]
129+
#[case("extensions/beta1", ParseApiVersionError::ParseVersion { source: ParseVersionError::InvalidFormat })]
130+
#[case("/v1beta1", ParseApiVersionError::ParseGroup { source: ParseGroupError::Empty })]
131+
fn invalid_api_version(#[case] input: &str, #[case] error: ParseApiVersionError) {
132+
let err = ApiVersion::from_str(input).expect_err("invalid Kubernetes api versions");
133+
assert_eq!(err, error);
134+
}
135+
136+
#[rstest]
137+
#[case(Version {major: 1, level: Some(Level::Alpha(2))}, Version {major: 1, level: Some(Level::Alpha(1))}, Ordering::Greater)]
138+
#[case(Version {major: 1, level: Some(Level::Alpha(1))}, Version {major: 1, level: Some(Level::Alpha(1))}, Ordering::Equal)]
139+
#[case(Version {major: 1, level: Some(Level::Alpha(1))}, Version {major: 1, level: Some(Level::Alpha(2))}, Ordering::Less)]
140+
#[case(Version {major: 1, level: None}, Version {major: 1, level: Some(Level::Alpha(2))}, Ordering::Greater)]
141+
#[case(Version {major: 1, level: None}, Version {major: 1, level: Some(Level::Beta(2))}, Ordering::Greater)]
142+
#[case(Version {major: 1, level: None}, Version {major: 1, level: None}, Ordering::Equal)]
143+
#[case(Version {major: 1, level: None}, Version {major: 2, level: None}, Ordering::Less)]
144+
fn partial_ord(#[case] input: Version, #[case] other: Version, #[case] expected: Ordering) {
145+
assert_eq!(input.partial_cmp(&other), Some(expected));
146+
}
147+
148+
#[cfg(feature = "darling")]
149+
#[rstest]
150+
#[case(quote!(ignore = "extensions/v1beta1"), ApiVersion { group: Some("extensions".parse().unwrap()), version: Version { major: 1, level: Some(Level::Beta(1)) } })]
151+
#[case(quote!(ignore = "v1beta1"), ApiVersion { group: None, version: Version { major: 1, level: Some(Level::Beta(1)) } })]
152+
#[case(quote!(ignore = "v1"), ApiVersion { group: None, version: Version { major: 1, level: None } })]
153+
fn from_meta(#[case] input: proc_macro2::TokenStream, #[case] expected: ApiVersion) {
154+
let meta = parse_meta(input).expect("valid attribute tokens");
155+
let api_version = ApiVersion::from_meta(&meta).expect("version must parse from attribute");
156+
assert_eq!(api_version, expected);
157+
}
158+
}

crates/k8s-version/src/group.rs

+67
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
use std::{fmt, ops::Deref, str::FromStr};
2+
3+
use lazy_static::lazy_static;
4+
use regex::Regex;
5+
use snafu::{ensure, Snafu};
6+
7+
const MAX_GROUP_LENGTH: usize = 253;
8+
9+
lazy_static! {
10+
static ref API_GROUP_REGEX: Regex =
11+
Regex::new(r"^(?:(?:[a-z0-9][a-z0-9-]{0,61}[a-z0-9])\.?)+$")
12+
.expect("failed to compile API group regex");
13+
}
14+
15+
/// Error variants which can be encountered when creating a new [`Group`] from
16+
/// unparsed input.
17+
#[derive(Debug, PartialEq, Snafu)]
18+
pub enum ParseGroupError {
19+
#[snafu(display("group must not be empty"))]
20+
Empty,
21+
22+
#[snafu(display("group must not be longer than 253 characters"))]
23+
TooLong,
24+
25+
#[snafu(display("group must be a valid DNS subdomain"))]
26+
InvalidFormat,
27+
}
28+
29+
/// A validated Kubernetes group.
30+
///
31+
/// The group string must follow these rules:
32+
///
33+
/// - must be non-empty
34+
/// - must only contain lower case characters
35+
/// - and must be a valid DNS subdomain
36+
///
37+
/// ### See
38+
///
39+
/// - <https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#api-conventions>
40+
#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd)]
41+
pub struct Group(String);
42+
43+
impl FromStr for Group {
44+
type Err = ParseGroupError;
45+
46+
fn from_str(group: &str) -> Result<Self, Self::Err> {
47+
ensure!(!group.is_empty(), EmptySnafu);
48+
ensure!(group.len() <= MAX_GROUP_LENGTH, TooLongSnafu);
49+
ensure!(API_GROUP_REGEX.is_match(group), InvalidFormatSnafu);
50+
51+
Ok(Self(group.to_string()))
52+
}
53+
}
54+
55+
impl fmt::Display for Group {
56+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57+
write!(f, "{}", self.0)
58+
}
59+
}
60+
61+
impl Deref for Group {
62+
type Target = str;
63+
64+
fn deref(&self) -> &Self::Target {
65+
&self.0
66+
}
67+
}

0 commit comments

Comments
 (0)