Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add stackable-versioned and k8s-version crates for CRD versioning #764

Merged
merged 45 commits into from
May 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
31df5b1
chore: Add skeleton code
Techassi Apr 4, 2024
c93cdee
feat: Add basic container and attribute validation
Techassi Apr 8, 2024
109e38f
Start field attribute validation
Techassi Apr 8, 2024
40df3af
Move code generation into structs
Techassi Apr 9, 2024
cff3fe9
Adjust field actions which require generation in multiple versions
Techassi Apr 16, 2024
aaaedaa
Add basic support for added and always present fields
Techassi Apr 16, 2024
be20ad8
feat(k8s-version): Add Kubernetes version crate
Techassi Apr 19, 2024
f46d370
feat(k8s-version): Add support for FromMeta
Techassi Apr 19, 2024
50f8076
chore(k8s-version): Add changelog
Techassi Apr 19, 2024
b09ad5f
test(k8s-version): Add more unit tests
Techassi Apr 19, 2024
015ce8d
docs(k8s-version): Add README
Techassi Apr 19, 2024
9ba774f
chore: Switch work machine
Techassi Apr 19, 2024
03c32e7
Add basic support for renamed fields
Techassi Apr 22, 2024
5448375
Enfore version sorting, add option to opt out
Techassi Apr 22, 2024
cc2190a
Add basic support for deprecated fields
Techassi Apr 22, 2024
2eb23d7
Remove unused dependency
Techassi Apr 22, 2024
2586e77
Fix k8s-version unit tests
Techassi Apr 22, 2024
9f6c682
chore: Merge branch 'main' into feat/crd-versioning
Techassi Apr 22, 2024
2506fc4
Add basic support for multiple field actions on one field
Techassi Apr 22, 2024
957dd91
Restructure field validation code
Techassi Apr 23, 2024
cba9153
Generate chain of statuses
Techassi Apr 25, 2024
2269675
Merge branch 'main' into feat/crd-versioning
Techassi Apr 29, 2024
f3515e2
Add Ord impl for Level and Version
Techassi Apr 29, 2024
e324d30
Add Part(Ord) unit tests for Level and Version
Techassi Apr 29, 2024
3fa20f4
Add FromMeta unit test for Level
Techassi Apr 29, 2024
bd935d6
Generate code for multiple field actions
Techassi Apr 30, 2024
a9eeafd
Improve field attribute validation
Techassi Apr 30, 2024
0916a93
Improve error handling, add doc comments
Techassi Apr 30, 2024
727fbdf
k8s-version: Add validated Group
Techassi May 2, 2024
92fafcf
k8s-version: Add library doc comments
Techassi May 2, 2024
1952db8
k8s-version: Add doc comments for error enums
Techassi May 2, 2024
6148a09
Add more (doc) comments
Techassi May 3, 2024
3252c55
Add changelog for stackable-versioned
Techassi May 6, 2024
2393ae0
Apply suggestions
Techassi May 6, 2024
9e6fdef
Clean-up suggestions
Techassi May 6, 2024
8a38f47
Rename API_VERSION_REGEX to API_GROUP_REGEX
Techassi May 6, 2024
1aa7fcf
Use expect instead of unwrap for regular expressions
Techassi May 6, 2024
36279ff
Include duplicate version name in error message
Techassi May 6, 2024
cbf7c8c
Bump json-patch to 1.4.0 because 1.3.0 was yanked
Techassi May 6, 2024
0fdd492
Adjust level format
Techassi May 6, 2024
005c203
Improve derive macro test
Techassi May 6, 2024
4944a7f
Add how to use the ApiVersion::new() function
Techassi May 6, 2024
3a2e052
Add doc comment for FieldAttributes::validate_versions
Techassi May 6, 2024
232f883
Fix doc comment
Techassi May 6, 2024
ba3fc19
Fix doc tests
Techassi May 6, 2024
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

Please see the relevant crate changelogs:

- [k8s-version](./crates/k8s-version/CHANGELOG.md)
- [stackable-certs](./crates/stackable-certs/CHANGELOG.md)
- [stackable-operator](./crates/stackable-operator/CHANGELOG.md)
- [stackable-operator-derive](./crates/stackable-operator-derive/CHANGELOG.md)
- [stackable-telemetry](./crates/stackable-telemetry/CHANGELOG.md)
- [stackable-versioned](./crates/stackable-versioned/CHANGELOG.md)
- [stackable-webhook](./crates/stackable-webhook/CHANGELOG.md)
3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ futures = "0.3.30"
futures-util = "0.3.30"
hyper = { version = "1.3.1", features = ["full"] }
hyper-util = "0.1.3"
json-patch = "1.2.0"
json-patch = "1.4.0"
k8s-openapi = { version = "0.21.1", default-features = false, features = ["schemars", "v1_29"] }
# 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
kube = { version = "0.90.0", default-features = false, features = ["client", "jsonpatch", "runtime", "derive", "rustls-tls"] }
Expand All @@ -46,6 +46,7 @@ rand_core = "0.6.4"
regex = "1.10.4"
rsa = { version = "0.9.6", features = ["sha2"] }
rstest = "0.19.0"
rstest_reuse = "0.6.0"
Techassi marked this conversation as resolved.
Show resolved Hide resolved
schemars = { version = "0.8.16", features = ["url"] }
semver = "1.0.22"
serde = { version = "1.0.198", features = ["derive"] }
Expand Down
5 changes: 5 additions & 0 deletions crates/k8s-version/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Changelog

All notable changes to this project will be documented in this file.

## [Unreleased]
Techassi marked this conversation as resolved.
Show resolved Hide resolved
23 changes: 23 additions & 0 deletions crates/k8s-version/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
[package]
name = "k8s-version"
version = "0.1.0"
authors.workspace = true
license.workspace = true
edition.workspace = true
repository.workspace = true

[features]
darling = ["dep:darling"]
Techassi marked this conversation as resolved.
Show resolved Hide resolved

[dependencies]
darling = { workspace = true, optional = true }
lazy_static.workspace = true
regex.workspace = true
snafu.workspace = true

[dev-dependencies]
rstest.workspace = true
rstest_reuse.workspace = true
quote.workspace = true
proc-macro2.workspace = true
syn.workspace = true
9 changes: 9 additions & 0 deletions crates/k8s-version/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# k8s-version

A small helper crate to parse and validate Kubernetes resource API versions.

```rust
use k8s_version::ApiVersion;

let api_version = ApiVersion::from_str("extensions/v1beta1")?;
```
158 changes: 158 additions & 0 deletions crates/k8s-version/src/api_version.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
use std::{cmp::Ordering, fmt::Display, str::FromStr};

use snafu::{ResultExt, Snafu};

#[cfg(feature = "darling")]
use darling::FromMeta;

use crate::{Group, ParseGroupError, ParseVersionError, Version};

/// Error variants which can be encountered when creating a new [`ApiVersion`]
/// from unparsed input.
#[derive(Debug, PartialEq, Snafu)]
pub enum ParseApiVersionError {
#[snafu(display("failed to parse version"))]
ParseVersion { source: ParseVersionError },

#[snafu(display("failed to parse group"))]
ParseGroup { source: ParseGroupError },
}

/// A Kubernetes API version, following the `(<GROUP>/)<VERSION>` format.
///
/// The `<VERSION>` string must follow the DNS label format defined in the
/// [Kubernetes design proposals archive][1]. The `<GROUP>` string must be lower
/// case and must be a valid DNS subdomain.
///
/// ### See
///
/// - <https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#api-conventions>
/// - <https://kubernetes.io/docs/reference/using-api/#api-versioning>
/// - <https://kubernetes.io/docs/reference/using-api/#api-groups>
///
/// [1]: https://github.com/kubernetes/design-proposals-archive/blob/main/architecture/identifiers.md#definitions
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct ApiVersion {
pub group: Option<Group>,
pub version: Version,
}

impl FromStr for ApiVersion {
type Err = ParseApiVersionError;

fn from_str(input: &str) -> Result<Self, Self::Err> {
let (group, version) = if let Some((group, version)) = input.split_once('/') {
let group = Group::from_str(group).context(ParseGroupSnafu)?;

(
Some(group),
Version::from_str(version).context(ParseVersionSnafu)?,
)
} else {
(None, Version::from_str(input).context(ParseVersionSnafu)?)
};

Ok(Self { group, version })
}
}

impl PartialOrd for ApiVersion {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
match self.group.partial_cmp(&other.group) {
Some(Ordering::Equal) => {}
_ => return None,
}
self.version.partial_cmp(&other.version)
}
}

impl Display for ApiVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.group {
Some(group) => write!(f, "{group}/{version}", version = self.version),
None => write!(f, "{version}", version = self.version),
}
}
}

#[cfg(feature = "darling")]
impl FromMeta for ApiVersion {
fn from_string(value: &str) -> darling::Result<Self> {
Self::from_str(value).map_err(darling::Error::custom)
}
}

impl ApiVersion {
/// Create a new Kubernetes API version.
pub fn new(group: Option<Group>, version: Version) -> Self {
Self { group, version }
}

/// Try to create a new Kubernetes API version based on the unvalidated
/// `group` string.
pub fn try_new(group: Option<&str>, version: Version) -> Result<Self, ParseApiVersionError> {
let group = group
.map(|g| g.parse())
.transpose()
.context(ParseGroupSnafu)?;

Ok(Self { group, version })
}
}

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

use rstest::rstest;

#[cfg(feature = "darling")]
use quote::quote;

#[cfg(feature = "darling")]
fn parse_meta(tokens: proc_macro2::TokenStream) -> ::std::result::Result<syn::Meta, String> {
let attribute: syn::Attribute = syn::parse_quote!(#[#tokens]);
Ok(attribute.meta)
}

#[rstest]
#[case("extensions/v1beta1", ApiVersion { group: Some("extensions".parse().unwrap()), version: Version { major: 1, level: Some(Level::Beta(1)) } })]
#[case("v1beta1", ApiVersion { group: None, version: Version { major: 1, level: Some(Level::Beta(1)) } })]
#[case("v1", ApiVersion { group: None, version: Version { major: 1, level: None } })]
fn valid_api_version(#[case] input: &str, #[case] expected: ApiVersion) {
let api_version = ApiVersion::from_str(input).expect("valid Kubernetes api version");
assert_eq!(api_version, expected);
}

#[rstest]
#[case("extensions/beta1", ParseApiVersionError::ParseVersion { source: ParseVersionError::InvalidFormat })]
#[case("/v1beta1", ParseApiVersionError::ParseGroup { source: ParseGroupError::Empty })]
fn invalid_api_version(#[case] input: &str, #[case] error: ParseApiVersionError) {
let err = ApiVersion::from_str(input).expect_err("invalid Kubernetes api versions");
assert_eq!(err, error);
}

#[rstest]
#[case(Version {major: 1, level: Some(Level::Alpha(2))}, Version {major: 1, level: Some(Level::Alpha(1))}, Ordering::Greater)]
#[case(Version {major: 1, level: Some(Level::Alpha(1))}, Version {major: 1, level: Some(Level::Alpha(1))}, Ordering::Equal)]
#[case(Version {major: 1, level: Some(Level::Alpha(1))}, Version {major: 1, level: Some(Level::Alpha(2))}, Ordering::Less)]
#[case(Version {major: 1, level: None}, Version {major: 1, level: Some(Level::Alpha(2))}, Ordering::Greater)]
#[case(Version {major: 1, level: None}, Version {major: 1, level: Some(Level::Beta(2))}, Ordering::Greater)]
#[case(Version {major: 1, level: None}, Version {major: 1, level: None}, Ordering::Equal)]
#[case(Version {major: 1, level: None}, Version {major: 2, level: None}, Ordering::Less)]
fn partial_ord(#[case] input: Version, #[case] other: Version, #[case] expected: Ordering) {
assert_eq!(input.partial_cmp(&other), Some(expected));
}

#[cfg(feature = "darling")]
#[rstest]
#[case(quote!(ignore = "extensions/v1beta1"), ApiVersion { group: Some("extensions".parse().unwrap()), version: Version { major: 1, level: Some(Level::Beta(1)) } })]
#[case(quote!(ignore = "v1beta1"), ApiVersion { group: None, version: Version { major: 1, level: Some(Level::Beta(1)) } })]
#[case(quote!(ignore = "v1"), ApiVersion { group: None, version: Version { major: 1, level: None } })]
fn from_meta(#[case] input: proc_macro2::TokenStream, #[case] expected: ApiVersion) {
let meta = parse_meta(input).expect("valid attribute tokens");
let api_version = ApiVersion::from_meta(&meta).expect("version must parse from attribute");
assert_eq!(api_version, expected);
}
}
67 changes: 67 additions & 0 deletions crates/k8s-version/src/group.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
use std::{fmt, ops::Deref, str::FromStr};

use lazy_static::lazy_static;
use regex::Regex;
use snafu::{ensure, Snafu};

const MAX_GROUP_LENGTH: usize = 253;

lazy_static! {
static ref API_GROUP_REGEX: Regex =
Regex::new(r"^(?:(?:[a-z0-9][a-z0-9-]{0,61}[a-z0-9])\.?)+$")
.expect("failed to compile API group regex");
}

/// Error variants which can be encountered when creating a new [`Group`] from
/// unparsed input.
#[derive(Debug, PartialEq, Snafu)]
pub enum ParseGroupError {
#[snafu(display("group must not be empty"))]
Empty,

#[snafu(display("group must not be longer than 253 characters"))]
TooLong,

#[snafu(display("group must be a valid DNS subdomain"))]
InvalidFormat,
}

/// A validated Kubernetes group.
///
/// The group string must follow these rules:
///
/// - must be non-empty
/// - must only contain lower case characters
/// - and must be a valid DNS subdomain
///
/// ### See
///
/// - <https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#api-conventions>
#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd)]
pub struct Group(String);

impl FromStr for Group {
type Err = ParseGroupError;

fn from_str(group: &str) -> Result<Self, Self::Err> {
ensure!(!group.is_empty(), EmptySnafu);
ensure!(group.len() <= MAX_GROUP_LENGTH, TooLongSnafu);
ensure!(API_GROUP_REGEX.is_match(group), InvalidFormatSnafu);

Ok(Self(group.to_string()))
}
}

impl fmt::Display for Group {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}

impl Deref for Group {
Techassi marked this conversation as resolved.
Show resolved Hide resolved
type Target = str;

fn deref(&self) -> &Self::Target {
&self.0
}
}
Loading
Loading