diff --git a/CHANGELOG.md b/CHANGELOG.md index 96a6709b5..96c05352b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Please see the relevant crate changelogs: - [stackable-certs](./crates/stackable-certs/CHANGELOG.md) - [stackable-operator](./crates/stackable-operator/CHANGELOG.md) - [stackable-operator-derive](./crates/stackable-operator-derive/CHANGELOG.md) +- [stackable-shared](./crates/stackable-shared/CHANGELOG.md) - [stackable-telemetry](./crates/stackable-telemetry/CHANGELOG.md) - [stackable-versioned](./crates/stackable-versioned/CHANGELOG.md) - [stackable-webhook](./crates/stackable-webhook/CHANGELOG.md) diff --git a/Cargo.lock b/Cargo.lock index 3612a8a51..e2ccf7531 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3028,6 +3028,7 @@ dependencies = [ "serde_yaml", "snafu 0.8.4", "stackable-operator-derive", + "stackable-shared", "strum", "tempfile", "time", @@ -3050,6 +3051,18 @@ dependencies = [ "syn 2.0.77", ] +[[package]] +name = "stackable-shared" +version = "0.0.1" +dependencies = [ + "k8s-openapi", + "kube", + "semver", + "serde", + "serde_yaml", + "snafu 0.8.4", +] + [[package]] name = "stackable-telemetry" version = "0.2.0" diff --git a/crates/stackable-operator/CHANGELOG.md b/crates/stackable-operator/CHANGELOG.md index e1775db1c..7404e2561 100644 --- a/crates/stackable-operator/CHANGELOG.md +++ b/crates/stackable-operator/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- Re-export the `YamlSchema` trait and the `stackable-shared` crate as the `shared` module ([#883]). + +### Changed + +- BREAKING: The `CustomResourceExt` trait is now re-exported from the `stackable-shared` crate. The + trait functions use the same parameters but return a different error type ([#883]). + +### Removed + +- BREAKING: The `CustomResourceExt` trait doesn't provide a `generate_yaml_schema` function any + more. Instead, use the high-level functions to write the schema to a file, write it to stdout or + use it as a `String`. + +[#883]: https://github.com/stackabletech/operator-rs/pull/883 + ## [0.78.0] - 2024-09-30 ### Added diff --git a/crates/stackable-operator/Cargo.toml b/crates/stackable-operator/Cargo.toml index b98460395..0196fb8b6 100644 --- a/crates/stackable-operator/Cargo.toml +++ b/crates/stackable-operator/Cargo.toml @@ -11,7 +11,8 @@ repository.workspace = true time = ["dep:time"] [dependencies] -stackable-operator-derive = { path = "../stackable-operator-derive" } +stackable-operator-derive = { path = "../stackable-operator-derive", version = "0.3.1" } +stackable-shared = { path = "../stackable-shared", version = "0.0.1" } chrono.workspace = true clap.workspace = true diff --git a/crates/stackable-operator/src/cli.rs b/crates/stackable-operator/src/cli.rs index 10bdc18fc..022502564 100644 --- a/crates/stackable-operator/src/cli.rs +++ b/crates/stackable-operator/src/cli.rs @@ -14,7 +14,7 @@ //! use kube::CustomResource; //! use schemars::JsonSchema; //! use serde::{Deserialize, Serialize}; -//! use stackable_operator::{CustomResourceExt, cli, crd}; +//! use stackable_operator::{CustomResourceExt, cli, shared::crd}; //! //! const OPERATOR_VERSION: &str = "23.1.1"; //! @@ -106,16 +106,17 @@ //! ``` //! //! -use crate::logging::TracingTarget; -use crate::namespace::WatchNamespace; -use clap::Args; -use product_config::ProductConfigManager; -use snafu::{ResultExt, Snafu}; use std::{ ffi::OsStr, path::{Path, PathBuf}, }; +use clap::Args; +use product_config::ProductConfigManager; +use snafu::{ResultExt, Snafu}; + +use crate::{logging::TracingTarget, namespace::WatchNamespace}; + pub const AUTHOR: &str = "Stackable GmbH - info@stackable.tech"; type Result = std::result::Result; diff --git a/crates/stackable-operator/src/crd.rs b/crates/stackable-operator/src/crd.rs index d0610a478..666e84ef6 100644 --- a/crates/stackable-operator/src/crd.rs +++ b/crates/stackable-operator/src/crd.rs @@ -2,40 +2,7 @@ use std::marker::PhantomData; use derivative::Derivative; use schemars::JsonSchema; -use semver::Version; use serde::{Deserialize, Serialize}; -use snafu::{ResultExt, Snafu}; - -use crate::yaml; -use std::fs::File; -use std::io::Write; -use std::path::Path; - -const DOCS_HOME_URL_PLACEHOLDER: &str = "DOCS_BASE_URL_PLACEHOLDER"; -const DOCS_HOME_BASE_URL: &str = "https://docs.stackable.tech/home"; - -type Result = std::result::Result; - -#[derive(Debug, Snafu)] -pub enum Error { - #[snafu(display("cannot parse version {version:?} as a semantic version"))] - InvalidSemverVersion { - source: semver::Error, - version: String, - }, - - #[snafu(display("error converting CRD byte array to UTF-8"))] - ConvertByteArrayToUtf8 { source: std::string::FromUtf8Error }, - - #[snafu(display("failed to serialize YAML"))] - SerializeYaml { source: yaml::Error }, - - #[snafu(display("failed to write YAML"))] - WriteYamlSchema { source: std::io::Error }, - - #[snafu(display("failed to create YAML file"))] - CreateYamlFile { source: std::io::Error }, -} /// A reference to a product cluster (for example, a `ZookeeperCluster`) /// @@ -96,83 +63,6 @@ pub trait HasApplication { fn get_application_name() -> &'static str; } -/// Takes an operator version and returns a docs version -fn docs_version(operator_version: &str) -> Result { - if operator_version == "0.0.0-dev" { - Ok("nightly".to_owned()) - } else { - let v = Version::parse(operator_version).context(InvalidSemverVersionSnafu { - version: operator_version.to_owned(), - })?; - Ok(format!("{}.{}", v.major, v.minor)) - } -} - -/// Given an operator version like 0.0.0-dev or 23.1.1, generate a docs home -/// component base URL like `https://docs.stackable.tech/home/nightly/` or -/// `https://docs.stackable.tech/home/23.1/`. -fn docs_home_versioned_base_url(operator_version: &str) -> Result { - Ok(format!( - "{}/{}", - DOCS_HOME_BASE_URL, - docs_version(operator_version)? - )) -} - -/// This trait can be implemented to allow automatic handling -/// (e.g. creation) of `CustomResourceDefinition`s in Kubernetes. -pub trait CustomResourceExt: kube::CustomResourceExt { - /// Generates a YAML CustomResourceDefinition and writes it to a `Write`. - /// - /// The generated YAML string is an explicit document with leading dashes (`---`). - fn generate_yaml_schema(mut writer: W, operator_version: &str) -> Result<()> - where - W: Write, - { - let mut buffer = Vec::new(); - yaml::serialize_to_explicit_document(&mut buffer, &Self::crd()) - .context(SerializeYamlSnafu)?; - - let yaml_schema = String::from_utf8(buffer) - .context(ConvertByteArrayToUtf8Snafu)? - .replace( - DOCS_HOME_URL_PLACEHOLDER, - &docs_home_versioned_base_url(operator_version)?, - ); - - writer - .write_all(yaml_schema.as_bytes()) - .context(WriteYamlSchemaSnafu) - } - - /// Generates a YAML CustomResourceDefinition and writes it to the specified file. - /// - /// The written YAML string is an explicit document with leading dashes (`---`). - fn write_yaml_schema>(path: P, operator_version: &str) -> Result<()> { - let writer = File::create(path).context(CreateYamlFileSnafu)?; - Self::generate_yaml_schema(writer, operator_version) - } - - /// Generates a YAML CustomResourceDefinition and prints it to stdout. - /// - /// The printed YAML string is an explicit document with leading dashes (`---`). - fn print_yaml_schema(operator_version: &str) -> Result<()> { - let writer = std::io::stdout(); - Self::generate_yaml_schema(writer, operator_version) - } - - /// Returns the YAML schema of this CustomResourceDefinition as a string. - /// - /// The written YAML string is an explicit document with leading dashes (`---`). - fn yaml_schema(operator_version: &str) -> Result { - let mut writer = Vec::new(); - Self::generate_yaml_schema(&mut writer, operator_version)?; - String::from_utf8(writer).context(ConvertByteArrayToUtf8Snafu) - } -} - -impl CustomResourceExt for T where T: kube::CustomResourceExt {} - #[cfg(test)] mod tests { use k8s_openapi::api::core::v1::ConfigMap; diff --git a/crates/stackable-operator/src/helm/mod.rs b/crates/stackable-operator/src/helm/mod.rs index f5bf85c0b..f987c8c71 100644 --- a/crates/stackable-operator/src/helm/mod.rs +++ b/crates/stackable-operator/src/helm/mod.rs @@ -79,7 +79,7 @@ mod tests { use rstest::rstest; - use crate::yaml::serialize_to_explicit_document; + use stackable_shared::yaml::{serialize as ser, SerializeOptions}; use super::*; @@ -102,7 +102,7 @@ mod tests { let expected = std::fs::read_to_string("fixtures/helm/output.yaml").unwrap(); let mut output = Vec::new(); - serialize_to_explicit_document(&mut output, &values).unwrap(); + ser(&values, &mut output, SerializeOptions::default()).unwrap(); assert_eq!(std::str::from_utf8(&output).unwrap(), expected); } diff --git a/crates/stackable-operator/src/lib.rs b/crates/stackable-operator/src/lib.rs index 6c748bb6e..50567fb88 100644 --- a/crates/stackable-operator/src/lib.rs +++ b/crates/stackable-operator/src/lib.rs @@ -20,10 +20,15 @@ pub mod status; pub mod time; pub mod utils; pub mod validation; -pub mod yaml; -pub use crate::crd::CustomResourceExt; +// Internal re-exports +pub use stackable_shared::{crd::CustomResourceExt, yaml::YamlSchema}; +pub mod shared { + pub use stackable_shared::*; +} + +// External re-exports pub use ::k8s_openapi; pub use ::kube; pub use ::schemars; diff --git a/crates/stackable-operator/src/yaml.rs b/crates/stackable-operator/src/yaml.rs deleted file mode 100644 index bea00be7b..000000000 --- a/crates/stackable-operator/src/yaml.rs +++ /dev/null @@ -1,72 +0,0 @@ -//! Utility functions for processing data in the YAML file format -use std::io::Write; - -use serde::ser; -use snafu::{ResultExt, Snafu}; - -type Result = std::result::Result; - -#[derive(Debug, Snafu)] -pub enum Error { - #[snafu(display("failed to serialize YAML"))] - SerializeYaml { source: serde_yaml::Error }, - - #[snafu(display("failed to write YAML document separator"))] - WriteDocumentSeparator { source: std::io::Error }, -} - -/// Serializes the given data structure as an explicit YAML document and writes it to a [`Write`]. -/// -/// Enums are serialized as a YAML map containing one entry in which the key identifies the variant -/// name. -/// -/// # Example -/// -/// ``` -/// use serde::Serialize; -/// use stackable_operator::yaml; -/// -/// #[derive(Serialize)] -/// #[serde(rename_all = "camelCase")] -/// enum Connection { -/// Inline(String), -/// Reference(String), -/// } -/// -/// #[derive(Serialize)] -/// struct Spec { -/// connection: Connection, -/// } -/// -/// let value = Spec { -/// connection: Connection::Inline("http://localhost".into()), -/// }; -/// -/// let mut buf = Vec::new(); -/// yaml::serialize_to_explicit_document(&mut buf, &value).unwrap(); -/// let actual_yaml = std::str::from_utf8(&buf).unwrap(); -/// -/// let expected_yaml = "--- -/// connection: -/// inline: http://localhost -/// "; -/// -/// assert_eq!(expected_yaml, actual_yaml); -/// ``` -/// -/// # Errors -/// -/// Serialization can fail if `T`'s implementation of `Serialize` decides to return an error. -pub fn serialize_to_explicit_document(mut writer: W, value: &T) -> Result<()> -where - T: ser::Serialize, - W: Write, -{ - writer - .write_all(b"---\n") - .context(WriteDocumentSeparatorSnafu)?; - let mut serializer = serde_yaml::Serializer::new(writer); - serde_yaml::with::singleton_map_recursive::serialize(value, &mut serializer) - .context(SerializeYamlSnafu)?; - Ok(()) -} diff --git a/crates/stackable-shared/CHANGELOG.md b/crates/stackable-shared/CHANGELOG.md new file mode 100644 index 000000000..8890b44be --- /dev/null +++ b/crates/stackable-shared/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [0.0.1] + +### Added + +- Add YAML and CRD helper functions and traits ([#883]). + +[#883]: https://github.com/stackabletech/operator-rs/pull/883 diff --git a/crates/stackable-shared/Cargo.toml b/crates/stackable-shared/Cargo.toml new file mode 100644 index 000000000..906b4ec72 --- /dev/null +++ b/crates/stackable-shared/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "stackable-shared" +version = "0.0.1" +authors.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true + +[dependencies] +kube.workspace = true +semver.workspace = true +serde.workspace = true +serde_yaml.workspace = true +snafu.workspace = true + +[dev-dependencies] +k8s-openapi.workspace = true diff --git a/crates/stackable-shared/src/crd.rs b/crates/stackable-shared/src/crd.rs new file mode 100644 index 000000000..4e40b38da --- /dev/null +++ b/crates/stackable-shared/src/crd.rs @@ -0,0 +1,56 @@ +use std::path::Path; + +use snafu::{ResultExt, Snafu}; + +use crate::yaml::{SerializeOptions, YamlSchema}; + +pub type Result = std::result::Result; + +#[derive(Debug, Snafu)] +pub enum Error { + #[snafu(display("failed to write CRD YAML schema to file"))] + WriteToFile { source: crate::yaml::Error }, + + #[snafu(display("failed to write CRD YAML schema to stdout"))] + WriteToStdout { source: crate::yaml::Error }, + + #[snafu(display("failed to generate CRD YAML schema"))] + GenerateSchema { source: crate::yaml::Error }, +} + +/// Provides YAML schema generation and output capabilities for Kubernetes custom resources. +pub trait CustomResourceExt: kube::CustomResourceExt { + /// Generates the YAML schema of a `CustomResourceDefinition` and writes it to the specified + /// file at `path`. + /// + /// It additionally replaces the documentation URL placeholder with the correct value based on + /// the provided `operator_version`. The written YAML string is an explicit document with + /// leading dashes (`---`). + fn write_yaml_schema>(path: P, operator_version: &str) -> Result<()> { + Self::crd() + .write_yaml_schema(path, operator_version, SerializeOptions::default()) + .context(WriteToFileSnafu) + } + + /// Generates the YAML schema of a `CustomResourceDefinition` and prints it to [stdout]. + /// + /// It additionally replaces the documentation URL placeholder with the correct value based on + /// the provided `operator_version`. The written YAML string is an explicit document with + /// leading dashes (`---`). + /// + /// [stdout]: std::io::stdout + fn print_yaml_schema(operator_version: &str) -> Result<()> { + Self::crd() + .print_yaml_schema(operator_version, SerializeOptions::default()) + .context(WriteToStdoutSnafu) + } + + /// Generates the YAML schema of a `CustomResourceDefinition` and returns it as a [`String`]. + fn yaml_schema(operator_version: &str) -> Result { + Self::crd() + .generate_yaml_schema(operator_version, SerializeOptions::default()) + .context(GenerateSchemaSnafu) + } +} + +impl CustomResourceExt for T where T: kube::CustomResourceExt {} diff --git a/crates/stackable-shared/src/lib.rs b/crates/stackable-shared/src/lib.rs new file mode 100644 index 000000000..ea8e41a91 --- /dev/null +++ b/crates/stackable-shared/src/lib.rs @@ -0,0 +1,5 @@ +//! This crate contains various shared helpers and utilities used across other crates in this +//! workspace. + +pub mod crd; +pub mod yaml; diff --git a/crates/stackable-shared/src/yaml.rs b/crates/stackable-shared/src/yaml.rs new file mode 100644 index 000000000..04ef83770 --- /dev/null +++ b/crates/stackable-shared/src/yaml.rs @@ -0,0 +1,165 @@ +//! Utility functions for processing data in the YAML file format +use std::{io::Write, path::Path}; + +use semver::Version; +use snafu::{ResultExt, Snafu}; + +const STACKABLE_DOCS_HOME_URL_PLACEHOLDER: &str = "DOCS_BASE_URL_PLACEHOLDER"; +const STACKABLE_DOCS_HOME_BASE_URL: &str = "https://docs.stackable.tech/home"; + +type Result = std::result::Result; + +/// Represents every error which can be encountered during YAML serialization. +#[derive(Debug, Snafu)] +pub enum Error { + #[snafu(display("failed to serialize YAML"))] + SerializeYaml { source: serde_yaml::Error }, + + #[snafu(display("failed to write YAML document separator"))] + WriteDocumentSeparator { source: std::io::Error }, + + #[snafu(display("failed to write YAML to file"))] + WriteToFile { source: std::io::Error }, + + #[snafu(display("failed to write YAML to stdout"))] + WriteToStdout { source: std::io::Error }, + + #[snafu(display("failed to parse {input:?} as semantic version"))] + ParseSemanticVersion { + source: semver::Error, + input: String, + }, + + #[snafu(display("failed to parse bytes as valid UTF-8 string"))] + ParseUtf8Bytes { source: std::string::FromUtf8Error }, +} + +pub(crate) struct DocUrlReplacer(Version); + +impl DocUrlReplacer { + pub(crate) fn new(operator_version: &str) -> Result { + let version = operator_version + .parse() + .context(ParseSemanticVersionSnafu { + input: operator_version, + })?; + + Ok(Self(version)) + } + + fn replace(&self, input: &str) -> String { + let docs_version = match ( + self.0.major, + self.0.minor, + self.0.patch, + self.0.pre.as_str(), + ) { + (0, 0, 0, "dev") => "nightly".to_owned(), + (major, minor, ..) => { + format!("{major}.{minor}") + } + }; + + input.replace( + STACKABLE_DOCS_HOME_URL_PLACEHOLDER, + &format!("{STACKABLE_DOCS_HOME_BASE_URL}/{docs_version}"), + ) + } +} + +/// Provides configurable options during YAML serialization. +/// +/// For most people the default implementation [`SerializeOptions::default()`] is sufficient as it +/// enables explicit document and singleton map serialization. +pub struct SerializeOptions { + /// Adds leading triple dashes (`---`) to the output string. + pub explicit_document: bool, + + /// Serialize enum variants as YAML maps using the variant name as the key. + pub singleton_map: bool, +} + +impl Default for SerializeOptions { + fn default() -> Self { + Self { + explicit_document: true, + singleton_map: true, + } + } +} + +/// Serializes any type `T` which is [serializable](serde::Serialize) as YAML using the provided +/// [`SerializeOptions`]. +/// +/// It additionally replaces the documentation URL placeholder with the correct value based on the +/// provided `operator_version`. +pub trait YamlSchema: Sized + serde::Serialize { + /// Generates the YAML schema of `self` using the provided [`SerializeOptions`]. + fn generate_yaml_schema( + &self, + operator_version: &str, + options: SerializeOptions, + ) -> Result { + let mut buffer = Vec::new(); + + serialize(&self, &mut buffer, options)?; + + let yaml_string = String::from_utf8(buffer).context(ParseUtf8BytesSnafu)?; + + let replacer = DocUrlReplacer::new(operator_version)?; + let yaml_string = replacer.replace(&yaml_string); + + Ok(yaml_string) + } + + /// Generates and write the YAML schema of `self` to a file at `path` using the provided + /// [`SerializeOptions`]. + fn write_yaml_schema>( + &self, + path: P, + operator_version: &str, + options: SerializeOptions, + ) -> Result<()> { + let schema = self.generate_yaml_schema(operator_version, options)?; + std::fs::write(path, schema).context(WriteToFileSnafu) + } + + /// Generates and prints the YAML schema of `self` to stdout at `path` using the provided + /// [`SerializeOptions`]. + fn print_yaml_schema(&self, operator_version: &str, options: SerializeOptions) -> Result<()> { + let schema = self.generate_yaml_schema(operator_version, options)?; + + let mut writer = std::io::stdout(); + writer + .write_all(schema.as_bytes()) + .context(WriteToStdoutSnafu) + } +} + +impl YamlSchema for T where T: serde::ser::Serialize {} + +/// Serializes the given data structure and writes it to a [`Writer`](Write). +pub fn serialize(value: &T, mut writer: W, options: SerializeOptions) -> Result<()> +where + T: serde::Serialize, + W: std::io::Write, +{ + if options.explicit_document { + writer + .write_all(b"---\n") + .context(WriteDocumentSeparatorSnafu)?; + } + + let mut serializer = serde_yaml::Serializer::new(writer); + + if options.singleton_map { + serde_yaml::with::singleton_map_recursive::serialize(value, &mut serializer) + .context(SerializeYamlSnafu)?; + } else { + value + .serialize(&mut serializer) + .context(SerializeYamlSnafu)?; + } + + Ok(()) +}